openvisio-agent 0.19.0 → 0.19.2

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,13 +58,15 @@ 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.
62
+
61
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.
62
64
 
63
65
  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.
64
66
 
65
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.
66
68
 
67
- 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.
68
70
 
69
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.
70
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
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": {
@@ -32,6 +32,7 @@ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
32
32
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
33
33
  const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
34
34
  const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
35
+ const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
35
36
  const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
36
37
  const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
37
38
  const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
@@ -55,7 +56,8 @@ const assertions = [
55
56
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
56
57
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
57
58
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
58
- ['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')],
59
61
  ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
60
62
  ['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'")],
61
63
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
@@ -74,6 +76,7 @@ const assertions = [
74
76
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
75
77
  ['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:')],
76
78
  ['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
79
+ ['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'" )],
77
80
  ['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunner = createCycleRunner')],
78
81
  ['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)')],
79
82
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
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) }
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { AcpAgent } from '@mastra/acp'
4
4
  import { onPath } from './lib.mjs'
5
+ import { resolveAvailableModel } from './model-selection.mjs'
5
6
 
6
7
  const MCP_TOOL_NAMES = [
7
8
  'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
@@ -97,7 +98,6 @@ export function createMastraAcpRunner({
97
98
  mcpServers,
98
99
  },
99
100
  persistSession: false,
100
- ...(cycleModel || model ? { model: cycleModel || model } : {}),
101
101
  onPermissionRequest: async ({ options }) => {
102
102
  const kind = canCode ? 'allow_once' : 'reject_once'
103
103
  const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
@@ -114,9 +114,22 @@ export function createMastraAcpRunner({
114
114
  }) : undefined,
115
115
  })
116
116
  active = { acp, controller }
117
- const timer = setTimeout(() => { timedOut = true; controller.abort() }, maxCycleMs)
118
- log(`running ${agent} cycle via Mastra ACP…${cycleModel || model ? ` [${cycleModel || model}]` : ''}`)
117
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); acp.connection.disconnect() }, maxCycleMs)
118
+ const requestedModel = cycleModel || model || ''
119
+ let selectedModel = ''
119
120
  try {
121
+ if (requestedModel) {
122
+ const availableModels = await acp.getAvailableModels()
123
+ const resolution = resolveAvailableModel(requestedModel, availableModels)
124
+ selectedModel = resolution.selected
125
+ if (selectedModel) {
126
+ await acp.setModel(selectedModel)
127
+ if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
128
+ } else {
129
+ log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
130
+ }
131
+ }
132
+ log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
120
133
  const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
121
134
  for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
122
135
  if (event.type === 'text') { outputText += event.text; continue }
@@ -152,7 +165,7 @@ export function createMastraAcpRunner({
152
165
  didResultMessage = didChannelMessage && didRepoMutation
153
166
  log(`${agent} cycle done via Mastra ACP (ok)`)
154
167
  return {
155
- type: 'result', subtype: 'ok', runtime: 'mastra-acp', outputText: outputText.trim(),
168
+ type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
156
169
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
157
170
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
158
171
  didMcpTaskRead, didMcpTaskUpdate,
@@ -162,7 +175,7 @@ export function createMastraAcpRunner({
162
175
  const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
163
176
  log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
164
177
  return {
165
- type: 'result', subtype, runtime: 'mastra-acp', outputText: outputText.trim(),
178
+ type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
166
179
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
167
180
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
168
181
  didMcpTaskRead, didMcpTaskUpdate,
@@ -178,6 +191,7 @@ export function createMastraAcpRunner({
178
191
  if (!active) return
179
192
  active.controller.abort()
180
193
  try { await active.acp.connection.cancel() } catch { /* already stopped */ }
194
+ active.acp.connection.disconnect()
181
195
  }
182
196
 
183
197
  log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
@@ -0,0 +1,75 @@
1
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
2
+ const DEFAULT_EFFORT = 'medium'
3
+
4
+ const text = (value) => String(value ?? '').trim()
5
+ const lower = (value) => text(value).toLowerCase()
6
+
7
+ function parts(value) {
8
+ const raw = text(value)
9
+ const effortMatch = /\[([^\]]+)\]$/.exec(raw)
10
+ const withoutEffort = effortMatch ? raw.slice(0, effortMatch.index) : raw
11
+ const path = lower(withoutEffort).split('/').filter(Boolean)
12
+ const leaf = path.at(-1) || ''
13
+ const tokens = leaf.split(/[^a-z0-9]+/).filter(Boolean)
14
+ return { raw, id: lower(raw), base: lower(withoutEffort), leaf, tokens, provider: path.slice(0, -1).join('/'), effort: lower(effortMatch?.[1]) }
15
+ }
16
+
17
+ const effortDistance = (actual, preferred) => {
18
+ const actualIndex = EFFORTS.indexOf(actual)
19
+ const preferredIndex = EFFORTS.indexOf(preferred)
20
+ if (actualIndex < 0) return 20
21
+ return Math.abs(actualIndex - (preferredIndex < 0 ? EFFORTS.indexOf(DEFAULT_EFFORT) : preferredIndex))
22
+ }
23
+
24
+ function similarity(requested, candidate, preferredEffort) {
25
+ if (candidate.id === requested.id) return 100_000
26
+ let score = 0
27
+ if (candidate.base === requested.base || candidate.leaf === requested.leaf) score += 50_000
28
+ if (requested.provider && candidate.provider === requested.provider) score += 2_000
29
+ const requestedTokens = new Set(requested.tokens)
30
+ const shared = candidate.tokens.filter((token) => requestedTokens.has(token)).length
31
+ score += shared * 1_000
32
+ if (requested.leaf && (candidate.leaf.includes(requested.leaf) || requested.leaf.includes(candidate.leaf))) score += 10_000
33
+ let prefix = 0
34
+ while (prefix < requested.leaf.length && prefix < candidate.leaf.length && requested.leaf[prefix] === candidate.leaf[prefix]) prefix++
35
+ score += prefix * 10
36
+ score -= effortDistance(candidate.effort, requested.effort || preferredEffort) * 100
37
+ return score
38
+ }
39
+
40
+ export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
41
+ const requested = parts(requestedValue)
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: [] }
44
+ if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
45
+
46
+ const ranked = choices.map((id, index) => {
47
+ const candidate = parts(id)
48
+ return { id, index, score: similarity(requested, candidate, preferredEffort) }
49
+ }).sort((a, b) => b.score - a.score || a.index - b.index)
50
+ const winner = ranked[0]
51
+ return {
52
+ requested: requested.raw,
53
+ selected: winner.id,
54
+ reason: lower(winner.id) === requested.id ? 'exact' : parts(winner.id).base === requested.base || parts(winner.id).leaf === requested.leaf ? 'family-effort' : 'nearest',
55
+ available: choices,
56
+ }
57
+ }
58
+
59
+ const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
60
+
61
+ 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)
66
+ }
67
+
68
+ 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
+ }
75
+
package/src/watch.mjs CHANGED
@@ -20,6 +20,7 @@ import { buildCodexMcpOverride } from './codex-config.mjs'
20
20
  import { createCycleQueue } from './cycle-queue.mjs'
21
21
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
22
  import { createMastraAcpRunner } from './mastra-harness.mjs'
23
+ import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
23
24
 
24
25
  function findTicketWorktree(workspaceRoot, ticketId) {
25
26
  if (!workspaceRoot || ticketId == null) return ''
@@ -613,7 +614,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
613
614
  // The model the CURRENT session was spawned with. runCycle can pass a different
614
615
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
615
616
  // session so the new model takes effect.
616
- let sessionModel = model || null
617
+ let sessionModel = resolveClaudeModel(model) || null
617
618
  let turnsThisSession = 0
618
619
  let sessionStartedAt = 0
619
620
  let resolveTurn = null
@@ -670,7 +671,8 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
670
671
  if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
671
672
  // The charter + creds ride in the system prompt (cacheable → not re-billed each
672
673
  // cycle), leaving only the small per-cycle instruction in the user message.
673
- const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
674
+ const fallbackModels = claudeFallbackModels(sessionModel)
675
+ const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
674
676
  const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
675
677
  const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
676
678
  child = c
@@ -731,9 +733,11 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
731
733
  return new Promise((resolve) => {
732
734
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
733
735
  // change means the warm session must be respawned with the new --model.
734
- if (cycleModel && cycleModel !== sessionModel) {
735
- if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + cycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
736
- sessionModel = cycleModel
736
+ const resolvedCycleModel = resolveClaudeModel(cycleModel)
737
+ if (resolvedCycleModel && resolvedCycleModel !== sessionModel) {
738
+ if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + resolvedCycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
739
+ if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
740
+ sessionModel = resolvedCycleModel
737
741
  }
738
742
  if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
739
743
  log('recycling session (turns=' + turnsThisSession + ')')
@@ -1705,11 +1709,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1705
1709
  const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
1706
1710
  const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
1707
1711
  const threadRoot = parent != null ? parent : mid
1712
+ const controlKey = threadControlKey(cid, threadRoot)
1713
+ const threadOwned = !!controlKey && memory.has(controlKey, 'active')
1714
+ const mentionKeys = mentionDedupeKeys(msg, cid)
1715
+ const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1708
1716
  // De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
1709
1717
  // NOT trigger a second reply. Key by message id, or a channel+text signature
1710
- // when the payload carries no id.
1711
- if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) skipped'); return }
1712
- const target = classifyConversationTarget(msg, [...selfAliases])
1718
+ // when the payload carries no id. Older recipient filtering acknowledged
1719
+ // some owned-thread follow-ups before routing them. If such a message is
1720
+ // replayed, its replay key exists but its received ledger entry does not;
1721
+ // admit it once so the corrected ownership rule can recover the message.
1722
+ if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) {
1723
+ if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
1724
+ log('agent:mention replay recovered for active owned thread')
1725
+ }
1726
+ const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
1713
1727
  if (target.action === 'ignore') {
1714
1728
  log('agent:mention ignored before model start (' + target.reason + ')')
1715
1729
  // A redirect or another agent taking over invalidates any older work or
@@ -1720,8 +1734,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1720
1734
  }
1721
1735
  return
1722
1736
  }
1723
- const mentionKeys = mentionDedupeKeys(msg, cid)
1724
- const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
1725
1737
  if (target.action === 'stand_down') {
1726
1738
  if (cid != null && threadRoot != null) {
1727
1739
  cancelThread(cid, threadRoot, text || 'Explicit stand-down')