openvisio-agent 0.18.3 → 0.18.5
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 +4 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +16 -0
- package/src/events.mjs +66 -1
- package/src/watch.mjs +143 -55
package/README.md
CHANGED
|
@@ -62,6 +62,10 @@ The backend MCP may be stateful or stateless. A successful initialize response w
|
|
|
62
62
|
|
|
63
63
|
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.
|
|
64
64
|
|
|
65
|
+
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. Accepted coding work uses the activity indicator instead of a generic pickup message, then returns one verified result or concrete blocker in the source thread.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
65
69
|
Claude uses Haiku for routine coordination and Sonnet for repository work. Codex uses `gpt-5.6-sol` for every cycle, including messages, mentions, triage, board movement, MCP calls, and coding. This intentionally favors reliability and consistent tool use over the cheaper Codex tiers.
|
|
66
70
|
|
|
67
71
|
```bash
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -31,6 +31,11 @@ const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
|
31
31
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
32
32
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
33
33
|
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
34
|
+
const agentAutonomy = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'agentAutonomy.ts'), 'utf8')
|
|
35
|
+
const bareRuntime = readFileSync(join(repo, 'frontend', 'lib', 'agents', 'bareRuntime.ts'), 'utf8')
|
|
36
|
+
const bareRun = readFileSync(join(repo, 'frontend', 'app', 'api', 'agent', 'bare', 'run', 'route.ts'), 'utf8')
|
|
37
|
+
const bareDriver = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'bareDriver.ts'), 'utf8')
|
|
38
|
+
const quickReply = readFileSync(join(repo, 'frontend', 'app', 'api', 'agent', 'quick-reply', 'route.ts'), 'utf8')
|
|
34
39
|
const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
35
40
|
|
|
36
41
|
const assertions = [
|
|
@@ -46,6 +51,12 @@ const assertions = [
|
|
|
46
51
|
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
47
52
|
['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
48
53
|
['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
|
|
54
|
+
['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'")],
|
|
55
|
+
['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
|
|
56
|
+
['stand-down and redirects cancel source-thread work across runtimes', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('activeDelivery') && watcher.includes('cancelCurrent') && watcher.includes("subtype === 'canceled'")],
|
|
57
|
+
['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
|
|
58
|
+
['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
|
|
59
|
+
['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
|
|
49
60
|
['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
|
|
50
61
|
['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
|
|
51
62
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
@@ -65,6 +76,11 @@ const assertions = [
|
|
|
65
76
|
['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
|
|
66
77
|
['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
|
|
67
78
|
['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
|
|
79
|
+
['bare-agent inbox ownership is thread scoped', agentAutonomy.includes('lastPostInThread') && !agentAutonomy.includes('lastPostInChannel')],
|
|
80
|
+
['ambiguous bare-agent follow-ups fail closed', bareRuntime.includes("return { reply: false, reason: 'reply gate unavailable' }") && bareRuntime.includes('When unsure, stay silent.')],
|
|
81
|
+
['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
|
|
82
|
+
['bare-agent posts have a live conversation guard', bareRun.includes('const canPostMessage') && bareRun.includes('{ canPostMessage }') && bareRuntime.includes('reply suppressed because the live thread was redirected, cancelled, or already answered')],
|
|
83
|
+
['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
|
|
68
84
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
69
85
|
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
70
86
|
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
package/src/events.mjs
CHANGED
|
@@ -26,6 +26,16 @@ export function taskAgentId(task) {
|
|
|
26
26
|
return task.agent_id != null ? task.agent_id : (task.agentId != null ? task.agentId : null)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Backend task ids are database plumbing. Teammates identify tickets by the
|
|
30
|
+
// project-scoped slug shown on the board (for example, OVS-57). Never synthesize
|
|
31
|
+
// a slug from the numeric id: if an older backend omits it, human-facing copy
|
|
32
|
+
// should use the title or “the ticket” instead of inventing a reference.
|
|
33
|
+
export function ticketDisplaySlug(task) {
|
|
34
|
+
if (!task || typeof task !== 'object') return ''
|
|
35
|
+
const value = task.slug ?? task.ticket_slug ?? task.ticketSlug
|
|
36
|
+
return typeof value === 'string' ? value.trim().toUpperCase() : ''
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
export function taskIsCompleted(task, completedTypeIds = new Set()) {
|
|
30
40
|
if (!task || typeof task !== 'object') return false
|
|
31
41
|
if (task.deleted_at || task.deletedAt || task.completed_at || task.completedAt || task.closed_at || task.closedAt || task.archived_at || task.archivedAt) return true
|
|
@@ -60,7 +70,9 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
|
|
|
60
70
|
const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
|
|
61
71
|
const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
|
|
62
72
|
const mention = requester ? `@${requester} ` : ''
|
|
63
|
-
const
|
|
73
|
+
const slug = ticketDisplaySlug(task)
|
|
74
|
+
const ticketLabel = slug ? `ticket ${slug} “${title}”` : `the ticket “${title}”`
|
|
75
|
+
const content = `${mention}I finished ${ticketLabel} and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
|
|
64
76
|
const revision = task.updated_at ?? task.updatedAt ?? prUrl
|
|
65
77
|
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
|
|
66
78
|
}
|
|
@@ -234,6 +246,59 @@ export function renderedAgentMessages(value, identity = {}) {
|
|
|
234
246
|
return rows
|
|
235
247
|
}
|
|
236
248
|
|
|
249
|
+
const cleanAlias = (value) => String(value || '').trim().toLowerCase().replace(/^@/, '')
|
|
250
|
+
|
|
251
|
+
export function messageSenderIsAgent(message) {
|
|
252
|
+
const m = message && typeof message === 'object' ? message : {}
|
|
253
|
+
if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
254
|
+
const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
|
|
255
|
+
const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
|
|
256
|
+
return /agent|bot/.test(kind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// WebSocket deployments may fan every reply in a watched thread as
|
|
260
|
+
// `agent:mention`, even when the new message names somebody else. Treat the event
|
|
261
|
+
// name as a wake-up hint only: recipient ownership is decided from the actual
|
|
262
|
+
// message before a model or work lane is allowed to run.
|
|
263
|
+
export function classifyConversationTarget(message, selfAliases) {
|
|
264
|
+
const m = message && typeof message === 'object' ? message : {}
|
|
265
|
+
const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
|
|
266
|
+
const aliases = new Set((selfAliases || []).map(cleanAlias).filter(Boolean))
|
|
267
|
+
const mentions = [...text.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)/gi)]
|
|
268
|
+
.map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
|
|
269
|
+
const selfMentions = mentions.filter((mention) => aliases.has(mention.name))
|
|
270
|
+
const otherMentions = mentions.filter((mention) => !aliases.has(mention.name))
|
|
271
|
+
const explicitSelf = selfMentions.length > 0
|
|
272
|
+
|
|
273
|
+
if (messageSenderIsAgent(m) && !explicitSelf) {
|
|
274
|
+
return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
275
|
+
}
|
|
276
|
+
if (!explicitSelf && otherMentions.length) {
|
|
277
|
+
return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
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)
|
|
281
|
+
if (standDown && (explicitSelf || !mentions.length)) {
|
|
282
|
+
return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
|
|
286
|
+
return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
287
|
+
}
|
|
288
|
+
return { action: 'handle', reason: explicitSelf ? 'explicit-self-recipient' : 'eligible-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Require an actionable repository verb and a concrete code/repository object.
|
|
292
|
+
// Broad nouns such as "feature", "API", "file", or "documentation" on their
|
|
293
|
+
// own describe plenty of product conversations and must not start a coding lane.
|
|
294
|
+
export function conversationNeedsCode(value) {
|
|
295
|
+
const text = String(value || '')
|
|
296
|
+
if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
|
|
297
|
+
const action = /\b(?:implement|fix|debug|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
|
|
298
|
+
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)
|
|
299
|
+
return action && target
|
|
300
|
+
}
|
|
301
|
+
|
|
237
302
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
238
303
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
239
304
|
// model starts, while keeping explicitly shared requests addressed to both.
|
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from 'node:os'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages,
|
|
13
|
+
import { agentStateRequest, buildTaskCompletionReport, classifyConversationTarget, codexPolicyBlock, conversationNeedsCode, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
|
|
14
14
|
import { createByoMemoryGraph } from './memory.mjs'
|
|
15
15
|
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
16
16
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
@@ -33,9 +33,11 @@ const REPLY_DISCIPLINE = [
|
|
|
33
33
|
'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
|
|
34
34
|
' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
|
|
35
35
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
36
|
-
' •
|
|
36
|
+
' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
|
|
37
|
+
' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT post again. Do not send a generic pickup acknowledgement; activity shows that work is underway. The first durable task message is a verified result, a link, or a real blocker. One answer per question.',
|
|
37
38
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
38
39
|
' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
|
|
40
|
+
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
|
|
39
41
|
' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
|
|
40
42
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
41
43
|
].join('\n')
|
|
@@ -62,8 +64,8 @@ const CYCLE_FAST = [
|
|
|
62
64
|
const COORDINATE = [
|
|
63
65
|
'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
|
|
64
66
|
'Call get_marching_orders or poll_inbox only when the event context does not already contain enough detail. Use update_ticket to move a ticket to the correct board column when requested or when non-code work is complete.',
|
|
65
|
-
'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified,
|
|
66
|
-
'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing
|
|
67
|
+
'Do not inspect repositories, edit files, run tests, or write code in this cycle. If the request actually requires code and this cycle was misclassified, leave it retryable for the coding lane; report only a concrete routing blocker, never a generic acknowledgement. Do not pretend it is complete.',
|
|
68
|
+
'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
|
|
67
69
|
].join('\n')
|
|
68
70
|
|
|
69
71
|
// ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
|
|
@@ -95,7 +97,7 @@ const CODE_FULL = [
|
|
|
95
97
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
96
98
|
' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
|
|
97
99
|
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
98
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result.
|
|
100
|
+
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
|
|
99
101
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
100
102
|
].join('\n')
|
|
101
103
|
|
|
@@ -119,7 +121,7 @@ const INTRO = [
|
|
|
119
121
|
const SWEEP = [
|
|
120
122
|
'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
|
|
121
123
|
'Use the available task/inbox tools. If get_marching_orders/poll_inbox are absent, use list_agents + list_projects + list_tasks to find tasks assigned to your agent identity, then:',
|
|
122
|
-
' 1. For every task assigned to YOU that you have NOT started: begin the work without
|
|
124
|
+
' 1. For every task assigned to YOU that you have NOT started: begin the work without a pickup acknowledgement. Use update_ticket while working and report only a verified result or concrete blocker through an actual source channel when one is supplied. Then do the work end-to-end. Skip tasks assigned to other agents.',
|
|
123
125
|
' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
|
|
124
126
|
'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
|
|
125
127
|
].join('\n')
|
|
@@ -301,6 +303,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
301
303
|
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
302
304
|
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
|
|
303
305
|
let configured = false
|
|
306
|
+
let cancelActive = null
|
|
304
307
|
const ensureConfig = () => {
|
|
305
308
|
if (configured) return
|
|
306
309
|
configured = true
|
|
@@ -326,11 +329,13 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
326
329
|
// OpenCode exited; raw events tell us which tools actually completed.
|
|
327
330
|
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
328
331
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
332
|
+
let cancel = null
|
|
329
333
|
let outputText = '', jsonlBuffer = ''
|
|
330
334
|
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
331
335
|
const finish = (o) => {
|
|
332
336
|
if (done) return
|
|
333
337
|
done = true
|
|
338
|
+
if (cancelActive === cancel) cancelActive = null
|
|
334
339
|
clearTimeout(timer)
|
|
335
340
|
const calls = [...mcpCalls]
|
|
336
341
|
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
@@ -374,6 +379,11 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
374
379
|
try { child && child.kill() } catch { /* gone */ }
|
|
375
380
|
finish({ type: 'result', subtype: 'timeout' })
|
|
376
381
|
}, maxCycleMs)
|
|
382
|
+
cancel = () => {
|
|
383
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
384
|
+
finish({ type: 'result', subtype: 'canceled' })
|
|
385
|
+
}
|
|
386
|
+
cancelActive = cancel
|
|
377
387
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
378
388
|
try {
|
|
379
389
|
child = spawn(bin, args, {
|
|
@@ -407,7 +417,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
407
417
|
}
|
|
408
418
|
|
|
409
419
|
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
410
|
-
return { runCycle, canCode }
|
|
420
|
+
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
411
421
|
}
|
|
412
422
|
|
|
413
423
|
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
@@ -420,6 +430,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
420
430
|
const cwd = workdir || OV_DIR
|
|
421
431
|
const tomlString = (v) => JSON.stringify(String(v))
|
|
422
432
|
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
433
|
+
let cancelActive = null
|
|
423
434
|
|
|
424
435
|
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
425
436
|
return new Promise((resolve) => {
|
|
@@ -436,12 +447,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
436
447
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
437
448
|
full]
|
|
438
449
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
450
|
+
let cancel = null
|
|
439
451
|
let policyBlock = null
|
|
440
452
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
441
453
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
442
454
|
const finish = (o) => {
|
|
443
455
|
if (done) return
|
|
444
456
|
done = true
|
|
457
|
+
if (cancelActive === cancel) cancelActive = null
|
|
445
458
|
clearTimeout(timer)
|
|
446
459
|
const calls = [...mcpCalls]
|
|
447
460
|
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
@@ -496,6 +509,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
496
509
|
try { child && child.kill() } catch { /* gone */ }
|
|
497
510
|
finish({ type: 'result', subtype: 'timeout' })
|
|
498
511
|
}, maxCycleMs)
|
|
512
|
+
cancel = () => {
|
|
513
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
514
|
+
finish({ type: 'result', subtype: 'canceled' })
|
|
515
|
+
}
|
|
516
|
+
cancelActive = cancel
|
|
499
517
|
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
500
518
|
try {
|
|
501
519
|
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
@@ -526,7 +544,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
526
544
|
|
|
527
545
|
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
528
546
|
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
|
|
529
|
-
return { runCycle, canCode }
|
|
547
|
+
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
530
548
|
}
|
|
531
549
|
|
|
532
550
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
@@ -652,7 +670,14 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
652
670
|
})
|
|
653
671
|
}
|
|
654
672
|
|
|
655
|
-
|
|
673
|
+
const cancelCurrent = () => {
|
|
674
|
+
const active = child
|
|
675
|
+
if (!active) return
|
|
676
|
+
child = null
|
|
677
|
+
try { active.kill() } catch { /* gone */ }
|
|
678
|
+
settleTurn({ type: 'result', subtype: 'canceled' })
|
|
679
|
+
}
|
|
680
|
+
return { runCycle, canCode, cancelCurrent }
|
|
656
681
|
}
|
|
657
682
|
|
|
658
683
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -708,6 +733,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
708
733
|
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
|
|
709
734
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
710
735
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
736
|
+
const guardedReplyPrompt = 'WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Do not call post_message or any inbox/resource-discovery tool. Return only one natural, context-specific reply of 1-3 sentences. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.' + '\n\n' + backendToolRule
|
|
711
737
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
712
738
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
713
739
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -715,8 +741,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
715
741
|
let liteModel = chatModel || model
|
|
716
742
|
|
|
717
743
|
const lanes = {
|
|
718
|
-
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
719
|
-
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
744
|
+
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
|
|
745
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
|
|
720
746
|
}
|
|
721
747
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
722
748
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -787,7 +813,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
787
813
|
let lastTaskTriggeredAt = 0
|
|
788
814
|
let lastInboxSignature = ''
|
|
789
815
|
let selfAgentId = null
|
|
790
|
-
const
|
|
816
|
+
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
817
|
+
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
791
818
|
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
792
819
|
let mcpToolNames = null
|
|
793
820
|
let mcpToolDiscoveryPromise = null
|
|
@@ -838,12 +865,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
838
865
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
839
866
|
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
840
867
|
const messageDeliveries = new Map()
|
|
841
|
-
const
|
|
868
|
+
const threadControlKey = (channelId, parentId) => Number.isFinite(Number(channelId)) && parentId != null ? `thread:${Number(channelId)}:${parentId}` : ''
|
|
869
|
+
const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
|
|
870
|
+
const cancelThread = (channelId, parentId, summary) => {
|
|
871
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
872
|
+
if (!controlKey) return
|
|
873
|
+
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
874
|
+
for (const [laneName, lane] of Object.entries(lanes)) {
|
|
875
|
+
lane.deferred = lane.deferred.filter((item) => !sameDeliveryThread(item.delivery, channelId, parentId))
|
|
876
|
+
if (sameDeliveryThread(lane.activeDelivery, channelId, parentId)) {
|
|
877
|
+
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
878
|
+
runners[laneName].cancelCurrent?.('thread-cancelled')
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const activateThread = (channelId, parentId, summary) => {
|
|
883
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
884
|
+
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
885
|
+
}
|
|
886
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
|
|
842
887
|
const deliveryKey = String(key || '')
|
|
843
888
|
const message = String(content || '').trim()
|
|
844
889
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
845
890
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
846
891
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
892
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
893
|
+
const suppressCancelled = () => {
|
|
894
|
+
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
895
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
896
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
|
|
897
|
+
return { posted: false, reason: 'thread-cancelled' }
|
|
898
|
+
}
|
|
899
|
+
const initiallyCancelled = suppressCancelled()
|
|
900
|
+
if (initiallyCancelled) return initiallyCancelled
|
|
847
901
|
|
|
848
902
|
const run = (async () => {
|
|
849
903
|
if (parentId != null) {
|
|
@@ -859,6 +913,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
859
913
|
}
|
|
860
914
|
}
|
|
861
915
|
|
|
916
|
+
// A redirect can arrive while the authoritative thread read is in flight.
|
|
917
|
+
// Re-check immediately before the irreversible post.
|
|
918
|
+
const newlyCancelled = suppressCancelled()
|
|
919
|
+
if (newlyCancelled) return newlyCancelled
|
|
920
|
+
|
|
862
921
|
sendStatus(Number(channelId), 'typing')
|
|
863
922
|
const result = toolData(await callMcpTool('post_message', {
|
|
864
923
|
...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
|
|
@@ -1054,6 +1113,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1054
1113
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1055
1114
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
1056
1115
|
selfAgentId = Number(self.id)
|
|
1116
|
+
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
1057
1117
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1058
1118
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1059
1119
|
const assigned = []
|
|
@@ -1097,19 +1157,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1097
1157
|
} else continue
|
|
1098
1158
|
}
|
|
1099
1159
|
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
|
|
1100
|
-
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
1160
|
+
assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
1101
1161
|
}
|
|
1102
1162
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1103
1163
|
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1104
1164
|
for (const item of activities) {
|
|
1105
1165
|
const text = JSON.stringify(item)
|
|
1106
|
-
const lower = text.toLowerCase()
|
|
1107
1166
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1167
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1168
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1169
|
+
? item.message
|
|
1170
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1171
|
+
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1172
|
+
// Reconciliation recovers explicit mentions only. Searching the whole
|
|
1173
|
+
// activity envelope can match an old thread root and replay a new reply
|
|
1174
|
+
// that is actually addressed to another agent.
|
|
1175
|
+
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => activityText.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
1113
1176
|
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1114
1177
|
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1115
1178
|
// The same logical mention may already have arrived over WebSocket.
|
|
@@ -1138,7 +1201,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1138
1201
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
1139
1202
|
const activityChannel = await projectStatusChannel(next.projectId)
|
|
1140
1203
|
pendingCompletionReports.add(`${next.projectId}:${next.id}`); trimSeen(pendingCompletionReports); persistReplay()
|
|
1141
|
-
|
|
1204
|
+
const humanTicketRef = next.slug ? `ticket ${next.slug}` : `the ticket “${next.title}”`
|
|
1205
|
+
void drain('full', `Backlog reconciliation verified ${humanTicketRef} is assigned to YOU. Internal tool identity: project_id ${next.projectId}, ticket_id ${next.id}. Process this ONE ticket only. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${next.slug || 'the ticket title'} instead. This is backlog-only work with NO source message or requester thread: do NOT call post_message yourself. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to review or done when appropriate. The watcher will publish exactly one verified completion result to the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
|
|
1142
1206
|
}
|
|
1143
1207
|
}
|
|
1144
1208
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -1165,7 +1229,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1165
1229
|
|
|
1166
1230
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
1167
1231
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
1168
|
-
const baseFor = (kind) =>
|
|
1232
|
+
const baseFor = (kind, delivery) => delivery?.watcherOwned
|
|
1233
|
+
? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
|
|
1234
|
+
: kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1169
1235
|
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1170
1236
|
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1171
1237
|
// shape is different and remains on its existing completion path.
|
|
@@ -1221,6 +1287,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1221
1287
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
1222
1288
|
if (lane.busy) { lane.queued = (RANK[kind] ?? 0) >= (RANK[lane.queued] ?? 0) ? kind : lane.queued; log(laneName + ' lane busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
1223
1289
|
lane.busy = true
|
|
1290
|
+
lane.activeDelivery = delivery
|
|
1224
1291
|
const ctx = lane.pending.splice(0)
|
|
1225
1292
|
const activeTaskRef = lane.taskRefs.splice(0)[0] || null
|
|
1226
1293
|
const targets = [...lane.targets]
|
|
@@ -1232,7 +1299,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1232
1299
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1233
1300
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1234
1301
|
const recalled = memory.context(memoryRefs)
|
|
1235
|
-
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
|
|
1302
|
+
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1236
1303
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1237
1304
|
// work (full/sweep) uses the main model.
|
|
1238
1305
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
@@ -1244,8 +1311,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1244
1311
|
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1245
1312
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1246
1313
|
try {
|
|
1247
|
-
const result = await runners[laneName].runCycle(prompt, useModel,
|
|
1314
|
+
const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
|
|
1248
1315
|
let completionResult = result
|
|
1316
|
+
if (result?.subtype === 'canceled') {
|
|
1317
|
+
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1249
1320
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
1250
1321
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
1251
1322
|
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
@@ -1273,7 +1344,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1273
1344
|
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1274
1345
|
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1275
1346
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1276
|
-
const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel,
|
|
1347
|
+
const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
|
|
1277
1348
|
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1278
1349
|
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1279
1350
|
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
@@ -1304,7 +1375,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1304
1375
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1305
1376
|
}
|
|
1306
1377
|
}
|
|
1307
|
-
if (
|
|
1378
|
+
if (delivery?.watcherOwned) {
|
|
1308
1379
|
const reply = String(completionResult?.outputText || '').trim()
|
|
1309
1380
|
if (!reply) {
|
|
1310
1381
|
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
@@ -1317,6 +1388,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1317
1388
|
if (heartbeat) clearInterval(heartbeat)
|
|
1318
1389
|
laneStatusTargets[laneName].clear()
|
|
1319
1390
|
lane.busy = false
|
|
1391
|
+
lane.activeDelivery = null
|
|
1320
1392
|
const deferred = lane.deferred.shift()
|
|
1321
1393
|
if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
|
|
1322
1394
|
else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
@@ -1331,10 +1403,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1331
1403
|
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
1332
1404
|
}
|
|
1333
1405
|
|
|
1334
|
-
// Route
|
|
1335
|
-
//
|
|
1336
|
-
const
|
|
1337
|
-
const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !needsCode(value)
|
|
1406
|
+
// Route only concrete repository requests to the coding lane. Conversation
|
|
1407
|
+
// ownership is classified separately before this is consulted.
|
|
1408
|
+
const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !conversationNeedsCode(value)
|
|
1338
1409
|
|
|
1339
1410
|
// Engineers change the model under the hood from chat: "/model", "/model sonnet",
|
|
1340
1411
|
// "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
|
|
@@ -1376,6 +1447,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1376
1447
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1377
1448
|
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1378
1449
|
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
1450
|
+
for (const alias of [self?.name, self?.identifier, self?.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
1379
1451
|
}
|
|
1380
1452
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1381
1453
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -1413,6 +1485,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1413
1485
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1414
1486
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1415
1487
|
const title = String(ticket.title || hinted.title || '')
|
|
1488
|
+
const ticketSlug = ticketDisplaySlug(ticket)
|
|
1416
1489
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
|
|
1417
1490
|
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
1418
1491
|
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
@@ -1420,7 +1493,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1420
1493
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1421
1494
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1422
1495
|
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1423
|
-
|
|
1496
|
+
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1497
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
1424
1498
|
} catch (e) {
|
|
1425
1499
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1426
1500
|
}
|
|
@@ -1444,15 +1518,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1444
1518
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1445
1519
|
// when the payload carries no id.
|
|
1446
1520
|
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1447
|
-
|
|
1448
|
-
|
|
1521
|
+
const target = classifyConversationTarget(msg, [...selfAliases])
|
|
1522
|
+
if (target.action === 'ignore') {
|
|
1523
|
+
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
1524
|
+
// A redirect or another agent taking over invalidates any older work or
|
|
1525
|
+
// draft still queued for this thread. The event name alone cannot retain
|
|
1526
|
+
// ownership after the actual message says otherwise.
|
|
1527
|
+
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
|
|
1528
|
+
cancelThread(cid, threadRoot, text || target.reason)
|
|
1529
|
+
}
|
|
1449
1530
|
return
|
|
1450
1531
|
}
|
|
1451
1532
|
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1452
1533
|
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1534
|
+
if (target.action === 'stand_down') {
|
|
1535
|
+
if (cid != null && threadRoot != null) {
|
|
1536
|
+
cancelThread(cid, threadRoot, text || 'Explicit stand-down')
|
|
1537
|
+
const confirmation = `${who ? `@${who} ` : ''}Understood. I'm standing down.`
|
|
1538
|
+
void postMessageOnce({ key: `stand-down:${cid}:${threadRoot}`, channelId: Number(cid), parentId: threadRoot, content: confirmation, sourceKey, allowCancelled: true })
|
|
1539
|
+
.catch((e) => log('stand-down confirmation failed closed: ' + (e?.message || e)))
|
|
1540
|
+
}
|
|
1541
|
+
return
|
|
1542
|
+
}
|
|
1543
|
+
if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
|
|
1453
1544
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1454
|
-
|
|
1455
|
-
|
|
1545
|
+
// Every runtime carries the source thread through the queue so a later
|
|
1546
|
+
// redirect can cancel its process. Codex additionally delegates the final
|
|
1547
|
+
// post to the watcher for an authoritative last-moment thread check.
|
|
1548
|
+
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1549
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
|
|
1456
1550
|
: null
|
|
1457
1551
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1458
1552
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1460,11 +1554,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1460
1554
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
1461
1555
|
if (mcmd.report) {
|
|
1462
1556
|
log('model query → code ' + codeModel + ' / chat ' + liteModel)
|
|
1463
|
-
const delivery =
|
|
1464
|
-
void drain('fast', `An engineer asked which model you're running. ${delivery ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
|
|
1557
|
+
const delivery = conversationDelivery('model')
|
|
1558
|
+
void drain('fast', `An engineer asked which model you're running. ${delivery?.watcherOwned ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
|
|
1465
1559
|
} else if (mcmd.invalid) {
|
|
1466
|
-
const delivery =
|
|
1467
|
-
void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
|
|
1560
|
+
const delivery = conversationDelivery('model')
|
|
1561
|
+
void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery?.watcherOwned ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
|
|
1468
1562
|
} else {
|
|
1469
1563
|
const tgt = mcmd.target // 'chat' | 'code' | 'both'
|
|
1470
1564
|
const prev = `code ${codeModel}/chat ${liteModel}`
|
|
@@ -1474,8 +1568,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1474
1568
|
persistModel()
|
|
1475
1569
|
const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
|
|
1476
1570
|
log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
|
|
1477
|
-
const delivery =
|
|
1478
|
-
void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
|
|
1571
|
+
const delivery = conversationDelivery('model')
|
|
1572
|
+
void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery?.watcherOwned ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
|
|
1479
1573
|
}
|
|
1480
1574
|
return
|
|
1481
1575
|
}
|
|
@@ -1483,23 +1577,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1483
1577
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1484
1578
|
// subsequent working/typing heartbeat for its lane.
|
|
1485
1579
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1486
|
-
const codingMention = canCode &&
|
|
1487
|
-
const replyDelivery =
|
|
1580
|
+
const codingMention = canCode && conversationNeedsCode(text)
|
|
1581
|
+
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1488
1582
|
const ctx = cid != null
|
|
1489
|
-
? replyDelivery
|
|
1490
|
-
? `
|
|
1491
|
-
: `
|
|
1583
|
+
? replyDelivery?.watcherOwned
|
|
1584
|
+
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
|
|
1585
|
+
: `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'This is concrete repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates, and after your single reply, STOP.`
|
|
1492
1586
|
: undefined
|
|
1493
1587
|
if (codingMention) {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
} else {
|
|
1498
|
-
const ack = cid != null
|
|
1499
|
-
? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
|
|
1500
|
-
: undefined
|
|
1501
|
-
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1502
|
-
}
|
|
1588
|
+
// Activity indicators make accepted work visible. Do not add a canned
|
|
1589
|
+
// pickup message; the first channel message is the verified result or a
|
|
1590
|
+
// concrete blocker from the work lane.
|
|
1503
1591
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1504
1592
|
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1505
1593
|
} else if (k === 'error') {
|