openvisio-agent 0.18.3 → 0.18.4
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 +2 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +15 -0
- package/src/events.mjs +53 -0
- package/src/watch.mjs +136 -52
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ 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
|
+
|
|
65
67
|
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
68
|
|
|
67
69
|
```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,11 @@ 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")],
|
|
49
59
|
['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
60
|
['Codex cannot race the watcher with post_message', watcher.includes("disabledMcpTools: ['post_message']") && watcher.includes('disabled_tools = [')],
|
|
51
61
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
@@ -65,6 +75,11 @@ const assertions = [
|
|
|
65
75
|
['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
|
|
66
76
|
['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
|
|
67
77
|
['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
|
|
78
|
+
['bare-agent inbox ownership is thread scoped', agentAutonomy.includes('lastPostInThread') && !agentAutonomy.includes('lastPostInChannel')],
|
|
79
|
+
['ambiguous bare-agent follow-ups fail closed', bareRuntime.includes("return { reply: false, reason: 'reply gate unavailable' }") && bareRuntime.includes('When unsure, stay silent.')],
|
|
80
|
+
['bare-agent runs handle one source conversation', bareRun.includes('messageId?: string') && bareRun.includes('const targetRoot = target.parentId ?? target.messageId') && bareDriver.includes('messageId: source.messageId')],
|
|
81
|
+
['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')],
|
|
82
|
+
['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
|
|
68
83
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
69
84
|
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
70
85
|
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
package/src/events.mjs
CHANGED
|
@@ -234,6 +234,59 @@ export function renderedAgentMessages(value, identity = {}) {
|
|
|
234
234
|
return rows
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
const cleanAlias = (value) => String(value || '').trim().toLowerCase().replace(/^@/, '')
|
|
238
|
+
|
|
239
|
+
export function messageSenderIsAgent(message) {
|
|
240
|
+
const m = message && typeof message === 'object' ? message : {}
|
|
241
|
+
if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
242
|
+
const sender = [m.sender, m.user, m.author].find((item) => item && typeof item === 'object') || {}
|
|
243
|
+
const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
|
|
244
|
+
return /agent|bot/.test(kind) || sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// WebSocket deployments may fan every reply in a watched thread as
|
|
248
|
+
// `agent:mention`, even when the new message names somebody else. Treat the event
|
|
249
|
+
// name as a wake-up hint only: recipient ownership is decided from the actual
|
|
250
|
+
// message before a model or work lane is allowed to run.
|
|
251
|
+
export function classifyConversationTarget(message, selfAliases) {
|
|
252
|
+
const m = message && typeof message === 'object' ? message : {}
|
|
253
|
+
const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim()
|
|
254
|
+
const aliases = new Set((selfAliases || []).map(cleanAlias).filter(Boolean))
|
|
255
|
+
const mentions = [...text.matchAll(/@([a-z0-9](?:[a-z0-9_.-]*[a-z0-9_-])?)/gi)]
|
|
256
|
+
.map((match) => ({ name: cleanAlias(match[1]), index: match.index ?? 0, end: (match.index ?? 0) + match[0].length }))
|
|
257
|
+
const selfMentions = mentions.filter((mention) => aliases.has(mention.name))
|
|
258
|
+
const otherMentions = mentions.filter((mention) => !aliases.has(mention.name))
|
|
259
|
+
const explicitSelf = selfMentions.length > 0
|
|
260
|
+
|
|
261
|
+
if (messageSenderIsAgent(m) && !explicitSelf) {
|
|
262
|
+
return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
263
|
+
}
|
|
264
|
+
if (!explicitSelf && otherMentions.length) {
|
|
265
|
+
return { action: 'ignore', reason: 'explicit-other-recipient', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
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)
|
|
269
|
+
if (standDown && (explicitSelf || !mentions.length)) {
|
|
270
|
+
return { action: 'stand_down', reason: 'explicit-stand-down', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (explicitSelf && requestTargetsLaterAgent(text, selfAliases)) {
|
|
274
|
+
return { action: 'ignore', reason: 'redirected-to-later-agent', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
275
|
+
}
|
|
276
|
+
return { action: 'handle', reason: explicitSelf ? 'explicit-self-recipient' : 'eligible-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Require an actionable repository verb and a concrete code/repository object.
|
|
280
|
+
// Broad nouns such as "feature", "API", "file", or "documentation" on their
|
|
281
|
+
// own describe plenty of product conversations and must not start a coding lane.
|
|
282
|
+
export function conversationNeedsCode(value) {
|
|
283
|
+
const text = String(value || '')
|
|
284
|
+
if (/\b(?:do\s+not|don't|dont|stop|cancel|stand\s+down)\b/i.test(text)) return false
|
|
285
|
+
const action = /\b(?:implement|fix|debug|refactor|change|update|add|remove|write|edit|test|build|deploy|release|migrate|wire|integrate)\b/i.test(text)
|
|
286
|
+
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)
|
|
287
|
+
return action && target
|
|
288
|
+
}
|
|
289
|
+
|
|
237
290
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
238
291
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
239
292
|
// 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 } 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,7 +33,8 @@ 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.',
|
|
39
40
|
' • 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.',
|
|
@@ -62,8 +63,8 @@ const CYCLE_FAST = [
|
|
|
62
63
|
const COORDINATE = [
|
|
63
64
|
'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
|
|
64
65
|
'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
|
|
66
|
+
'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.',
|
|
67
|
+
'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
|
|
67
68
|
].join('\n')
|
|
68
69
|
|
|
69
70
|
// ── CODE agents (--workdir given): full file + Bash + git/gh surface. ─────────
|
|
@@ -95,7 +96,7 @@ const CODE_FULL = [
|
|
|
95
96
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
96
97
|
' 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
98
|
' 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.
|
|
99
|
+
' 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
100
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
100
101
|
].join('\n')
|
|
101
102
|
|
|
@@ -119,7 +120,7 @@ const INTRO = [
|
|
|
119
120
|
const SWEEP = [
|
|
120
121
|
'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
|
|
121
122
|
'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
|
|
123
|
+
' 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
124
|
' 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
125
|
'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
|
|
125
126
|
].join('\n')
|
|
@@ -301,6 +302,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
301
302
|
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
302
303
|
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
|
|
303
304
|
let configured = false
|
|
305
|
+
let cancelActive = null
|
|
304
306
|
const ensureConfig = () => {
|
|
305
307
|
if (configured) return
|
|
306
308
|
configured = true
|
|
@@ -326,11 +328,13 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
326
328
|
// OpenCode exited; raw events tell us which tools actually completed.
|
|
327
329
|
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
328
330
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
331
|
+
let cancel = null
|
|
329
332
|
let outputText = '', jsonlBuffer = ''
|
|
330
333
|
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
331
334
|
const finish = (o) => {
|
|
332
335
|
if (done) return
|
|
333
336
|
done = true
|
|
337
|
+
if (cancelActive === cancel) cancelActive = null
|
|
334
338
|
clearTimeout(timer)
|
|
335
339
|
const calls = [...mcpCalls]
|
|
336
340
|
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
@@ -374,6 +378,11 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
374
378
|
try { child && child.kill() } catch { /* gone */ }
|
|
375
379
|
finish({ type: 'result', subtype: 'timeout' })
|
|
376
380
|
}, maxCycleMs)
|
|
381
|
+
cancel = () => {
|
|
382
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
383
|
+
finish({ type: 'result', subtype: 'canceled' })
|
|
384
|
+
}
|
|
385
|
+
cancelActive = cancel
|
|
377
386
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
378
387
|
try {
|
|
379
388
|
child = spawn(bin, args, {
|
|
@@ -407,7 +416,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
407
416
|
}
|
|
408
417
|
|
|
409
418
|
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
410
|
-
return { runCycle, canCode }
|
|
419
|
+
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
411
420
|
}
|
|
412
421
|
|
|
413
422
|
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
@@ -420,6 +429,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
420
429
|
const cwd = workdir || OV_DIR
|
|
421
430
|
const tomlString = (v) => JSON.stringify(String(v))
|
|
422
431
|
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
432
|
+
let cancelActive = null
|
|
423
433
|
|
|
424
434
|
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
425
435
|
return new Promise((resolve) => {
|
|
@@ -436,12 +446,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
436
446
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
437
447
|
full]
|
|
438
448
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
449
|
+
let cancel = null
|
|
439
450
|
let policyBlock = null
|
|
440
451
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
441
452
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
442
453
|
const finish = (o) => {
|
|
443
454
|
if (done) return
|
|
444
455
|
done = true
|
|
456
|
+
if (cancelActive === cancel) cancelActive = null
|
|
445
457
|
clearTimeout(timer)
|
|
446
458
|
const calls = [...mcpCalls]
|
|
447
459
|
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
@@ -496,6 +508,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
496
508
|
try { child && child.kill() } catch { /* gone */ }
|
|
497
509
|
finish({ type: 'result', subtype: 'timeout' })
|
|
498
510
|
}, maxCycleMs)
|
|
511
|
+
cancel = () => {
|
|
512
|
+
try { child && child.kill() } catch { /* gone */ }
|
|
513
|
+
finish({ type: 'result', subtype: 'canceled' })
|
|
514
|
+
}
|
|
515
|
+
cancelActive = cancel
|
|
499
516
|
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
500
517
|
try {
|
|
501
518
|
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
@@ -526,7 +543,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
526
543
|
|
|
527
544
|
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
528
545
|
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
|
|
529
|
-
return { runCycle, canCode }
|
|
546
|
+
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
530
547
|
}
|
|
531
548
|
|
|
532
549
|
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
@@ -652,7 +669,14 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
652
669
|
})
|
|
653
670
|
}
|
|
654
671
|
|
|
655
|
-
|
|
672
|
+
const cancelCurrent = () => {
|
|
673
|
+
const active = child
|
|
674
|
+
if (!active) return
|
|
675
|
+
child = null
|
|
676
|
+
try { active.kill() } catch { /* gone */ }
|
|
677
|
+
settleTurn({ type: 'result', subtype: 'canceled' })
|
|
678
|
+
}
|
|
679
|
+
return { runCycle, canCode, cancelCurrent }
|
|
656
680
|
}
|
|
657
681
|
|
|
658
682
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -708,6 +732,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
708
732
|
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
733
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
710
734
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
735
|
+
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
736
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
712
737
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
713
738
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -715,8 +740,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
715
740
|
let liteModel = chatModel || model
|
|
716
741
|
|
|
717
742
|
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: [] },
|
|
743
|
+
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
|
|
744
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [], activeDelivery: null },
|
|
720
745
|
}
|
|
721
746
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
722
747
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -787,7 +812,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
787
812
|
let lastTaskTriggeredAt = 0
|
|
788
813
|
let lastInboxSignature = ''
|
|
789
814
|
let selfAgentId = null
|
|
790
|
-
const
|
|
815
|
+
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
816
|
+
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.4', log })
|
|
791
817
|
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
792
818
|
let mcpToolNames = null
|
|
793
819
|
let mcpToolDiscoveryPromise = null
|
|
@@ -838,12 +864,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
838
864
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
839
865
|
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
840
866
|
const messageDeliveries = new Map()
|
|
841
|
-
const
|
|
867
|
+
const threadControlKey = (channelId, parentId) => Number.isFinite(Number(channelId)) && parentId != null ? `thread:${Number(channelId)}:${parentId}` : ''
|
|
868
|
+
const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
|
|
869
|
+
const cancelThread = (channelId, parentId, summary) => {
|
|
870
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
871
|
+
if (!controlKey) return
|
|
872
|
+
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
873
|
+
for (const [laneName, lane] of Object.entries(lanes)) {
|
|
874
|
+
lane.deferred = lane.deferred.filter((item) => !sameDeliveryThread(item.delivery, channelId, parentId))
|
|
875
|
+
if (sameDeliveryThread(lane.activeDelivery, channelId, parentId)) {
|
|
876
|
+
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
877
|
+
runners[laneName].cancelCurrent?.('thread-cancelled')
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const activateThread = (channelId, parentId, summary) => {
|
|
882
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
883
|
+
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
884
|
+
}
|
|
885
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
|
|
842
886
|
const deliveryKey = String(key || '')
|
|
843
887
|
const message = String(content || '').trim()
|
|
844
888
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
845
889
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
846
890
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
891
|
+
const controlKey = threadControlKey(channelId, parentId)
|
|
892
|
+
const suppressCancelled = () => {
|
|
893
|
+
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
894
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
895
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
|
|
896
|
+
return { posted: false, reason: 'thread-cancelled' }
|
|
897
|
+
}
|
|
898
|
+
const initiallyCancelled = suppressCancelled()
|
|
899
|
+
if (initiallyCancelled) return initiallyCancelled
|
|
847
900
|
|
|
848
901
|
const run = (async () => {
|
|
849
902
|
if (parentId != null) {
|
|
@@ -859,6 +912,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
859
912
|
}
|
|
860
913
|
}
|
|
861
914
|
|
|
915
|
+
// A redirect can arrive while the authoritative thread read is in flight.
|
|
916
|
+
// Re-check immediately before the irreversible post.
|
|
917
|
+
const newlyCancelled = suppressCancelled()
|
|
918
|
+
if (newlyCancelled) return newlyCancelled
|
|
919
|
+
|
|
862
920
|
sendStatus(Number(channelId), 'typing')
|
|
863
921
|
const result = toolData(await callMcpTool('post_message', {
|
|
864
922
|
...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
|
|
@@ -1054,6 +1112,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1054
1112
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1055
1113
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
1056
1114
|
selfAgentId = Number(self.id)
|
|
1115
|
+
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
1057
1116
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1058
1117
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1059
1118
|
const assigned = []
|
|
@@ -1103,13 +1162,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1103
1162
|
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1104
1163
|
for (const item of activities) {
|
|
1105
1164
|
const text = JSON.stringify(item)
|
|
1106
|
-
const lower = text.toLowerCase()
|
|
1107
1165
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1166
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1167
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1168
|
+
? item.message
|
|
1169
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1170
|
+
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1171
|
+
// Reconciliation recovers explicit mentions only. Searching the whole
|
|
1172
|
+
// activity envelope can match an old thread root and replay a new reply
|
|
1173
|
+
// that is actually addressed to another agent.
|
|
1174
|
+
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => activityText.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
1113
1175
|
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1114
1176
|
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1115
1177
|
// The same logical mention may already have arrived over WebSocket.
|
|
@@ -1165,7 +1227,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1165
1227
|
|
|
1166
1228
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
1167
1229
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
1168
|
-
const baseFor = (kind) =>
|
|
1230
|
+
const baseFor = (kind, delivery) => delivery?.watcherOwned
|
|
1231
|
+
? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
|
|
1232
|
+
: kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1169
1233
|
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1170
1234
|
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1171
1235
|
// shape is different and remains on its existing completion path.
|
|
@@ -1221,6 +1285,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1221
1285
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
1222
1286
|
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
1287
|
lane.busy = true
|
|
1288
|
+
lane.activeDelivery = delivery
|
|
1224
1289
|
const ctx = lane.pending.splice(0)
|
|
1225
1290
|
const activeTaskRef = lane.taskRefs.splice(0)[0] || null
|
|
1226
1291
|
const targets = [...lane.targets]
|
|
@@ -1232,7 +1297,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1232
1297
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1233
1298
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1234
1299
|
const recalled = memory.context(memoryRefs)
|
|
1235
|
-
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
|
|
1300
|
+
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1236
1301
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1237
1302
|
// work (full/sweep) uses the main model.
|
|
1238
1303
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
@@ -1244,8 +1309,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1244
1309
|
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1245
1310
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1246
1311
|
try {
|
|
1247
|
-
const result = await runners[laneName].runCycle(prompt, useModel,
|
|
1312
|
+
const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
|
|
1248
1313
|
let completionResult = result
|
|
1314
|
+
if (result?.subtype === 'canceled') {
|
|
1315
|
+
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1249
1318
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
1250
1319
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
1251
1320
|
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
@@ -1273,7 +1342,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1273
1342
|
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1274
1343
|
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1275
1344
|
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,
|
|
1345
|
+
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
1346
|
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1278
1347
|
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1279
1348
|
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
@@ -1304,7 +1373,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1304
1373
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1305
1374
|
}
|
|
1306
1375
|
}
|
|
1307
|
-
if (
|
|
1376
|
+
if (delivery?.watcherOwned) {
|
|
1308
1377
|
const reply = String(completionResult?.outputText || '').trim()
|
|
1309
1378
|
if (!reply) {
|
|
1310
1379
|
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
@@ -1317,6 +1386,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1317
1386
|
if (heartbeat) clearInterval(heartbeat)
|
|
1318
1387
|
laneStatusTargets[laneName].clear()
|
|
1319
1388
|
lane.busy = false
|
|
1389
|
+
lane.activeDelivery = null
|
|
1320
1390
|
const deferred = lane.deferred.shift()
|
|
1321
1391
|
if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
|
|
1322
1392
|
else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
@@ -1331,10 +1401,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1331
1401
|
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
1332
1402
|
}
|
|
1333
1403
|
|
|
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)
|
|
1404
|
+
// Route only concrete repository requests to the coding lane. Conversation
|
|
1405
|
+
// ownership is classified separately before this is consulted.
|
|
1406
|
+
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
1407
|
|
|
1339
1408
|
// Engineers change the model under the hood from chat: "/model", "/model sonnet",
|
|
1340
1409
|
// "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
|
|
@@ -1376,6 +1445,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1376
1445
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1377
1446
|
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1378
1447
|
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
1448
|
+
for (const alias of [self?.name, self?.identifier, self?.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
1379
1449
|
}
|
|
1380
1450
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1381
1451
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -1444,15 +1514,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1444
1514
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1445
1515
|
// when the payload carries no id.
|
|
1446
1516
|
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1447
|
-
|
|
1448
|
-
|
|
1517
|
+
const target = classifyConversationTarget(msg, [...selfAliases])
|
|
1518
|
+
if (target.action === 'ignore') {
|
|
1519
|
+
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
1520
|
+
// A redirect or another agent taking over invalidates any older work or
|
|
1521
|
+
// draft still queued for this thread. The event name alone cannot retain
|
|
1522
|
+
// ownership after the actual message says otherwise.
|
|
1523
|
+
if (cid != null && threadRoot != null && (target.reason === 'explicit-other-recipient' || target.reason === 'other-agent-chatter' || target.reason === 'redirected-to-later-agent')) {
|
|
1524
|
+
cancelThread(cid, threadRoot, text || target.reason)
|
|
1525
|
+
}
|
|
1449
1526
|
return
|
|
1450
1527
|
}
|
|
1451
1528
|
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1452
1529
|
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1530
|
+
if (target.action === 'stand_down') {
|
|
1531
|
+
if (cid != null && threadRoot != null) {
|
|
1532
|
+
cancelThread(cid, threadRoot, text || 'Explicit stand-down')
|
|
1533
|
+
const confirmation = `${who ? `@${who} ` : ''}Understood. I'm standing down.`
|
|
1534
|
+
void postMessageOnce({ key: `stand-down:${cid}:${threadRoot}`, channelId: Number(cid), parentId: threadRoot, content: confirmation, sourceKey, allowCancelled: true })
|
|
1535
|
+
.catch((e) => log('stand-down confirmation failed closed: ' + (e?.message || e)))
|
|
1536
|
+
}
|
|
1537
|
+
return
|
|
1538
|
+
}
|
|
1539
|
+
if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
|
|
1453
1540
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1454
|
-
|
|
1455
|
-
|
|
1541
|
+
// Every runtime carries the source thread through the queue so a later
|
|
1542
|
+
// redirect can cancel its process. Codex additionally delegates the final
|
|
1543
|
+
// post to the watcher for an authoritative last-moment thread check.
|
|
1544
|
+
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1545
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
|
|
1456
1546
|
: null
|
|
1457
1547
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1458
1548
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1460,11 +1550,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1460
1550
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
1461
1551
|
if (mcmd.report) {
|
|
1462
1552
|
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)
|
|
1553
|
+
const delivery = conversationDelivery('model')
|
|
1554
|
+
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
1555
|
} 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)
|
|
1556
|
+
const delivery = conversationDelivery('model')
|
|
1557
|
+
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
1558
|
} else {
|
|
1469
1559
|
const tgt = mcmd.target // 'chat' | 'code' | 'both'
|
|
1470
1560
|
const prev = `code ${codeModel}/chat ${liteModel}`
|
|
@@ -1474,8 +1564,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1474
1564
|
persistModel()
|
|
1475
1565
|
const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
|
|
1476
1566
|
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)
|
|
1567
|
+
const delivery = conversationDelivery('model')
|
|
1568
|
+
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
1569
|
}
|
|
1480
1570
|
return
|
|
1481
1571
|
}
|
|
@@ -1483,23 +1573,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1483
1573
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1484
1574
|
// subsequent working/typing heartbeat for its lane.
|
|
1485
1575
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1486
|
-
const codingMention = canCode &&
|
|
1487
|
-
const replyDelivery =
|
|
1576
|
+
const codingMention = canCode && conversationNeedsCode(text)
|
|
1577
|
+
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1488
1578
|
const ctx = cid != null
|
|
1489
|
-
? replyDelivery
|
|
1490
|
-
? `
|
|
1491
|
-
: `
|
|
1579
|
+
? replyDelivery?.watcherOwned
|
|
1580
|
+
? `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.`
|
|
1581
|
+
: `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
1582
|
: undefined
|
|
1493
1583
|
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
|
-
}
|
|
1584
|
+
// Activity indicators make accepted work visible. Do not add a canned
|
|
1585
|
+
// pickup message; the first channel message is the verified result or a
|
|
1586
|
+
// concrete blocker from the work lane.
|
|
1503
1587
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1504
1588
|
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1505
1589
|
} else if (k === 'error') {
|