openvisio-agent 0.19.12 → 0.20.0

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.
@@ -1,26 +1,33 @@
1
1
  const normalize = (name) => String(name || '').toLowerCase().replace(/^#/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
2
- const validId = (id) => id != null && Number.isSafeInteger(Number(id)) && Number(id) > 0
2
+ const validId = (id) => (typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id))) && Number.isSafeInteger(Number(id)) && Number(id) > 0
3
3
 
4
4
  export function repliesAfterSource(messages, sourceMessageId) {
5
- if (sourceMessageId == null) return messages
5
+ const rows = Array.isArray(messages) ? messages : []
6
+ if (sourceMessageId == null) return rows
6
7
  // Backend message ids are monotonically increasing database ids. For opaque
7
8
  // ids, rely on the persisted source delivery key rather than suppressing a
8
9
  // new question because the agent replied to an earlier one in this thread.
9
10
  if (!/^\d+$/.test(String(sourceMessageId))) return []
10
- return messages.filter((message) => /^\d+$/.test(String(message.id)) && BigInt(message.id) > BigInt(sourceMessageId))
11
+ return rows.filter((message) => message && /^\d+$/.test(String(message.id)) && BigInt(message.id) > BigInt(sourceMessageId))
11
12
  }
12
13
 
13
14
  export function dedicatedChannel(channels, { id, identifier, aliases = [], channelId } = {}) {
14
- const usable = channels.filter((channel) => validId(channel?.id))
15
+ const usable = (Array.isArray(channels) ? channels : []).filter((channel) => validId(channel?.id))
16
+ if (channelId != null && !validId(channelId)) return null
15
17
  if (validId(channelId)) return usable.find((channel) => Number(channel.id) === Number(channelId)) || null
16
- const owned = usable.filter((channel) =>
17
- (id != null && channel.agent_id != null && String(channel.agent_id) === String(id)) ||
18
- (identifier && channel.agent_identifier === identifier))
18
+ const ownerId = (channel) => channel.agent_id ?? channel.agentId
19
+ const ownerIdentifier = (channel) => channel.agent_identifier ?? channel.agentIdentifier
20
+ const owned = usable.filter((channel) => {
21
+ if (id != null && ownerId(channel) != null) return String(ownerId(channel)) === String(id)
22
+ return identifier && ownerIdentifier(channel) === identifier
23
+ })
19
24
  if (owned.length) return owned.length === 1 ? owned[0] : null
20
25
  const names = new Set([identifier, ...aliases].filter(Boolean).flatMap((alias) => {
21
26
  const name = normalize(alias)
22
27
  return [name, `agent-${name}`, `${name}-agent`]
23
28
  }).filter((name) => !['general', 'team', 'project', 'dev', 'development'].includes(name)))
24
- const matches = usable.filter((channel) => names.has(normalize(channel.name)))
29
+ // Explicit ownership always outranks a similar name. A recycled agent name
30
+ // must never route a new thread into somebody else's dedicated channel.
31
+ const matches = usable.filter((channel) => ownerId(channel) == null && !ownerIdentifier(channel) && names.has(normalize(channel.name)))
25
32
  return matches.length === 1 ? matches[0] : null
26
33
  }
@@ -5,35 +5,80 @@ import { createMcpHttpClient } from './mcp-http.mjs'
5
5
 
