openvisio-agent 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/bin/cli.mjs +40 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +30 -1
- package/src/events.mjs +65 -3
- package/src/mcp-http.mjs +85 -0
- package/src/memory.mjs +82 -0
- package/src/opencode-config.mjs +29 -0
- package/src/pr-push.mjs +112 -0
- package/src/watch.mjs +251 -110
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,11 @@ 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, opencodeEventEvidence, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
14
|
+
import { createByoMemoryGraph } from './memory.mjs'
|
|
15
|
+
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
16
|
+
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
17
|
+
import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
|
|
14
18
|
|
|
15
19
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
16
20
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -286,25 +290,25 @@ export async function runWatch({ flags }) {
|
|
|
286
290
|
// ── opencode cycle runner ─────────────────────────────────────────────────────
|
|
287
291
|
// opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
|
|
288
292
|
// so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
|
|
289
|
-
// The openvisio-team MCP is declared in
|
|
290
|
-
//
|
|
293
|
+
// The openvisio-team MCP is declared in a private per-agent config. The actual
|
|
294
|
+
// code workspace is passed through --dir, so two agents never overwrite each
|
|
295
|
+
// other's MCP identity even when they work in the same repository.
|
|
296
|
+
// `--auto` approves tool use non-interactively.
|
|
291
297
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
292
298
|
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
293
|
-
|
|
294
|
-
// per-agent dir for chat-only agents.
|
|
295
|
-
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
299
|
+
const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
|
|
296
300
|
const bin = onPath('opencode') || 'opencode'
|
|
301
|
+
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
302
|
+
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
|
|
297
303
|
let configured = false
|
|
298
304
|
const ensureConfig = () => {
|
|
299
305
|
if (configured) return
|
|
300
306
|
configured = true
|
|
301
307
|
try {
|
|
302
|
-
mkdirSync(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
|
|
307
|
-
}, true)
|
|
308
|
+
mkdirSync(configDir, { recursive: true })
|
|
309
|
+
mkdirSync(workspace, { recursive: true })
|
|
310
|
+
if (opencodeConfig) {
|
|
311
|
+
writeJson(opencodeConfigPath, opencodeConfig, true)
|
|
308
312
|
} else {
|
|
309
313
|
log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
310
314
|
}
|
|
@@ -320,7 +324,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
320
324
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
321
325
|
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
322
326
|
// OpenCode exited; raw events tell us which tools actually completed.
|
|
323
|
-
const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
|
|
327
|
+
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
324
328
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
325
329
|
let outputText = '', jsonlBuffer = ''
|
|
326
330
|
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
@@ -348,6 +352,10 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
348
352
|
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
349
353
|
if (!evidence.tool) return
|
|
350
354
|
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
355
|
+
if (evidence.failed && evidence.toolError) {
|
|
356
|
+
const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
|
|
357
|
+
log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
|
|
358
|
+
}
|
|
351
359
|
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
352
360
|
if (evidence.mcpTool) {
|
|
353
361
|
mcpCalls.add(evidence.mcpTool)
|
|
@@ -368,7 +376,18 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
368
376
|
}, maxCycleMs)
|
|
369
377
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
370
378
|
try {
|
|
371
|
-
child = spawn(bin, args, {
|
|
379
|
+
child = spawn(bin, args, {
|
|
380
|
+
cwd: configDir,
|
|
381
|
+
env: {
|
|
382
|
+
...process.env,
|
|
383
|
+
OPENCODE_CONFIG: opencodeConfigPath,
|
|
384
|
+
// Inline config has higher precedence than a project opencode.json.
|
|
385
|
+
// This prevents a stale generated workspace file from replacing this
|
|
386
|
+
// agent's URL or credentials while keeping normal project settings.
|
|
387
|
+
...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
|
|
388
|
+
},
|
|
389
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
390
|
+
})
|
|
372
391
|
child.stdout?.on('data', (data) => {
|
|
373
392
|
jsonlBuffer += String(data)
|
|
374
393
|
const lines = jsonlBuffer.split('\n')
|
|
@@ -387,7 +406,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
387
406
|
})
|
|
388
407
|
}
|
|
389
408
|
|
|
390
|
-
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' +
|
|
409
|
+
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
391
410
|
return { runCycle, canCode }
|
|
392
411
|
}
|
|
393
412
|
|
|
@@ -402,12 +421,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
402
421
|
const tomlString = (v) => JSON.stringify(String(v))
|
|
403
422
|
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
404
423
|
|
|
405
|
-
function runCycle(prompt, cycleModel) {
|
|
424
|
+
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
406
425
|
return new Promise((resolve) => {
|
|
407
426
|
const m = cycleModel || model
|
|
408
427
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
428
|
+
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
429
|
+
const disabledToolConfig = disabledMcpTools.length ? `, disabled_tools = [${disabledMcpTools.map(tomlString).join(', ')}]` : ''
|
|
409
430
|
const mcpOverride = mcpUrl
|
|
410
|
-
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
|
|
431
|
+
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''}${disabledToolConfig} } }`
|
|
411
432
|
: ''
|
|
412
433
|
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
413
434
|
...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
|
|
@@ -448,8 +469,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
448
469
|
const inspectLine = (line) => {
|
|
449
470
|
const s = line.trim()
|
|
450
471
|
if (!s) return
|
|
472
|
+
policyBlock = codexPolicyBlock(s) || policyBlock
|
|
451
473
|
if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
|
|
452
|
-
if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(s)) didRepoMutation = true
|
|
474
|
+
if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(s)) didRepoMutation = true
|
|
453
475
|
if (/post_message|comment_ticket/i.test(s)) didMessage = true
|
|
454
476
|
try {
|
|
455
477
|
const event = JSON.parse(s)
|
|
@@ -461,6 +483,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
461
483
|
try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
|
|
462
484
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
463
485
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
486
|
+
if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
|
|
464
487
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
465
488
|
if (/post_message/.test(tool)) didChannelMessage = true
|
|
466
489
|
if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
|
|
@@ -664,7 +687,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
664
687
|
const canCode = !!workdir
|
|
665
688
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
666
689
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
667
|
-
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, get_ticket, update_ticket, comment_ticket, and list_activity
|
|
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, comment_ticket, list_channels, list_message_thread, and list_activity as applicable. get_marching_orders, poll_inbox, and get_resource are relay-only and are NOT available 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.`
|
|
668
691
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
669
692
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
670
693
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -678,8 +701,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
678
701
|
work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
|
|
679
702
|
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
|
|
680
703
|
}
|
|
681
|
-
const
|
|
682
|
-
|
|
704
|
+
const codexPushGuide = agent === 'codex' && canCode
|
|
705
|
+
? '\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
|
+
: ''
|
|
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-only tools; they are not exposed by the backend MCP. 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.'
|
|
708
|
+
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
|
+
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
710
|
+
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
683
711
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
684
712
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
685
713
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -687,8 +715,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
687
715
|
let liteModel = chatModel || model
|
|
688
716
|
|
|
689
717
|
const lanes = {
|
|
690
|
-
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
691
|
-
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
718
|
+
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
719
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
692
720
|
}
|
|
693
721
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
694
722
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -703,6 +731,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
703
731
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
704
732
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
705
733
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
734
|
+
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
735
|
+
const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
|
|
706
736
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
707
737
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
708
738
|
// without re-running the model or posting the same result twice.
|
|
@@ -714,6 +744,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
714
744
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
715
745
|
// attempting the same rejected egress action.
|
|
716
746
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
747
|
+
const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
|
|
717
748
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
718
749
|
const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
|
|
719
750
|
const pruneMentionSignatures = () => {
|
|
@@ -728,7 +759,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
728
759
|
seenMentions: [...seenMentions],
|
|
729
760
|
recentMentionSignatures: [...recentMentionSignatures],
|
|
730
761
|
seenActivities: [...seenActivities],
|
|
762
|
+
deliveredReplies: [...deliveredReplies],
|
|
731
763
|
blockedTasks: [...blockedTasks],
|
|
764
|
+
blockedTaskRepos: [...blockedTaskRepos],
|
|
732
765
|
pendingCompletionReports: [...pendingCompletionReports],
|
|
733
766
|
reportedCompletions: [...reportedCompletions],
|
|
734
767
|
reportedTaskComments: [...reportedTaskComments],
|
|
@@ -754,60 +787,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
754
787
|
let lastTaskTriggeredAt = 0
|
|
755
788
|
let lastInboxSignature = ''
|
|
756
789
|
let selfAgentId = null
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const mcpPayload = async (res) => {
|
|
761
|
-
const body = await res.text()
|
|
762
|
-
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
763
|
-
return JSON.parse(data || body || '{}')
|
|
764
|
-
}
|
|
765
|
-
const mcpPost = async (message, withSession = true) => {
|
|
766
|
-
const res = await fetch(mcpUrl, {
|
|
767
|
-
method: 'POST',
|
|
768
|
-
headers: {
|
|
769
|
-
'content-type': 'application/json',
|
|
770
|
-
accept: 'application/json, text/event-stream',
|
|
771
|
-
'x-agent-api-key': apiKey,
|
|
772
|
-
'x-agent-identifier': identifier,
|
|
773
|
-
...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
|
|
774
|
-
},
|
|
775
|
-
body: JSON.stringify(message),
|
|
776
|
-
})
|
|
777
|
-
return res
|
|
778
|
-
}
|
|
779
|
-
const ensureMcpSession = async () => {
|
|
780
|
-
if (mcpSessionId) return
|
|
781
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.0' } } }, false)
|
|
782
|
-
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
783
|
-
await mcpPayload(res)
|
|
784
|
-
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
785
|
-
if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
|
|
786
|
-
const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
787
|
-
if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
|
|
788
|
-
}
|
|
789
|
-
const callMcpTool = async (name, args = {}, retried = false) => {
|
|
790
|
-
await ensureMcpSession()
|
|
791
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
|
|
792
|
-
if (!res.ok) {
|
|
793
|
-
if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
|
|
794
|
-
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
795
|
-
}
|
|
796
|
-
const payload = await mcpPayload(res)
|
|
797
|
-
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
798
|
-
const result = payload.result ?? payload
|
|
799
|
-
if (result?.isError) {
|
|
800
|
-
const detail = result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error'
|
|
801
|
-
throw new Error(`MCP ${name}: ${detail}`)
|
|
802
|
-
}
|
|
803
|
-
return result
|
|
804
|
-
}
|
|
790
|
+
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.2', log })
|
|
791
|
+
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
805
792
|
const toolData = (result) => {
|
|
806
793
|
const text = result?.content?.find?.((c) => c?.type === 'text')?.text
|
|
807
794
|
if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
|
|
808
795
|
try { return JSON.parse(text) } catch { return { text } }
|
|
809
796
|
}
|
|
810
797
|
|
|
798
|
+
// All watcher-owned message delivery goes through this gate. For threaded
|
|
799
|
+
// replies it first reads the live backend thread and inspects rows already
|
|
800
|
+
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
801
|
+
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
802
|
+
const messageDeliveries = new Map()
|
|
803
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
|
|
804
|
+
const deliveryKey = String(key || '')
|
|
805
|
+
const message = String(content || '').trim()
|
|
806
|
+
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
807
|
+
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
808
|
+
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
809
|
+
|
|
810
|
+
const run = (async () => {
|
|
811
|
+
if (parentId != null) {
|
|
812
|
+
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
813
|
+
const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
|
|
814
|
+
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
815
|
+
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
816
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
817
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
|
|
818
|
+
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
819
|
+
log('message delivery ' + deliveryKey + ' already rendered — skipped')
|
|
820
|
+
return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
sendStatus(Number(channelId), 'typing')
|
|
825
|
+
const result = toolData(await callMcpTool('post_message', {
|
|
826
|
+
...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
|
|
827
|
+
channel_id: Number(channelId),
|
|
828
|
+
...(parentId != null ? { parent_id: Number(parentId) } : {}),
|
|
829
|
+
content: message,
|
|
830
|
+
}))
|
|
831
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
832
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
|
|
833
|
+
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
834
|
+
return { posted: true, result }
|
|
835
|
+
})().finally(() => messageDeliveries.delete(deliveryKey))
|
|
836
|
+
messageDeliveries.set(deliveryKey, run)
|
|
837
|
+
return run
|
|
838
|
+
}
|
|
839
|
+
|
|
811
840
|
const statusChannelCache = new Map()
|
|
812
841
|
const projectStatusChannel = async (projectId) => {
|
|
813
842
|
const key = Number(projectId)
|
|
@@ -829,6 +858,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
829
858
|
}
|
|
830
859
|
}
|
|
831
860
|
|
|
861
|
+
const announceIntroduction = async () => {
|
|
862
|
+
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
863
|
+
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
864
|
+
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
865
|
+
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
866
|
+
const channelId = await projectStatusChannel(project.id)
|
|
867
|
+
if (!Number.isFinite(Number(channelId))) return false
|
|
868
|
+
await postMessageOnce({
|
|
869
|
+
key: `intro:${identifier}:${project.id}`,
|
|
870
|
+
projectId: Number(project.id),
|
|
871
|
+
channelId: Number(channelId),
|
|
872
|
+
content: "I'm here, I pick up tasks assigned to me, and I respond to @mentions. Send work my way whenever you need me.",
|
|
873
|
+
})
|
|
874
|
+
return true
|
|
875
|
+
}
|
|
876
|
+
|
|
832
877
|
const announceTaskCompletion = async (taskRef, result = {}) => {
|
|
833
878
|
const projectId = Number(taskRef?.projectId)
|
|
834
879
|
const ticketId = Number(taskRef?.ticketId)
|
|
@@ -838,6 +883,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
838
883
|
const ticket = current.ticket ?? current.task ?? current
|
|
839
884
|
const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
|
|
840
885
|
if (!report) return false
|
|
886
|
+
const memoryKey = `ticket:${projectId}:${ticketId}`
|
|
887
|
+
memory.remember({
|
|
888
|
+
key: memoryKey,
|
|
889
|
+
kind: 'ticket',
|
|
890
|
+
state: 'handoff',
|
|
891
|
+
summary: report.content,
|
|
892
|
+
refs: { projectId, ticketId },
|
|
893
|
+
meta: { reportKey: report.key, prUrl: report.prUrl },
|
|
894
|
+
})
|
|
841
895
|
// Ticket comments are now a backend first-class surface. The watcher owns the
|
|
842
896
|
// final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
|
|
843
897
|
// loop consistently, and the persisted report key prevents reconnect repeats.
|
|
@@ -862,10 +916,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
862
916
|
}
|
|
863
917
|
const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
|
|
864
918
|
if (!Number.isFinite(channelId)) return false
|
|
865
|
-
|
|
866
|
-
await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
|
|
919
|
+
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
867
920
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
868
921
|
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
922
|
+
memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
|
|
869
923
|
log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
|
|
870
924
|
return true
|
|
871
925
|
}
|
|
@@ -882,11 +936,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
882
936
|
let delivered = false
|
|
883
937
|
if (Number.isFinite(channelId)) {
|
|
884
938
|
try {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
content: notice,
|
|
889
|
-
})
|
|
939
|
+
const parentId = parentMatch ? Number(parentMatch[1]) : null
|
|
940
|
+
const blockerKey = `blocker:${channelId}:${parentId ?? 'top'}:${normalizeRenderedMessageText(notice).slice(0, 180)}`
|
|
941
|
+
await postMessageOnce({ key: blockerKey, channelId, parentId, projectId, content: notice, sourceKey: Number.isFinite(ticketId) && Number.isFinite(projectId) ? `ticket:${projectId}:${ticketId}` : '' })
|
|
890
942
|
delivered = true
|
|
891
943
|
} catch (e) {
|
|
892
944
|
log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
|
|
@@ -899,6 +951,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
899
951
|
|
|
900
952
|
const key = `${projectId}:${ticketId}`
|
|
901
953
|
if (pause) { blockedTasks.add(key); persistReplay() }
|
|
954
|
+
memory.remember({
|
|
955
|
+
key: `ticket:${projectId}:${ticketId}`,
|
|
956
|
+
kind: 'ticket',
|
|
957
|
+
state: pause ? 'authorization-blocked' : 'blocked',
|
|
958
|
+
summary: ticketNotice,
|
|
959
|
+
refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
|
|
960
|
+
})
|
|
902
961
|
try {
|
|
903
962
|
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
|
|
904
963
|
delivered = true
|
|
@@ -921,6 +980,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
921
980
|
}
|
|
922
981
|
|
|
923
982
|
const reportPolicyBlock = async (prompt, taskRef, block) => {
|
|
983
|
+
if (block?.kind === 'pr-push-authorization-required') {
|
|
984
|
+
const location = block.root ? ` from \`${block.root}\`` : ' from the repository'
|
|
985
|
+
const notice = `Action required: run \`openvisio-agent authorize-pr-push\`${location}. This is a one-time, repository-scoped opt-in. It permits only the constrained \`openvisio-agent push-pr-branch\` helper for the current \`agent/*\` branch, never main/master, force pushes, another remote, or merges. I've paused the ticket until it is enabled.`
|
|
986
|
+
const ticketNotice = `I'm paused before the PR push. Run \`openvisio-agent authorize-pr-push\`${location}; the watcher will resume this ticket after the repository-scoped helper is authorized.`
|
|
987
|
+
const projectId = Number(taskRef?.projectId)
|
|
988
|
+
const ticketId = Number(taskRef?.ticketId)
|
|
989
|
+
if (block.root && Number.isFinite(projectId) && Number.isFinite(ticketId)) {
|
|
990
|
+
blockedTaskRepos.set(`${projectId}:${ticketId}`, block.root)
|
|
991
|
+
persistReplay()
|
|
992
|
+
}
|
|
993
|
+
return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
|
|
994
|
+
}
|
|
924
995
|
const command = block?.command || 'the requested external repository action'
|
|
925
996
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
926
997
|
const approval = block?.commit && block?.branch
|
|
@@ -962,12 +1033,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
962
1033
|
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
963
1034
|
const taskKey = `${project.id}:${task.id}`
|
|
964
1035
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1036
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
965
1037
|
if (pendingCompletionReports.has(taskKey)) {
|
|
966
1038
|
const activityChannel = await projectStatusChannel(project.id)
|
|
967
1039
|
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
968
1040
|
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
969
1041
|
}
|
|
970
|
-
if (blockedTasks.delete(taskKey)) persistReplay()
|
|
1042
|
+
if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
|
|
971
1043
|
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
972
1044
|
// the ticket back to an actionable column, that update must start a
|
|
973
1045
|
// fresh work cycle.
|
|
@@ -976,11 +1048,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
976
1048
|
}
|
|
977
1049
|
if (blockedTasks.has(taskKey)) {
|
|
978
1050
|
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1051
|
+
const blockedRepo = blockedTaskRepos.get(taskKey)
|
|
1052
|
+
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1053
|
+
if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
1054
|
+
blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
1055
|
+
log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
|
|
982
1056
|
} else continue
|
|
983
1057
|
}
|
|
1058
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
|
|
984
1059
|
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
985
1060
|
}
|
|
986
1061
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
@@ -999,7 +1074,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
999
1074
|
// The same logical mention may already have arrived over WebSocket.
|
|
1000
1075
|
// Share the id/signature guard instead of starting a second model turn.
|
|
1001
1076
|
if (markMentionHandled(activityMessage, activityChannelId)) continue
|
|
1002
|
-
mentionActivity.push({
|
|
1077
|
+
mentionActivity.push({
|
|
1078
|
+
projectId: project.id,
|
|
1079
|
+
project: project.name,
|
|
1080
|
+
channelId: activityChannelId,
|
|
1081
|
+
message: activityMessage,
|
|
1082
|
+
activity: item,
|
|
1083
|
+
})
|
|
1003
1084
|
}
|
|
1004
1085
|
}
|
|
1005
1086
|
}
|
|
@@ -1022,18 +1103,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1022
1103
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
1023
1104
|
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1024
1105
|
lastInboxSignature = inboxSignature
|
|
1025
|
-
log('backlog reconciliation found mention
|
|
1026
|
-
|
|
1106
|
+
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1107
|
+
// Replay each real message through the exact same delivery path as a live
|
|
1108
|
+
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1109
|
+
// consult the rendered thread before any reply is emitted.
|
|
1110
|
+
for (const mention of mentionActivity) {
|
|
1111
|
+
onEvent('agent:mention', {
|
|
1112
|
+
project_id: mention.projectId,
|
|
1113
|
+
channel_id: mention.channelId,
|
|
1114
|
+
message: mention.message,
|
|
1115
|
+
_mentionAlreadyMarked: true,
|
|
1116
|
+
})
|
|
1117
|
+
}
|
|
1027
1118
|
} else if (!mentionActivity.length) lastInboxSignature = ''
|
|
1028
1119
|
} catch (e) {
|
|
1029
|
-
|
|
1120
|
+
mcpClient.reset()
|
|
1030
1121
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1031
1122
|
} finally { backlogProbeBusy = false }
|
|
1032
1123
|
}
|
|
1033
1124
|
|
|
1034
1125
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
1035
1126
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
1036
|
-
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ?
|
|
1127
|
+
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1037
1128
|
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1038
1129
|
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1039
1130
|
// shape is different and remains on its existing completion path.
|
|
@@ -1074,9 +1165,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1074
1165
|
lastTaskSignature = ''
|
|
1075
1166
|
}
|
|
1076
1167
|
|
|
1077
|
-
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
1168
|
+
async function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1078
1169
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1079
1170
|
const lane = lanes[laneName]
|
|
1171
|
+
// A guarded reply is never coalesced with another event. It carries one
|
|
1172
|
+
// source message and one delivery key, so queue it as an independent cycle.
|
|
1173
|
+
if (lane.busy && delivery) {
|
|
1174
|
+
lane.deferred.push({ kind, context, targetChannels, taskRef, delivery })
|
|
1175
|
+
log(laneName + ' lane busy — queued one guarded ' + kind + ' cycle')
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1080
1178
|
if (context) lane.pending.push(context)
|
|
1081
1179
|
if (taskRef) lane.taskRefs.push(taskRef)
|
|
1082
1180
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
@@ -1089,7 +1187,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1089
1187
|
laneStatusTargets[laneName] = new Set(targets)
|
|
1090
1188
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
1091
1189
|
// message is just the event context + the small base instruction.
|
|
1092
|
-
const
|
|
1190
|
+
const memoryRefs = delivery
|
|
1191
|
+
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1192
|
+
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1193
|
+
const recalled = memory.context(memoryRefs)
|
|
1194
|
+
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
|
|
1093
1195
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1094
1196
|
// work (full/sweep) uses the main model.
|
|
1095
1197
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
@@ -1101,7 +1203,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1101
1203
|
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1102
1204
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1103
1205
|
try {
|
|
1104
|
-
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
1206
|
+
const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
|
|
1105
1207
|
let completionResult = result
|
|
1106
1208
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
1107
1209
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
@@ -1130,7 +1232,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1130
1232
|
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1131
1233
|
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1132
1234
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1133
|
-
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,
|
|
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, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
|
|
1134
1236
|
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1135
1237
|
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1136
1238
|
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
@@ -1161,11 +1263,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1161
1263
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1162
1264
|
}
|
|
1163
1265
|
}
|
|
1266
|
+
if (agent === 'codex' && delivery) {
|
|
1267
|
+
const reply = String(completionResult?.outputText || '').trim()
|
|
1268
|
+
if (!reply) {
|
|
1269
|
+
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
1270
|
+
} else {
|
|
1271
|
+
try { await postMessageOnce({ ...delivery, content: reply }) }
|
|
1272
|
+
catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1164
1275
|
} finally {
|
|
1165
1276
|
if (heartbeat) clearInterval(heartbeat)
|
|
1166
1277
|
laneStatusTargets[laneName].clear()
|
|
1167
1278
|
lane.busy = false
|
|
1168
|
-
|
|
1279
|
+
const deferred = lane.deferred.shift()
|
|
1280
|
+
if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
|
|
1281
|
+
else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
1169
1282
|
}
|
|
1170
1283
|
}
|
|
1171
1284
|
|
|
@@ -1229,22 +1342,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1229
1342
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1230
1343
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1231
1344
|
const key = `${projectId}:${ticketId}`
|
|
1232
|
-
if (!belongsToSelf) {
|
|
1345
|
+
if (!belongsToSelf) {
|
|
1346
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1347
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
|
|
1348
|
+
}
|
|
1233
1349
|
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1350
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1234
1351
|
if (pendingCompletionReports.has(key)) {
|
|
1235
1352
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1236
1353
|
try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
|
|
1237
1354
|
catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
|
|
1238
1355
|
}
|
|
1239
|
-
blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1356
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1240
1357
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1241
1358
|
return
|
|
1242
1359
|
}
|
|
1243
1360
|
if (blockedTasks.has(key)) {
|
|
1244
1361
|
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1362
|
+
const blockedRepo = blockedTaskRepos.get(key)
|
|
1363
|
+
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1364
|
+
if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
1365
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
|
|
1366
|
+
log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
|
|
1248
1367
|
} else {
|
|
1249
1368
|
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
1250
1369
|
return
|
|
@@ -1253,6 +1372,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1253
1372
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1254
1373
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1255
1374
|
const title = String(ticket.title || hinted.title || '')
|
|
1375
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
|
|
1256
1376
|
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
1257
1377
|
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
1258
1378
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
@@ -1282,20 +1402,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1282
1402
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
1283
1403
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1284
1404
|
// when the payload carries no id.
|
|
1285
|
-
if (markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1405
|
+
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1286
1406
|
if (requestTargetsLaterAgent(text, [slug, identifier])) {
|
|
1287
1407
|
log('agent:mention addressed to a later-mentioned agent — skipped')
|
|
1288
1408
|
return
|
|
1289
1409
|
}
|
|
1410
|
+
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1411
|
+
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1412
|
+
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1413
|
+
const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
|
|
1414
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
|
|
1415
|
+
: null
|
|
1290
1416
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1291
1417
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
1292
1418
|
if (mcmd) {
|
|
1293
1419
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
1294
1420
|
if (mcmd.report) {
|
|
1295
1421
|
log('model query → code ' + codeModel + ' / chat ' + liteModel)
|
|
1296
|
-
|
|
1422
|
+
const delivery = guardedDelivery('model')
|
|
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)
|
|
1297
1424
|
} else if (mcmd.invalid) {
|
|
1298
|
-
|
|
1425
|
+
const delivery = guardedDelivery('model')
|
|
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)
|
|
1299
1427
|
} else {
|
|
1300
1428
|
const tgt = mcmd.target // 'chat' | 'code' | 'both'
|
|
1301
1429
|
const prev = `code ${codeModel}/chat ${liteModel}`
|
|
@@ -1305,7 +1433,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1305
1433
|
persistModel()
|
|
1306
1434
|
const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
|
|
1307
1435
|
log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
|
|
1308
|
-
|
|
1436
|
+
const delivery = guardedDelivery('model')
|
|
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)
|
|
1309
1438
|
}
|
|
1310
1439
|
return
|
|
1311
1440
|
}
|
|
@@ -1314,16 +1443,24 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1314
1443
|
// subsequent working/typing heartbeat for its lane.
|
|
1315
1444
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1316
1445
|
const codingMention = canCode && needsCode(text)
|
|
1446
|
+
const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1317
1447
|
const ctx = cid != null
|
|
1318
|
-
?
|
|
1448
|
+
? replyDelivery
|
|
1449
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer 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, or poll_inbox.`
|
|
1450
|
+
: `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is 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. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. 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, or poll_inbox, and after your single reply, STOP.`
|
|
1319
1451
|
: undefined
|
|
1320
1452
|
if (codingMention) {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
:
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1453
|
+
if (agent === 'codex' && cid != null) {
|
|
1454
|
+
const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
|
|
1455
|
+
void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
|
|
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
|
+
}
|
|
1462
|
+
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1463
|
+
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1327
1464
|
} else if (k === 'error') {
|
|
1328
1465
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
1329
1466
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
@@ -1345,9 +1482,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1345
1482
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
1346
1483
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
1347
1484
|
if (!existsSync(introMarker)) {
|
|
1348
|
-
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1349
1485
|
log('first connection — introducing self to the workspace')
|
|
1350
|
-
introTimer = setTimeout(() =>
|
|
1486
|
+
introTimer = setTimeout(() => {
|
|
1487
|
+
void announceIntroduction().then((delivered) => {
|
|
1488
|
+
if (!delivered) return
|
|
1489
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1490
|
+
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
1491
|
+
}, 5000) // let the socket subscribe first
|
|
1351
1492
|
}
|
|
1352
1493
|
// Reconciliation replaces the old model-driven startup/daily sweep. It uses
|
|
1353
1494
|
// supported MCP tools directly, stays silent when empty, and hands verified
|