openvisio-agent 0.18.2 → 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 +4 -2
- package/package.json +1 -1
- package/scripts/certify.mjs +19 -2
- package/src/events.mjs +53 -0
- package/src/mcp-http.mjs +33 -2
- package/src/watch.mjs +192 -67
package/README.md
CHANGED
|
@@ -56,12 +56,14 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
|
|
|
56
56
|
|
|
57
57
|
Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
|
|
58
58
|
|
|
59
|
-
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session
|
|
59
|
+
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
60
|
|
|
61
|
-
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only
|
|
61
|
+
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
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 = [
|
|
@@ -41,11 +46,16 @@ const assertions = [
|
|
|
41
46
|
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
|
|
42
47
|
['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
|
|
43
48
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
44
|
-
['
|
|
49
|
+
['optional ticket comments cannot block verified completion', watcher.includes("callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('comment_ticket is not exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
45
50
|
['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
|
|
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,16 +75,23 @@ 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')],
|
|
71
86
|
['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
|
|
72
|
-
['OpenCode backend prompts forbid relay-
|
|
87
|
+
['OpenCode backend prompts forbid relay and resource-discovery tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates')],
|
|
73
88
|
['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
|
|
74
89
|
['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
75
90
|
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
76
91
|
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
|
77
92
|
['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
|
|
93
|
+
['backend MCP tools are discovered and cached', mcpHttp.includes("method: 'tools/list'") && mcpHttp.includes('if (!refresh && toolsCache)') && watcher.includes('discoverMcpTools')],
|
|
94
|
+
['missing optional MCP tools use compatibility fallbacks', watcher.includes("reason: 'not-advertised'") && watcher.includes('recording the blocker in the ticket description')],
|
|
78
95
|
['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
|
|
79
96
|
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
|
|
80
97
|
['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
|
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/mcp-http.mjs
CHANGED
|
@@ -14,6 +14,8 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
14
14
|
let sessionId = ''
|
|
15
15
|
let rpcId = 0
|
|
16
16
|
let initializePromise = null
|
|
17
|
+
let toolsPromise = null
|
|
18
|
+
let toolsCache = null
|
|
17
19
|
|
|
18
20
|
const post = (message, withSession = true) => fetchImpl(url, {
|
|
19
21
|
method: 'POST',
|
|
@@ -27,7 +29,7 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
27
29
|
body: JSON.stringify(message),
|
|
28
30
|
})
|
|
29
31
|
|
|
30
|
-
const reset = () => { initialized = false; sessionId = '' }
|
|
32
|
+
const reset = () => { initialized = false; sessionId = ''; toolsCache = null }
|
|
31
33
|
|
|
32
34
|
const initialize = () => {
|
|
33
35
|
if (initialized) return Promise.resolve()
|
|
@@ -80,6 +82,35 @@ export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fe
|
|
|
80
82
|
return result
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const requestTools = async (retried = false) => {
|
|
86
|
+
await initialize()
|
|
87
|
+
const hadSession = !!sessionId
|
|
88
|
+
const res = await post({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/list', params: {} })
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
|
|
91
|
+
reset()
|
|
92
|
+
return requestTools(true)
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`MCP tools/list HTTP ${res.status}`)
|
|
95
|
+
}
|
|
96
|
+
const payload = await parsePayload(res)
|
|
97
|
+
if (payload.error) throw new Error(`MCP tools/list: ${payload.error.message || 'protocol error'}`)
|
|
98
|
+
const tools = payload.result?.tools ?? payload.tools
|
|
99
|
+
if (!Array.isArray(tools)) throw new Error('MCP tools/list returned no tool array')
|
|
100
|
+
toolsCache = tools
|
|
101
|
+
return tools
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Capability discovery is shared and cached. BYO runtimes use it before
|
|
105
|
+
// optional actions so a backend deployment that lacks one tool cannot trap a
|
|
106
|
+
// completed ticket in a permanent retry loop.
|
|
107
|
+
const listTools = (refresh = false) => {
|
|
108
|
+
if (!refresh && toolsCache) return Promise.resolve(toolsCache)
|
|
109
|
+
if (toolsPromise) return toolsPromise
|
|
110
|
+
toolsPromise = requestTools().finally(() => { toolsPromise = null })
|
|
111
|
+
return toolsPromise
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
|
|
84
|
-
return { callTool, initialize, reset, mode }
|
|
115
|
+
return { callTool, listTools, initialize, reset, mode }
|
|
85
116
|
}
|
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.',
|
|
@@ -42,7 +43,7 @@ const REPLY_DISCIPLINE = [
|
|
|
42
43
|
|
|
43
44
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
44
45
|
const CHAT_CHARTER = [
|
|
45
|
-
'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket,
|
|
46
|
+
'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity. A ticket-comment tool is optional and must not be assumed. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
|
|
46
47
|
'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
|
|
47
48
|
'',
|
|
48
49
|
REPLY_DISCIPLINE,
|
|
@@ -62,15 +63,15 @@ 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. ─────────
|
|
70
71
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
71
72
|
const CODE_CHARTER = [
|
|
72
73
|
'YOU ARE a connected CODING agent in an OpenVisio team, running ON THE USER\'S LAPTOP. You have REAL tools — use them; do NOT claim you lack a capability without checking what you actually hold. Your toolbox:',
|
|
73
|
-
' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket,
|
|
74
|
+
' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, and update_ticket, plus post_message/react_message/list_activity. Ticket comments are optional: use a comment tool only when it appears in the current tool list. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
|
|
74
75
|
' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
|
|
75
76
|
' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
|
|
76
77
|
'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
|
|
@@ -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:
|
|
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
|
|
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 ──────────────────────────────────────────────────────
|
|
@@ -687,7 +711,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
687
711
|
const canCode = !!workdir
|
|
688
712
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
689
713
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
690
|
-
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket,
|
|
714
|
+
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: never invent or call comment_ticket unless that exact tool appears. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
691
715
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
692
716
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
693
717
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -704,10 +728,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
704
728
|
const codexPushGuide = agent === 'codex' && canCode
|
|
705
729
|
? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
|
|
706
730
|
: ''
|
|
707
|
-
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, or other relay-
|
|
731
|
+
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, list_mcp_resource_templates, or other relay/resource-discovery tools; they are not backend team actions. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. Never call comment_ticket unless that exact optional tool is present.'
|
|
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,8 +812,47 @@ 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)
|
|
818
|
+
let mcpToolNames = null
|
|
819
|
+
let mcpToolDiscoveryPromise = null
|
|
820
|
+
let mcpToolDiscoveryWarned = false
|
|
821
|
+
const discoverMcpTools = (refresh = false) => {
|
|
822
|
+
if (!refresh && mcpToolNames) return Promise.resolve(mcpToolNames)
|
|
823
|
+
if (mcpToolDiscoveryPromise) return mcpToolDiscoveryPromise
|
|
824
|
+
mcpToolDiscoveryPromise = mcpClient.listTools(refresh).then((tools) => {
|
|
825
|
+
mcpToolNames = new Set(tools.map((tool) => String(tool?.name || '')).filter(Boolean))
|
|
826
|
+
const required = ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message']
|
|
827
|
+
const missing = required.filter((name) => !mcpToolNames.has(name))
|
|
828
|
+
log(`MCP tools ready (${mcpToolNames.size})${missing.length ? '; missing core tools: ' + missing.join(', ') : ''}`)
|
|
829
|
+
return mcpToolNames
|
|
830
|
+
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
831
|
+
return mcpToolDiscoveryPromise
|
|
832
|
+
}
|
|
833
|
+
const mcpSupports = async (name) => {
|
|
834
|
+
try { return (await discoverMcpTools()).has(name) }
|
|
835
|
+
catch (e) {
|
|
836
|
+
if (!mcpToolDiscoveryWarned) {
|
|
837
|
+
mcpToolDiscoveryWarned = true
|
|
838
|
+
log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
|
|
839
|
+
}
|
|
840
|
+
return null
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const callOptionalMcpTool = async (name, args) => {
|
|
844
|
+
const supported = await mcpSupports(name)
|
|
845
|
+
if (supported === false) return { called: false, reason: 'not-advertised' }
|
|
846
|
+
try { return { called: true, result: await callMcpTool(name, args) } }
|
|
847
|
+
catch (e) {
|
|
848
|
+
if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
|
|
849
|
+
try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
|
|
850
|
+
return { called: false, reason: 'not-available' }
|
|
851
|
+
}
|
|
852
|
+
throw e
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (mcpUrl) void discoverMcpTools().catch(() => {})
|
|
792
856
|
const toolData = (result) => {
|
|
793
857
|
const text = result?.content?.find?.((c) => c?.type === 'text')?.text
|
|
794
858
|
if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
|
|
@@ -800,12 +864,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
800
864
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
801
865
|
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
802
866
|
const messageDeliveries = new Map()
|
|
803
|
-
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 }) => {
|
|
804
886
|
const deliveryKey = String(key || '')
|
|
805
887
|
const message = String(content || '').trim()
|
|
806
888
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
807
889
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
808
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
|
|
809
900
|
|
|
810
901
|
const run = (async () => {
|
|
811
902
|
if (parentId != null) {
|
|
@@ -821,6 +912,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
821
912
|
}
|
|
822
913
|
}
|
|
823
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
|
+
|
|
824
920
|
sendStatus(Number(channelId), 'typing')
|
|
825
921
|
const result = toolData(await callMcpTool('post_message', {
|
|
826
922
|
...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
|
|
@@ -892,13 +988,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
892
988
|
refs: { projectId, ticketId },
|
|
893
989
|
meta: { reportKey: report.key, prUrl: report.prUrl },
|
|
894
990
|
})
|
|
895
|
-
// Ticket comments are
|
|
896
|
-
//
|
|
897
|
-
//
|
|
991
|
+
// Ticket comments are optional across backend deployments. Prefer the tool
|
|
992
|
+
// when advertised, but never let its absence block the verified channel
|
|
993
|
+
// handoff or trap the ticket in reconnect retries.
|
|
898
994
|
if (!reportedTaskComments.has(report.key)) {
|
|
899
|
-
|
|
995
|
+
try {
|
|
996
|
+
const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
997
|
+
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
998
|
+
else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and project-channel report')
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified project-channel report')
|
|
1001
|
+
}
|
|
900
1002
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
901
|
-
log('posted verified ticket comment for #' + ticketId)
|
|
902
1003
|
}
|
|
903
1004
|
if (reportedCompletions.has(report.key)) {
|
|
904
1005
|
pendingCompletionReports.delete(taskKey)
|
|
@@ -959,12 +1060,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
959
1060
|
refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
|
|
960
1061
|
})
|
|
961
1062
|
try {
|
|
962
|
-
await
|
|
963
|
-
delivered = true
|
|
964
|
-
|
|
965
|
-
} catch (e) {
|
|
966
|
-
log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
|
|
967
|
-
}
|
|
1063
|
+
const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
|
|
1064
|
+
if (comment.called) { delivered = true; return }
|
|
1065
|
+
log('comment_ticket is not exposed; recording the blocker in the ticket description for #' + ticketId)
|
|
1066
|
+
} catch (e) { log('optional ticket comment failed; recording the blocker in the ticket description for #' + ticketId) }
|
|
968
1067
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
969
1068
|
const ticket = current.ticket ?? current.task ?? current
|
|
970
1069
|
const description = String(ticket.description || '')
|
|
@@ -1013,6 +1112,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1013
1112
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1014
1113
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
1015
1114
|
selfAgentId = Number(self.id)
|
|
1115
|
+
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
1016
1116
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1017
1117
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1018
1118
|
const assigned = []
|
|
@@ -1062,13 +1162,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1062
1162
|
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1063
1163
|
for (const item of activities) {
|
|
1064
1164
|
const text = JSON.stringify(item)
|
|
1065
|
-
const lower = text.toLowerCase()
|
|
1066
1165
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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)) {
|
|
1072
1175
|
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1073
1176
|
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1074
1177
|
// The same logical mention may already have arrived over WebSocket.
|
|
@@ -1124,7 +1227,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1124
1227
|
|
|
1125
1228
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
1126
1229
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
1127
|
-
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
|
|
1128
1233
|
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1129
1234
|
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1130
1235
|
// shape is different and remains on its existing completion path.
|
|
@@ -1180,6 +1285,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1180
1285
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
1181
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 }
|
|
1182
1287
|
lane.busy = true
|
|
1288
|
+
lane.activeDelivery = delivery
|
|
1183
1289
|
const ctx = lane.pending.splice(0)
|
|
1184
1290
|
const activeTaskRef = lane.taskRefs.splice(0)[0] || null
|
|
1185
1291
|
const targets = [...lane.targets]
|
|
@@ -1191,7 +1297,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1191
1297
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1192
1298
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1193
1299
|
const recalled = memory.context(memoryRefs)
|
|
1194
|
-
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)
|
|
1195
1301
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1196
1302
|
// work (full/sweep) uses the main model.
|
|
1197
1303
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
@@ -1203,8 +1309,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1203
1309
|
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1204
1310
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1205
1311
|
try {
|
|
1206
|
-
const result = await runners[laneName].runCycle(prompt, useModel,
|
|
1312
|
+
const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
|
|
1207
1313
|
let completionResult = result
|
|
1314
|
+
if (result?.subtype === 'canceled') {
|
|
1315
|
+
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1208
1318
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
1209
1319
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
1210
1320
|
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
@@ -1232,7 +1342,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1232
1342
|
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1233
1343
|
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1234
1344
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1235
|
-
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'] } : {})
|
|
1236
1346
|
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1237
1347
|
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1238
1348
|
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
@@ -1263,7 +1373,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1263
1373
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1264
1374
|
}
|
|
1265
1375
|
}
|
|
1266
|
-
if (
|
|
1376
|
+
if (delivery?.watcherOwned) {
|
|
1267
1377
|
const reply = String(completionResult?.outputText || '').trim()
|
|
1268
1378
|
if (!reply) {
|
|
1269
1379
|
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
@@ -1276,6 +1386,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1276
1386
|
if (heartbeat) clearInterval(heartbeat)
|
|
1277
1387
|
laneStatusTargets[laneName].clear()
|
|
1278
1388
|
lane.busy = false
|
|
1389
|
+
lane.activeDelivery = null
|
|
1279
1390
|
const deferred = lane.deferred.shift()
|
|
1280
1391
|
if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
|
|
1281
1392
|
else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
@@ -1290,10 +1401,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1290
1401
|
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
1291
1402
|
}
|
|
1292
1403
|
|
|
1293
|
-
// Route
|
|
1294
|
-
//
|
|
1295
|
-
const
|
|
1296
|
-
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)
|
|
1297
1407
|
|
|
1298
1408
|
// Engineers change the model under the hood from chat: "/model", "/model sonnet",
|
|
1299
1409
|
// "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
|
|
@@ -1335,6 +1445,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1335
1445
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1336
1446
|
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1337
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())
|
|
1338
1449
|
}
|
|
1339
1450
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1340
1451
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -1403,15 +1514,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1403
1514
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1404
1515
|
// when the payload carries no id.
|
|
1405
1516
|
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
+
}
|
|
1408
1526
|
return
|
|
1409
1527
|
}
|
|
1410
1528
|
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1411
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)
|
|
1412
1540
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1413
|
-
|
|
1414
|
-
|
|
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' }
|
|
1415
1546
|
: null
|
|
1416
1547
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1417
1548
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1419,11 +1550,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1419
1550
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
1420
1551
|
if (mcmd.report) {
|
|
1421
1552
|
log('model query → code ' + codeModel + ' / chat ' + liteModel)
|
|
1422
|
-
const delivery =
|
|
1423
|
-
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)
|
|
1424
1555
|
} else if (mcmd.invalid) {
|
|
1425
|
-
const delivery =
|
|
1426
|
-
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)
|
|
1427
1558
|
} else {
|
|
1428
1559
|
const tgt = mcmd.target // 'chat' | 'code' | 'both'
|
|
1429
1560
|
const prev = `code ${codeModel}/chat ${liteModel}`
|
|
@@ -1433,8 +1564,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1433
1564
|
persistModel()
|
|
1434
1565
|
const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
|
|
1435
1566
|
log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
|
|
1436
|
-
const delivery =
|
|
1437
|
-
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)
|
|
1438
1569
|
}
|
|
1439
1570
|
return
|
|
1440
1571
|
}
|
|
@@ -1442,23 +1573,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1442
1573
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1443
1574
|
// subsequent working/typing heartbeat for its lane.
|
|
1444
1575
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1445
|
-
const codingMention = canCode &&
|
|
1446
|
-
const replyDelivery =
|
|
1576
|
+
const codingMention = canCode && conversationNeedsCode(text)
|
|
1577
|
+
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1447
1578
|
const ctx = cid != null
|
|
1448
|
-
? replyDelivery
|
|
1449
|
-
? `
|
|
1450
|
-
: `
|
|
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.`
|
|
1451
1582
|
: undefined
|
|
1452
1583
|
if (codingMention) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
} else {
|
|
1457
|
-
const ack = cid != null
|
|
1458
|
-
? `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.`
|
|
1459
|
-
: undefined
|
|
1460
|
-
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1461
|
-
}
|
|
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.
|
|
1462
1587
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1463
1588
|
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1464
1589
|
} else if (k === 'error') {
|