6
6
  export function toolWithoutCredentialInputs(tool) {
7
7
  const copy = structuredClone(tool)
8
- const schema = copy.inputSchema ?? copy.input_schema
9
- if (!schema || typeof schema !== 'object') return copy
10
- if (schema.properties && typeof schema.properties === 'object') {
11
- delete schema.properties.agent_api_key
12
- delete schema.properties.agent_identifier
13
- }
14
- if (Array.isArray(schema.required)) {
15
- schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
8
+ for (const schema of [copy.inputSchema, copy.input_schema]) {
9
+ if (!schema || typeof schema !== 'object') continue
10
+ if (schema.properties && typeof schema.properties === 'object') {
11
+ delete schema.properties.agent_api_key
12
+ delete schema.properties.agent_identifier
13
+ }
14
+ if (Array.isArray(schema.required)) {
15
+ schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
16
+ }
16
17
  }
17
18
  return copy
18
19
  }
19
20
 
20
21
  export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
21
22
  const tool = String(name || '')
22
- return !disabled.has(tool) && (!(allowed instanceof Set) || allowed.has(tool))
23
+ return !!tool && !disabled.has(tool) && (!(allowed instanceof Set) || allowed.has(tool))
24
+ }
25
+
26
+ export function agentResultWithoutCredentials(result) {
27
+ const credentials = new Set()
28
+ const credentialFields = new Set(['apikey', 'agentapikey', 'password', 'passwordhash', 'accesstoken', 'refreshtoken', 'clientsecret', 'secret', 'token'])
29
+ const strip = (value) => {
30
+ if (Array.isArray(value)) return value.map(strip)
31
+ if (!value || typeof value !== 'object') return value
32
+ return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
33
+ if (credentialFields.has(key.replace(/[_-]/g, '').toLowerCase())) {
34
+ if (typeof item === 'string' && item) credentials.add(item)
35
+ return []
36
+ }
37
+ return [[key, strip(item)]]
38
+ }))
39
+ }
40
+ const copy = strip(result)
41
+ const parsedText = new Map()
42
+ for (const item of Array.isArray(copy?.content) ? copy.content : []) {
43
+ if (item?.type !== 'text' || typeof item.text !== 'string' || !/^\s*[\[{]/.test(item.text)) continue
44
+ let data
45
+ try { data = JSON.parse(item.text) }
46
+ catch { throw new Error('MCP agent directory returned malformed JSON') }
47
+ parsedText.set(item, strip(data))
48
+ }
49
+ // The backend can include the same records in structuredContent and in a
50
+ // rendered markdown summary. Gather secrets first, then redact every rendering.
51
+ const redact = (value) => {
52
+ if (typeof value === 'string') {
53
+ for (const secret of credentials) value = value.split(secret).join('[redacted]')
54
+ return value
55
+ }
56
+ if (Array.isArray(value)) return value.map(redact)
57
+ if (!value || typeof value !== 'object') return value
58
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]))
59
+ }
60
+ for (const [item, data] of parsedText) item.text = JSON.stringify(redact(data))
61
+ return redact(copy)
62
+ }
63
+
64
+ export function proxyToolPolicyFromEnvironment(env = process.env) {
65
+ const parse = (key, fallback) => {
66
+ let value
67
+ try { value = JSON.parse(env[key] ?? fallback) }
68
+ catch { throw new Error(`Invalid MCP tool policy: ${key}`) }
69
+ if (value !== null && (!Array.isArray(value) || value.some((name) => typeof name !== 'string' || !name))) throw new Error(`Invalid MCP tool policy: ${key}`)
70
+ return value
71
+ }
72
+ const disabled = parse('OPENVISIO_CODEX_DISABLED_TOOLS', '[]')
73
+ const allowed = parse('OPENVISIO_CODEX_ALLOWED_TOOLS', 'null')
74
+ return { disabled: new Set(disabled || []), allowed: allowed === null ? null : new Set(allowed) }
23
75
  }
24
76
 
25
77
  export function runCodexMcpProxy() {
26
78
  const url = process.env.OPENVISIO_CODEX_MCP_URL
27
79
  const apiKey = process.env.OPENVISIO_CODEX_API_KEY
28
80
  const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
29
- let disabledTools = []
30
- try { disabledTools = JSON.parse(process.env.OPENVISIO_CODEX_DISABLED_TOOLS || '[]') } catch { /* no disabled tools */ }
31
- const disabled = new Set(Array.isArray(disabledTools) ? disabledTools.map(String) : [])
32
- let allowedTools = null
33
- try {
34
- const parsed = JSON.parse(process.env.OPENVISIO_CODEX_ALLOWED_TOOLS || 'null')
35
- if (Array.isArray(parsed)) allowedTools = new Set(parsed.map(String))
36
- } catch { /* unrestricted except for disabled tools */ }
81
+ const { disabled, allowed: allowedTools } = proxyToolPolicyFromEnvironment()
37
82
  if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
38
83
  const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
39
84
  const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
@@ -77,7 +122,7 @@ export function runCodexMcpProxy() {
77
122
  if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
78
123
  result = await client.callTool(name, request.params?.arguments || {})
79
124
  }
80
- reply(id, result)
125
+ reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
81
126
  } else if (id != null) {
82
127
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
83
128
  }
@@ -1,12 +1,14 @@
1
1
  export async function mapConcurrent(items, concurrency, run) {
2
2
  if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('Invalid concurrency')
3
3
  const results = new Array(items.length)
4
- let next = 0
4
+ let next = 0, failed = false, failure
5
5
  await Promise.all(Array.from({ length: Math.min(items.length, concurrency) }, async () => {
6
- while (next < items.length) {
6
+ while (!failed && next < items.length) {
7
7
  const index = next++
8
- results[index] = await run(items[index], index)
8
+ try { results[index] = await run(items[index], index) }
9
+ catch (error) { if (!failed) failure = error; failed = true }
9
10
  }
10
11
  }))
12
+ if (failed) throw failure
11
13
  return results
12
14
  }
@@ -8,6 +8,9 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
8
8
  const active = new Map()
9
9
  const activeGroups = new Set()
10
10
  let sequence = 0
11
+ const report = (error, item) => {
12
+ try { Promise.resolve(onError(error, item)).catch(() => {}) } catch { /* logging cannot strand the queue */ }
13
+ }
11
14
 
12
15
  const pump = () => {
13
16
  while (active.size < concurrency && pending.size) {
@@ -24,7 +27,7 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
24
27
  void (async () => {
25
28
  try { entry.resolve(await run(entry.item)) }
26
29
  catch (error) {
27
- try { onError(error, entry.item) } catch { /* logging cannot strand the queue */ }
30
+ report(error, entry.item)
28
31
  entry.resolve({ status: 'failed' })
29
32
  } finally {
30
33
  active.delete(key)
@@ -41,7 +44,8 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
41
44
  if (pending.has(key)) return pending.get(key).promise
42
45
  let resolve
43
46
  const promise = new Promise((done) => { resolve = done })
44
- const group = String(groupKey(item) || '')
47
+ const rawGroup = groupKey(item)
48
+ const group = rawGroup == null ? '' : String(rawGroup)
45
49
  pending.set(key, { item, promise, resolve, group })
46
50
  pump()
47
51
  return promise
@@ -52,7 +56,16 @@ export function createCycleQueue({ run, onError = () => {}, concurrency = 1, gro
52
56
  pending.delete(key)
53
57
  entry.resolve({ status: 'canceled' })
54
58
  }
55
- for (const entry of active.values()) if (predicate(entry.item)) cancelActive(entry.item)
59
+ for (const entry of active.values()) {
60
+ if (!predicate(entry.item)) continue
61
+ try { Promise.resolve(cancelActive(entry.item)).catch((error) => report(error, entry.item)) }
62
+ catch (error) { report(error, entry.item) }
63
+ }
64
+ },
65
+ async settled() {
66
+ // Capture work already accepted. Shutdown first stops intake and cancels
67
+ // pending entries, then waits for active workers to finish their cleanup.
68
+ await Promise.all([...active.values(), ...pending.values()].map((entry) => entry.promise))
56
69
  },
57
70
  has(key) { return active.has(key) || pending.has(key) },
58
71
  get size() { return pending.size + active.size },
package/src/events.mjs CHANGED
@@ -23,7 +23,15 @@ export function taskFromEvent(payload) {
23
23
 
24
24
  export function taskAgentId(task) {
25
25
  if (!task || typeof task !== 'object') return null
26
- return task.agent_id != null ? task.agent_id : (task.agentId != null ? task.agentId : null)
26
+ return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
27
+ }
28
+
29
+ export function taskBelongsToAgent(task, { id, identifier } = {}) {
30
+ if (!task || typeof task !== 'object') return false
31
+ const assignedId = taskAgentId(task)
32
+ if (assignedId != null && id != null) return String(assignedId) === String(id)
33
+ const assignedIdentifier = task.agent_identifier ?? task.agentIdentifier ?? task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? task.assignedAgent?.identifier ?? task.assignedAgent?.slug
34
+ return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
27
35
  }
28
36
 
29
37
  // Backend task ids are database plumbing. Teammates identify tickets by the
@@ -87,9 +95,9 @@ export function agentAddedByName(agent) {
87
95
  return personDisplayName(addedBy)
88
96
  }
89
97
 
90
- export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '' } = {}) {
98
+ export function buildTaskCompletionReport(task, { projectId, fallbackText = '', recipientName = '', completedTypeIds = new Set(), reviewTypeIds = new Set() } = {}) {
91
99
  if (!task || typeof task !== 'object' || task.id == null) return null
92
- if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
100
+ if (!taskIsCompleted(task, completedTypeIds) && !taskIsAwaitingReview(task, reviewTypeIds)) return null
93
101
 
94
102
  const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
95
103
  const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
@@ -97,7 +105,7 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '',
97
105
 
98
106
  const requester = String(recipientName || '').trim()
99
107
  const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
100
- const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
108
+ const status = String(task.type?.name || task.task_type?.name || task.status || task.state || (taskIsCompleted(task, completedTypeIds) ? 'completed' : 'review')).replace(/\s+/g, ' ').trim()
101
109
  const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
102
110
  const mention = requester ? `@${requester} ` : ''
103
111
  const slug = ticketDisplaySlug(task)
@@ -137,9 +145,9 @@ export function opencodeEventEvidence(event) {
137
145
  const command = String(input.command ?? input.cmd ?? '')
138
146
 
139
147
  const lowerTool = tool.toLowerCase()
140
- const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
148
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:-watcher|_watcher)?(?:__|[_.:/-])(.+)$/i.exec(tool)
141
149
  const bareTool = lowerTool.replace(/[-.]/g, '_')
142
- const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
150
+ const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|create_task_comment|comment_ticket|react_message|list_projects|list_agents|list_activity|list_codebases|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
143
151
  const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
144
152
  const codebaseMutation = /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool)
145
153
  const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
@@ -156,7 +164,7 @@ export function opencodeEventEvidence(event) {
156
164
  didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
157
165
  didMcpTaskRead: completed && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
158
166
  didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
159
- didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
167
+ didMessage: completed && /^(?:post_message|create_task_comment|comment_ticket)$/.test(mcpTool),
160
168
  didChannelMessage: completed && mcpTool === 'post_message',
161
169
  }
162
170
  }
@@ -186,7 +194,7 @@ export function codexEventEvidence(event) {
186
194
  if (itemType !== 'mcp_tool_call') return {}
187
195
 
188
196
  const rawTool = String(item.tool ?? item.name ?? item.method ?? 'unknown')
189
- const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
197
+ const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:-watcher|_watcher)?(?:__|[_.:/-])(.+)$/i.exec(rawTool)
190
198
  const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
191
199
  const succeeded = completed && !failed
192
200
  const resultText = item.result?.content?.find?.((part) => part?.type === 'text')?.text
@@ -201,7 +209,7 @@ export function codexEventEvidence(event) {
201
209
  didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
202
210
  didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
203
211
  didMcpTaskUpdate: succeeded && mcpTool === 'update_ticket',
204
- didMessage: succeeded && /^(?:post_message|comment_ticket)$/.test(mcpTool),
212
+ didMessage: succeeded && /^(?:post_message|create_task_comment|comment_ticket)$/.test(mcpTool),
205
213
  didChannelMessage: succeeded && mcpTool === 'post_message',
206
214
  }
207
215
  }
@@ -352,7 +360,7 @@ export function shouldSuppressCodexDiagnostic(value) {
352
360
  export function mentionDedupeKeys(message, channelId) {
353
361
  const m = message && typeof message === 'object' ? message : {}
354
362
  const id = m.message_id ?? m.messageId ?? m.id
355
- const parent = m.parent_id ?? m.parentId ?? ''
363
+ const parent = messageParentId(m) ?? ''
356
364
  const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 180)
357
365
  return {
358
366
  idKey: id != null ? `id:${id}` : '',
@@ -360,10 +368,46 @@ export function mentionDedupeKeys(message, channelId) {
360
368
  }
361
369
  }
362
370
 
371
+ // Keep transport aliases consistent at intake, ownership verification and
372
+ // delivery deduplication. Unknown objects are not usable thread identities.
373
+ export function messageParentId(message) {
374
+ if (!message || typeof message !== 'object') return null
375
+ for (const key of ['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo']) {
376
+ let value = message[key]
377
+ if (value && typeof value === 'object' && !Array.isArray(value)) value = value.message_id ?? value.messageId ?? value.id
378
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
379
+ if (typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0) return value
380
+ }
381
+ return null
382
+ }
383
+
363
384
  export function normalizeRenderedMessageText(value) {
364
385
  return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
365
386
  }
366
387
 
388
+ export function messageSenderIsSelf(row, identity = {}) {
389
+ if (!row || typeof row !== 'object') return false
390
+ const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
391
+ const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
392
+ const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
393
+ const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
394
+ const agent = expanded || (senderLooksLikeAgent ? sender : {})
395
+ const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
396
+ const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
397
+ const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
398
+ const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
399
+ const stableAliases = new Set([identity.identifier, identity.slug].map((item) => String(item || '').toLowerCase()).filter(Boolean))
400
+ const hasId = (id) => id != null && String(id).trim() !== ''
401
+ // Stable identifiers are authoritative. Display names may be recycled or
402
+ // shared with a teammate and cannot override a conflicting agent id.
403
+ const isSelf = hasId(identity.id) && hasId(agentId)
404
+ ? String(agentId) === String(identity.id)
405
+ : identifier && stableAliases.size
406
+ ? stableAliases.has(identifier.toLowerCase())
407
+ : aliases.has(identifier.toLowerCase()) || (!!expanded && !identifier && aliases.has(name.toLowerCase()))
408
+ return !!isSelf
409
+ }
410
+
367
411
  // Extract only messages visibly authored by this agent from the backend's live
368
412
  // thread response. REST and WebSocket payloads use several sender shapes, so this
369
413
  // mirrors the frontend normalizer instead of trusting one field name.
@@ -376,25 +420,15 @@ export function renderedAgentMessages(value, identity = {}) {
376
420
  if (typeof node !== 'object') return
377
421
  const row = node
378
422
  const content = row.content ?? row.body ?? row.text ?? (typeof row.message === 'string' ? row.message : undefined)
379
- const rowId = row.id ?? row.message_id ?? row.messageId
423
+ const rowId = row.message_id ?? row.messageId ?? row.id
380
424
  if (content != null && rowId != null) {
381
- const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
382
- const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
383
- const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
384
- const senderLooksLikeAgent = /agent|bot/.test(senderKind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
385
- const agent = expanded || (senderLooksLikeAgent ? sender : {})
386
- const agentId = Number(agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? NaN)
387
- const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
388
- const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
389
- const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
390
- const isSelf = (Number.isFinite(Number(identity.id)) && agentId === Number(identity.id)) || aliases.has(identifier.toLowerCase()) || (!!expanded && aliases.has(name.toLowerCase()))
391
- if (isSelf) {
425
+ if (messageSenderIsSelf(row, identity)) {
392
426
  const key = String(rowId)
393
427
  if (!seen.has(key)) {
394
428
  seen.add(key)
395
429
  rows.push({
396
430
  id: rowId,
397
- parentId: row.parent_id ?? row.parentId ?? row.thread_id ?? null,
431
+ parentId: messageParentId(row),
398
432
  content: String(content),
399
433
  })
400
434
  }
@@ -425,14 +459,14 @@ function conversationMentions(value, selfAliases) {
425
459
  for (const alias of aliases) {
426
460
  // A sentence-ending period is punctuation, while a period followed by a
427
461
  // handle character is still part of an alias such as `alex.morgan`.
428
- const pattern = new RegExp(`@${escapeRegex(alias)}(?![a-z0-9_-]|\\.(?=[a-z0-9]))`, 'gi')
462
+ const pattern = new RegExp(`(?<![a-z0-9_@])@${escapeRegex(alias)}(?![a-z0-9_-]|\\.(?=[a-z0-9]))`, 'gi')
429
463
  for (const match of text.matchAll(pattern)) {
430
464
  const index = match.index ?? 0
431
465
  const end = index + match[0].length
432
466
  if (!self.some((item) => index >= item.index && end <= item.end)) self.push({ name: alias, index, end })
433
467
  }
434
468
  }
435
- const other = [...text.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)(?=$|[^a-z0-9_.-]|\.(?:\s|$))/gi)]
469
+ const other = [...text.matchAll(/(?<![a-z0-9_@])@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)(?=$|[^a-z0-9_.-]|\.(?:\s|$))/gi)]
436
470
  .map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
437
471
  .filter((mention) => !self.some((item) => mention.index >= item.index && mention.end <= item.end))
438
472
  return { self, other, all: [...self, ...other].sort((a, b) => a.index - b.index) }
@@ -441,11 +475,15 @@ function conversationMentions(value, selfAliases) {
441
475
  export function messageSenderIsAgent(message) {
442
476
  const m = message && typeof message === 'object' ? message : {}
443
477
  if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
444
- const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
478
+ const sender = [m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
479
+ if (sender.agent && typeof sender.agent === 'object') return true
445
480
  const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
446
- return /agent|bot/.test(kind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
481
+ return agentSenderKind(kind) || (!humanSenderKind(kind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
447
482
  }
448
483
 
484
+ const agentSenderKind = (kind) => /(?:^|[_ -])(?:agent|bot)(?:$|[_ -])/.test(kind)
485
+ const humanSenderKind = (kind) => /^(?:human|user|member|person)$/.test(kind)
486
+
449
487
  // WebSocket deployments may fan every reply in a watched thread as
450
488
  // `agent:mention`, even when the new message names somebody else. Treat the event
451
489
  // name as a wake-up hint only: recipient ownership is decided from the actual
@@ -456,6 +494,8 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
456
494
  const { self: selfMentions, other: otherMentions, all: mentions } = conversationMentions(text, selfAliases)
457
495
  const explicitSelf = selfMentions.length > 0
458
496
 
497
+ if (!text) return { action: 'ignore', reason: 'empty-message', text, explicitSelf, otherMentions: [] }
498
+
459
499
  if (messageSenderIsAgent(m) && !explicitSelf) {
460
500
  return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
461
501
  }
@@ -463,8 +503,12 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
463
503
  return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
464
504
  }
465
505
 
466
- const standDown = /\b(?:do\s+not|don't|dont)\s+(?:pick|take|handle|work\s+on|start|continue)\b|\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|cancel\s+(?:this|that|it)|not\s+(?:for|yours?))\b/i.test(text)
467
- if (standDown && (explicitSelf || !mentions.length)) {
506
+ const standDown = /\b(?:do\s+not|don't|dont)\s+(?:pick|take|handle|work\s+on|start|continue|fix|implement|code)\b|\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|cancel\s+(?:this|that|it)|not\s+(?:for|yours?))\b/i.test(text)
507
+ const readOnlyRequest = /\b(?:(?:just|only)\s+(?:list|check|tell|show)|status\s+only)\b/i.test(text)
508
+ const stopActiveWork = /\b(?:stand\s+down|stop\s+(?:working|handling|on\s+this)|cancel\s+(?:this|that|it)|leave\s+(?:this|it)\s+(?:alone|to\s+\S+)|(?:do\s+not|don't|dont)\s+(?:continue|handle|work\s+on))\b/i.test(text)
509
+ // “Just list my tickets, do not start” requests an answer with work pickup
510
+ // disabled. An instruction to stop active work still takes precedence.
511
+ if (standDown && (!readOnlyRequest || stopActiveWork) && (explicitSelf || (threadOwned && !mentions.length))) {
468
512
  return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
469
513
  }
470
514
 
@@ -489,15 +533,22 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
489
533
  // own describe plenty of product conversations and must not start a coding lane.
490
534
  export function conversationNeedsCode(value, { rootContent = '' } = {}) {
491
535
  const text = String(value || '')
492
- if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
493
- const action = /\b(?:implement|fix|debug|repair|rebuild|rerun|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
494
- const target = /\b(?:code|codebase|repository|repo|github|branch|commit|pull\s+request|pr|endpoint|route|component|page|screen|ui|function|class|database|migration|schema|package|typescript|javascript|python|swift|rust|golang|css|html|source\s+file|tests?)\b/i.test(text)
495
- const contextualRepair = /\b(?:fix|debug|repair|rebuild|rerun|run\s+(?:the\s+)?(?:build|tests?))\b/i.test(text) && /\b(?:github\.com\/\S+\/pull\/\d+|pull\s+request|repository|repo|branch|commit)\b/i.test(rootContent)
496
- return (action && target) || contextualRepair
536
+ // Negation scopes the following instruction, not every independent request
537
+ // in the message. “Fix the component; don't deploy” still requests a fix.
538
+ const clauses = text.split(/[;!?]|\b(?:but|however|instead)\b/i)
539
+ return clauses.some((clause) => {
540
+ const positive = clause.split(/\b(?:do\s+not|don't|dont|stand\s+down|stop\s+(?:working|handling|on\s+this)|cancel\s+(?:this|that|it|the\s+(?:task|ticket)))\b/i)[0]
541
+ const action = /\b(?:implement|fix|debug|repair|rebuild|rerun|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(positive)
542
+ const target = /\b(?:code|codebase|repository|repo|github|branch|commit|pull\s+request|pr|endpoint|route|component|page|screen|ui|function|class|database|migration|schema|package|typescript|javascript|python|swift|rust|golang|css|html|source\s+file|tests?)\b/i.test(positive)
543
+ const contextualRepair = /\b(?:fix|debug|repair|rebuild|rerun|run\s+(?:the\s+)?(?:build|tests?))\b/i.test(positive) && /\b(?:github\.com\/\S+\/pull\/\d+|pull\s+request|repository|repo|branch|commit)\b/i.test(rootContent)
544
+ return (action && target) || contextualRepair
545
+ })
497
546
  }
498
547
 
499
548
  export function conversationAsksPendingTickets(value) {
500
- const text = String(value || '').replace(/\s+/g, ' ').trim()
549
+ // Project prefixes such as OPEN-77 are identifiers, not the word “open”.
550
+ // A question about a finished ticket must not become a backlog pickup.
551
+ const text = String(value || '').replace(/\b[a-z][a-z0-9]*-\d+\b/gi, '').replace(/\s+/g, ' ').trim()
501
552
  if (!/\b(?:ticket|tickets|task|tasks)\b/i.test(text)) return false
502
553
  const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(text)
503
554
  const question = /\?|\b(?:any|what|which|how many|do you|have you|are there|show|list|check|verify|asked|whether)\b/i.test(text)
@@ -509,7 +560,7 @@ export function conversationAsksPendingTickets(value) {
509
560
  // precise, scoped answer (or say which live fact it could not read). Failed team
510
561
  // mutations remain fail-closed so prose can never masquerade as a completed act.
511
562
  export function blockingReplyMcpErrors(errors) {
512
- const mutation = /^(?:update_ticket|post_message|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
563
+ const mutation = /^(?:update_ticket|post_message|create_task_comment|comment_ticket|react_message|create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/
513
564
  return [...new Set((Array.isArray(errors) ? errors : []).map((name) => String(name || '').replace(/^.*(?:__|[.:/])/, '').replace(/[-.]/g, '_').toLowerCase()).filter((name) => mutation.test(name)))]
514
565
  }
515
566
 
@@ -539,7 +590,7 @@ export function requestTargetsLaterAgent(text, selfAliases) {
539
590
 
540
591
  const bridge = value.slice(self.end, later.index).toLowerCase()
541
592
  const suffix = value.slice(later.end).trim().toLowerCase()
542
- const shared = /\b(?:and|with|alongside)\s*$/.test(bridge.trim()) && /\b(?:both|together|you (?:two|all)|everyone)\b/.test(suffix)
593
+ const shared = /^(?:\s*[,;:]?\s*)(?:and|with|alongside|&)\s*$/.test(bridge)
543
594
  if (shared) return false
544
- return /\?|^(?:[,.:;-]\s*)?(?:please\s+)?(?:can|could|would|will|do|write|create|make|send|fix|check|review|prepare|give|tell|help|handle|take|move|update|implement)\b/i.test(suffix)
595
+ return /^(?:[,.:;-]\s*)?(?:please\s+)?(?:can|could|would|will|do|write|create|make|send|fix|check|review|prepare|give|tell|help|handle|take|move|update|implement)\b/i.test(suffix)
545
596
  }