openvisio-agent 0.16.2 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +4 -2
- package/scripts/certify.mjs +57 -0
- package/src/events.mjs +23 -0
- package/src/watch.mjs +216 -80
- package/src/ws.mjs +0 -6
package/README.md
CHANGED
|
@@ -58,6 +58,8 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
59
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session uses the backend's actual tools (`list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity`) to recover assigned tasks and recent mention activity missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
60
|
|
|
61
|
+
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.
|
|
62
|
+
|
|
61
63
|
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.
|
|
62
64
|
|
|
63
65
|
```bash
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openvisio-agent": "bin/cli.mjs"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test"
|
|
10
|
+
"test": "node --test",
|
|
11
|
+
"certify": "node scripts/certify.mjs"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"bin",
|
|
15
|
+
"scripts",
|
|
14
16
|
"src",
|
|
15
17
|
"README.md"
|
|
16
18
|
],
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
7
|
+
const repo = join(root, '..', '..')
|
|
8
|
+
const failures = []
|
|
9
|
+
|
|
10
|
+
function run(label, command, args, cwd = root) {
|
|
11
|
+
const result = spawnSync(command, args, { cwd, encoding: 'utf8', env: { ...process.env, npm_config_cache: '/private/tmp/openvisio-npm-cache' } })
|
|
12
|
+
if (result.status !== 0) failures.push(`${label}\n${result.stdout || ''}${result.stderr || ''}`)
|
|
13
|
+
else process.stdout.write(`✓ ${label}\n`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const file of readdirSync(join(root, 'src')).filter((name) => name.endsWith('.mjs'))) run(`syntax ${file}`, process.execPath, ['--check', join(root, 'src', file)])
|
|
17
|
+
for (const file of readdirSync(join(root, 'bin')).filter((name) => name.endsWith('.mjs'))) run(`syntax ${file}`, process.execPath, ['--check', join(root, 'bin', file)])
|
|
18
|
+
run('unit and behavior tests', process.execPath, ['--test'])
|
|
19
|
+
run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
|
|
20
|
+
run('package dry run', 'npm', ['pack', '--dry-run'])
|
|
21
|
+
run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
22
|
+
|
|
23
|
+
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
24
|
+
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
25
|
+
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
26
|
+
const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
27
|
+
|
|
28
|
+
const assertions = [
|
|
29
|
+
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
30
|
+
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
31
|
+
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
32
|
+
['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
|
|
33
|
+
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
34
|
+
['working heartbeat is battery-conscious', watcher.includes("emitLaneStatus(laneName, 'working'), 20_000")],
|
|
35
|
+
['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
|
|
36
|
+
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
37
|
+
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
38
|
+
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
39
|
+
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
40
|
+
['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
|
|
41
|
+
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
42
|
+
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
|
|
43
|
+
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
44
|
+
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
45
|
+
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, result.policyBlock)') && watcher.includes('[Agent action required]')],
|
|
46
|
+
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
47
|
+
]
|
|
48
|
+
for (const [label, ok] of assertions) {
|
|
49
|
+
if (!ok) failures.push(label)
|
|
50
|
+
else process.stdout.write(`✓ ${label}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (failures.length) {
|
|
54
|
+
process.stderr.write(`\nCERTIFICATION FAILED (${failures.length})\n\n${failures.join('\n\n')}\n`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
process.stdout.write('\nCERTIFICATION PASSED\n')
|
package/src/events.mjs
CHANGED
|
@@ -34,6 +34,29 @@ export function taskIsCompleted(task, completedTypeIds = new Set()) {
|
|
|
34
34
|
return /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(state)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
38
|
+
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
39
|
+
const id = Number(channelId)
|
|
40
|
+
if (!Number.isFinite(id)) throw new Error('invalid channel id')
|
|
41
|
+
return {
|
|
42
|
+
url: String(backend || '').replace(/\/+$/, '') + `/channels/${id}/agent-state`,
|
|
43
|
+
init: {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
46
|
+
body: JSON.stringify({ state }),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function codexPolicyBlock(value) {
|
|
52
|
+
const text = String(value || '')
|
|
53
|
+
if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
|
|
54
|
+
const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
|
|
55
|
+
const reasonTail = text.split(/Reason:\s*/i)[1] || ''
|
|
56
|
+
const reason = reasonTail.split(/(?:\\n|\n)The agent\b/i)[0].replace(/[\\"') }]+$/, '').trim() || 'Codex requires explicit user approval for this external action.'
|
|
57
|
+
return { kind: 'authorization-required', command, reason: reason.slice(0, 900) }
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
38
61
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
39
62
|
// model starts, while keeping explicitly shared requests addressed to both.
|
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from 'node:os'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
|
|
14
14
|
|
|
15
15
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
16
16
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -247,6 +247,7 @@ export async function runWatch({ flags }) {
|
|
|
247
247
|
// of REST-polling the frontend relay. Detected by the saved mode / a --ws flag.
|
|
248
248
|
const backendMode = (saved && saved.mode === 'backend') || !!flags.ws
|
|
249
249
|
if (backendMode) {
|
|
250
|
+
const backend = stripSlash(flags.backend || (saved && saved.backend) || '')
|
|
250
251
|
const wsUrl = stripSlash(flags.ws || (saved && saved.wsUrl) || '')
|
|
251
252
|
const apiKey = String(flags.key || (saved && saved.apiKey) || '')
|
|
252
253
|
const identifier = String(flags.id || (saved && saved.identifier) || '')
|
|
@@ -254,7 +255,7 @@ export async function runWatch({ flags }) {
|
|
|
254
255
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
255
256
|
assertWebSocket(fail)
|
|
256
257
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
257
|
-
return loopBackendWs({ wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
258
|
+
return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
const host = stripSlash(flags.host || (saved && saved.host) || '')
|
|
@@ -326,7 +327,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
326
327
|
// line. That avoids inheriting unrelated user MCP servers while keeping the
|
|
327
328
|
// user's normal Codex authentication. We deliberately never use Codex's dangerous
|
|
328
329
|
// approval/sandbox bypass flag.
|
|
329
|
-
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
330
|
+
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
330
331
|
const bin = onPath('codex') || 'codex'
|
|
331
332
|
const cwd = workdir || OV_DIR
|
|
332
333
|
const tomlString = (v) => JSON.stringify(String(v))
|
|
@@ -344,7 +345,8 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
344
345
|
...(m ? ['--model', m] : []),
|
|
345
346
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
346
347
|
full]
|
|
347
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = ''
|
|
348
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
349
|
+
let policyBlock = null
|
|
348
350
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
349
351
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
350
352
|
const finish = (o) => {
|
|
@@ -353,7 +355,12 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
353
355
|
clearTimeout(timer)
|
|
354
356
|
const calls = [...mcpCalls]
|
|
355
357
|
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
356
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, outputText })
|
|
358
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
|
|
359
|
+
}
|
|
360
|
+
const inspectDiagnostic = (value) => {
|
|
361
|
+
const s = String(value || '')
|
|
362
|
+
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
363
|
+
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
357
364
|
}
|
|
358
365
|
const inspectLine = (line) => {
|
|
359
366
|
const s = line.trim()
|
|
@@ -368,6 +375,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
368
375
|
if (item?.type === 'mcp_tool_call') {
|
|
369
376
|
const tool = String(item.tool || item.name || item.method || 'unknown').replace(/^openvisio-team[.:/]/, '')
|
|
370
377
|
mcpCalls.add(tool)
|
|
378
|
+
try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
|
|
371
379
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
372
380
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
373
381
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
@@ -385,18 +393,27 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
385
393
|
try {
|
|
386
394
|
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
387
395
|
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
388
|
-
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', '
|
|
396
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
389
397
|
if (child.stdout) child.stdout.on('data', (d) => {
|
|
390
398
|
jsonlBuffer += String(d)
|
|
391
399
|
const lines = jsonlBuffer.split('\n')
|
|
392
400
|
jsonlBuffer = lines.pop() ?? ''
|
|
393
401
|
for (const line of lines) inspectLine(line)
|
|
394
402
|
})
|
|
403
|
+
if (child.stderr) child.stderr.on('data', (d) => {
|
|
404
|
+
inspectDiagnostic(d)
|
|
405
|
+
process.stderr.write(d)
|
|
406
|
+
})
|
|
395
407
|
} catch (e) {
|
|
396
408
|
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
397
409
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
398
410
|
}
|
|
399
|
-
child.on('close', (code) => {
|
|
411
|
+
child.on('close', (code) => {
|
|
412
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''; inspectDiagnostic('')
|
|
413
|
+
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
414
|
+
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
415
|
+
finish({ type: 'result', subtype })
|
|
416
|
+
})
|
|
400
417
|
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
401
418
|
})
|
|
402
419
|
}
|
|
@@ -415,7 +432,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
415
432
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
416
433
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
417
434
|
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
418
|
-
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
435
|
+
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
419
436
|
let child = null
|
|
420
437
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
421
438
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -526,19 +543,30 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
526
543
|
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
527
544
|
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
528
545
|
// stack up N sessions. Plus a one-time intro on first connect and a daily catch-up sweep.
|
|
529
|
-
function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
546
|
+
function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
530
547
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
531
548
|
let handle = null
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
const statusTargets = new Set()
|
|
549
|
+
const statusBackoff = new Map()
|
|
550
|
+
// Activity belongs to a lane. Keeping work/reply targets separate prevents a
|
|
551
|
+
// quick reply from overwriting or clearing a long coding cycle's status.
|
|
552
|
+
const laneStatusTargets = { work: new Set(), reply: new Set() }
|
|
537
553
|
const sendStatus = (channelId, state) => {
|
|
538
|
-
if (!
|
|
539
|
-
|
|
554
|
+
if (!backend || !['thinking', 'working', 'typing'].includes(state)) return
|
|
555
|
+
const key = Number(channelId)
|
|
556
|
+
if ((statusBackoff.get(key) || 0) > Date.now()) return
|
|
557
|
+
let request
|
|
558
|
+
try { request = agentStateRequest(backend, key, state, apiKey, identifier) } catch { return }
|
|
559
|
+
void fetch(request.url, request.init).then(async (res) => {
|
|
560
|
+
if (res.ok) { statusBackoff.delete(key); return }
|
|
561
|
+
const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160)
|
|
562
|
+
statusBackoff.set(key, Date.now() + 30_000)
|
|
563
|
+
log(`agent state HTTP ${res.status}${body ? ': ' + body : ''}; backing off 30s`)
|
|
564
|
+
}).catch((e) => {
|
|
565
|
+
statusBackoff.set(key, Date.now() + 30_000)
|
|
566
|
+
log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
|
|
567
|
+
})
|
|
540
568
|
}
|
|
541
|
-
const
|
|
569
|
+
const emitLaneStatus = (lane, state) => { for (const c of laneStatusTargets[lane]) sendStatus(c, state) }
|
|
542
570
|
const canCode = !!workdir
|
|
543
571
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
544
572
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -549,14 +577,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
549
577
|
const runnerOptions = {
|
|
550
578
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
551
579
|
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
552
|
-
// The moment the agent calls post_message it is about to speak → "typing".
|
|
553
|
-
onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
|
|
554
580
|
}
|
|
555
581
|
// One watcher and one WS subscription, but two independent model lanes. This
|
|
556
582
|
// avoids duplicate event delivery while mentions can be answered during code.
|
|
557
583
|
const runners = {
|
|
558
|
-
work: createCycleRunner(runnerOptions),
|
|
559
|
-
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply' }),
|
|
584
|
+
work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
|
|
585
|
+
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
|
|
560
586
|
}
|
|
561
587
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
562
588
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
@@ -567,8 +593,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
567
593
|
let liteModel = chatModel || model
|
|
568
594
|
|
|
569
595
|
const lanes = {
|
|
570
|
-
work: { busy: false, queued: null, pending: [] },
|
|
571
|
-
reply: { busy: false, queued: null, pending: [] },
|
|
596
|
+
work: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
597
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
572
598
|
}
|
|
573
599
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
574
600
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -582,9 +608,14 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
582
608
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
583
609
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
584
610
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
611
|
+
// A policy-blocked task stays paused across reconnects. It is released only
|
|
612
|
+
// after the ticket itself carries explicit authorization or is completed/
|
|
613
|
+
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
614
|
+
// attempting the same rejected egress action.
|
|
615
|
+
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
585
616
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
586
617
|
const persistReplay = () => {
|
|
587
|
-
try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities] }, true) } catch { /* best-effort */ }
|
|
618
|
+
try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities], blockedTasks: [...blockedTasks] }, true) } catch { /* best-effort */ }
|
|
588
619
|
}
|
|
589
620
|
// Context lines from the events themselves (the WS payload already carries the
|
|
590
621
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
@@ -594,6 +625,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
594
625
|
let lastTaskSignature = ''
|
|
595
626
|
let lastTaskTriggeredAt = 0
|
|
596
627
|
let lastInboxSignature = ''
|
|
628
|
+
let selfAgentId = null
|
|
597
629
|
let mcpSessionId = ''
|
|
598
630
|
let mcpRpcId = 0
|
|
599
631
|
|
|
@@ -618,7 +650,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
618
650
|
}
|
|
619
651
|
const ensureMcpSession = async () => {
|
|
620
652
|
if (mcpSessionId) return
|
|
621
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.
|
|
653
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.1' } } }, false)
|
|
622
654
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
623
655
|
await mcpPayload(res)
|
|
624
656
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -648,6 +680,67 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
648
680
|
try { return JSON.parse(text) } catch { return { text } }
|
|
649
681
|
}
|
|
650
682
|
|
|
683
|
+
const statusChannelCache = new Map()
|
|
684
|
+
const projectStatusChannel = async (projectId) => {
|
|
685
|
+
const key = Number(projectId)
|
|
686
|
+
const cached = statusChannelCache.get(key)
|
|
687
|
+
if (cached && cached.expiresAt > Date.now()) return cached.channelId
|
|
688
|
+
try {
|
|
689
|
+
const data = toolData(await callMcpTool('list_channels', { project_id: key }))
|
|
690
|
+
const channels = Array.isArray(data.channels) ? data.channels : Array.isArray(data.data?.channels) ? data.data.channels : Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : []
|
|
691
|
+
const usable = channels.filter((c) => Number.isFinite(Number(c.id)))
|
|
692
|
+
const preferred = usable.find((c) => /^(?:general|team|project|dev|development)$/i.test(String(c.name || '').trim())) || usable[0]
|
|
693
|
+
const channelId = preferred ? Number(preferred.id) : null
|
|
694
|
+
statusChannelCache.set(key, { channelId, expiresAt: Date.now() + 5 * 60_000 })
|
|
695
|
+
if (channelId == null) log('no channel available for project ' + key + '; task activity cannot be displayed')
|
|
696
|
+
return channelId
|
|
697
|
+
} catch (e) {
|
|
698
|
+
statusChannelCache.set(key, { channelId: null, expiresAt: Date.now() + 30_000 })
|
|
699
|
+
log('cannot resolve activity channel for project ' + key + ': ' + (e?.message || e))
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const reportPolicyBlock = async (prompt, block) => {
|
|
705
|
+
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
706
|
+
const channelMatch = /channel\s+(\d+)/i.exec(prompt)
|
|
707
|
+
const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
|
|
708
|
+
const command = block?.command || 'the requested external repository action'
|
|
709
|
+
const notice = `Action required: Codex reached ${command.includes('git push') ? 'the branch push step' : 'an external action'}, but the safety layer blocked \`${command}\` because private repository code cannot be sent to a remote without explicit approval for the destination and payload. Please explicitly authorize this exact push (repository remote + branch). Alex has paused the ticket and will not retry it automatically.`
|
|
710
|
+
|
|
711
|
+
if (channelMatch) {
|
|
712
|
+
await callMcpTool('post_message', {
|
|
713
|
+
channel_id: Number(channelMatch[1]),
|
|
714
|
+
...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
|
|
715
|
+
content: notice,
|
|
716
|
+
})
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
if (!taskMatch) { log('policy blocker has no source channel or ticket to update'); return }
|
|
720
|
+
|
|
721
|
+
const ticketId = Number(taskMatch[1])
|
|
722
|
+
const projectId = Number(taskMatch[2])
|
|
723
|
+
const key = `${projectId}:${ticketId}`
|
|
724
|
+
blockedTasks.add(key)
|
|
725
|
+
persistReplay()
|
|
726
|
+
try {
|
|
727
|
+
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
|
|
728
|
+
return
|
|
729
|
+
} catch (e) {
|
|
730
|
+
log('comment_ticket unavailable for policy blocker; recording it on ticket #' + ticketId)
|
|
731
|
+
}
|
|
732
|
+
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
733
|
+
const ticket = current.ticket ?? current.task ?? current
|
|
734
|
+
const description = String(ticket.description || '')
|
|
735
|
+
if (!description.includes('[Agent action required]')) {
|
|
736
|
+
await callMcpTool('update_ticket', {
|
|
737
|
+
project_id: projectId,
|
|
738
|
+
ticket_id: ticketId,
|
|
739
|
+
description: `${description}${description ? '\n\n' : ''}[Agent action required]\n${notice}`,
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
651
744
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
652
745
|
// model tokens unless the tools actually report pending work.
|
|
653
746
|
const reconcileBacklog = async () => {
|
|
@@ -658,6 +751,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
658
751
|
const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
|
|
659
752
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
660
753
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
754
|
+
selfAgentId = Number(self.id)
|
|
661
755
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
662
756
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
663
757
|
const assigned = []
|
|
@@ -673,7 +767,15 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
673
767
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
674
768
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
675
769
|
if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
|
|
676
|
-
|
|
770
|
+
const taskKey = `${project.id}:${task.id}`
|
|
771
|
+
if (blockedTasks.has(taskKey)) {
|
|
772
|
+
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
773
|
+
if (/\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)) {
|
|
774
|
+
blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
775
|
+
log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
|
|
776
|
+
} else continue
|
|
777
|
+
}
|
|
778
|
+
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 })
|
|
677
779
|
}
|
|
678
780
|
}
|
|
679
781
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
@@ -696,8 +798,11 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
696
798
|
if (signature !== lastTaskSignature || retryDue) {
|
|
697
799
|
lastTaskSignature = signature
|
|
698
800
|
lastTaskTriggeredAt = Date.now()
|
|
699
|
-
|
|
700
|
-
|
|
801
|
+
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
802
|
+
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
803
|
+
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
804
|
+
const activityChannel = await projectStatusChannel(next.projectId)
|
|
805
|
+
void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT post_message and do not announce progress or completion in any channel. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to done when appropriate.`, activityChannel == null ? [] : [activityChannel])
|
|
701
806
|
}
|
|
702
807
|
}
|
|
703
808
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -716,13 +821,17 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
716
821
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
717
822
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
718
823
|
|
|
719
|
-
async function drain(kind, context) {
|
|
824
|
+
async function drain(kind, context, targetChannels = []) {
|
|
720
825
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
721
826
|
const lane = lanes[laneName]
|
|
722
827
|
if (context) lane.pending.push(context)
|
|
828
|
+
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
723
829
|
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 }
|
|
724
830
|
lane.busy = true
|
|
725
831
|
const ctx = lane.pending.splice(0)
|
|
832
|
+
const targets = [...lane.targets]
|
|
833
|
+
lane.targets.clear()
|
|
834
|
+
laneStatusTargets[laneName] = new Set(targets)
|
|
726
835
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
727
836
|
// message is just the event context + the small base instruction.
|
|
728
837
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
@@ -732,24 +841,38 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
732
841
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
733
842
|
// Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
|
|
734
843
|
// through a long cycle; onTool flips it to "typing" when post_message fires.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
844
|
+
emitLaneStatus(laneName, 'working')
|
|
845
|
+
// Backend activity TTL is refreshed well before expiry, but only once every
|
|
846
|
+
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
847
|
+
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
738
848
|
try {
|
|
739
849
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
850
|
+
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
851
|
+
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
852
|
+
try { await reportPolicyBlock(prompt, result.policyBlock) }
|
|
853
|
+
catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
// Model prose never proves success or a blocker. Full cycles must produce
|
|
857
|
+
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
858
|
+
const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
859
|
+
const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
|
|
860
|
+
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
|
|
861
|
+
const missing = [ticketCycle && !result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', ticketCycle && !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
|
|
862
|
+
if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
747
863
|
log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
748
|
-
await runners.work.runCycle(`The assigned task is NOT complete. Missing evidence: ${missing.join('; ')}. Do not post
|
|
864
|
+
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, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
|
|
865
|
+
const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
|
|
866
|
+
if (recoveryIncomplete) {
|
|
867
|
+
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
868
|
+
if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
|
|
869
|
+
lastTaskSignature = ''
|
|
870
|
+
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
871
|
+
}
|
|
749
872
|
}
|
|
750
873
|
} finally {
|
|
751
874
|
if (heartbeat) clearInterval(heartbeat)
|
|
752
|
-
|
|
875
|
+
laneStatusTargets[laneName].clear()
|
|
753
876
|
lane.busy = false
|
|
754
877
|
if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
755
878
|
}
|
|
@@ -798,33 +921,53 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
798
921
|
try { writeJson(configPath(slug), { ...(readConfig(slug) || {}), model: codeModel, chatModel: liteModel !== codeModel ? liteModel : '' }, true) } catch { /* best-effort */ }
|
|
799
922
|
}
|
|
800
923
|
|
|
924
|
+
const handleTaskSignal = async (kind, raw) => {
|
|
925
|
+
const hinted = taskFromEvent(raw)
|
|
926
|
+
const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
|
|
927
|
+
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
928
|
+
if (ticketId == null || projectId == null) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
929
|
+
try {
|
|
930
|
+
if (selfAgentId == null) {
|
|
931
|
+
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
932
|
+
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
933
|
+
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
934
|
+
}
|
|
935
|
+
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
936
|
+
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
937
|
+
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
938
|
+
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
939
|
+
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
940
|
+
const key = `${projectId}:${ticketId}`
|
|
941
|
+
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
942
|
+
if (taskIsCompleted(ticket)) { blockedTasks.delete(key); persistReplay(); seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
|
|
943
|
+
if (blockedTasks.has(key)) {
|
|
944
|
+
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
945
|
+
if (/\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)) {
|
|
946
|
+
blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
|
|
947
|
+
log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
|
|
948
|
+
} else {
|
|
949
|
+
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
954
|
+
seenTasks.add(key); trimSeen(seenTasks)
|
|
955
|
+
const title = String(ticket.title || hinted.title || '')
|
|
956
|
+
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
957
|
+
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
958
|
+
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
959
|
+
const activityChannel = await projectStatusChannel(projectId)
|
|
960
|
+
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
961
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source channel: do not post_message. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel])
|
|
962
|
+
} catch (e) {
|
|
963
|
+
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
801
967
|
function onEvent(k, d) {
|
|
802
968
|
const raw = d && typeof d === 'object' ? d : {}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
806
|
-
// cycle confirm ownership via get_marching_orders before acting.
|
|
807
|
-
if (k === 'task:created' || k === 'task:updated') {
|
|
808
|
-
const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
|
|
809
|
-
const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
|
|
810
|
-
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
|
|
811
|
-
const agentId = t.agent_id ?? t.agentId ?? t.assigned_agent_id ?? t.assignedAgentId ?? t.assignee_agent_id ?? t.assigneeAgentId ?? ag?.id ?? ag?.agent_id ?? null
|
|
812
|
-
if (agentId == null) return // not assigned to an agent — ignore
|
|
813
|
-
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
814
|
-
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
815
|
-
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
816
|
-
if (agIdent != null && agIdent !== identifier) return
|
|
817
|
-
const key = `${t.id}:${agentId}`
|
|
818
|
-
if (t.id != null && seenTasks.has(key)) return
|
|
819
|
-
if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
|
|
820
|
-
log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
|
|
821
|
-
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
822
|
-
const taskText = [t.title, t.description, t.type, t.kind, Array.isArray(t.labels) ? t.labels.join(' ') : t.labels].filter(Boolean).join(' ')
|
|
823
|
-
// An assigned task is presumed to require execution. Only explicit board or
|
|
824
|
-
// messaging work stays on the cheap lane; vague verbs such as "create",
|
|
825
|
-
// "make", or "improve" must not strand a coding task in coordination.
|
|
826
|
-
const kind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
827
|
-
void drain(kind, `A task was just ${k === 'task:created' ? 'created and assigned' : 'assigned'} to an agent in this workspace: task #${t.id != null ? t.id : '?'}: "${t.title || ''}"${desc} (agent_id ${agentId}). Call get_marching_orders to confirm it is assigned to YOU. If it is yours, acknowledge it once, complete it with the tools appropriate to this ${kind === 'full' ? 'coding' : 'coordination'} lane, move the ticket when appropriate, and report the verified result. If it is not yours, do nothing and stop.`)
|
|
969
|
+
if (k === 'task:assigned' || k === 'task:updated') {
|
|
970
|
+
void handleTaskSignal(k, raw)
|
|
828
971
|
} else if (k === 'agent:mention') {
|
|
829
972
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
830
973
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -868,9 +1011,9 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
868
1011
|
return
|
|
869
1012
|
}
|
|
870
1013
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
871
|
-
// Light up the live status the instant we pick this up
|
|
872
|
-
//
|
|
873
|
-
if (cid != null)
|
|
1014
|
+
// Light up the live status the instant we pick this up; drain owns the
|
|
1015
|
+
// subsequent working/typing heartbeat for its lane.
|
|
1016
|
+
if (cid != null) sendStatus(cid, 'thinking')
|
|
874
1017
|
const codingMention = canCode && needsCode(text)
|
|
875
1018
|
const ctx = cid != null
|
|
876
1019
|
? `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.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
|
|
@@ -879,19 +1022,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
879
1022
|
const ack = cid != null
|
|
880
1023
|
? `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.`
|
|
881
1024
|
: undefined
|
|
882
|
-
void drain('coord', ack)
|
|
883
|
-
void drain('full', ctx)
|
|
884
|
-
} else void drain('fast', ctx)
|
|
1025
|
+
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1026
|
+
void drain('full', ctx, cid == null ? [] : [cid])
|
|
1027
|
+
} else void drain('fast', ctx, cid == null ? [] : [cid])
|
|
885
1028
|
} else if (k === 'error') {
|
|
886
1029
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
887
|
-
|
|
888
|
-
// first immediate rejection instead of producing an error every nine seconds.
|
|
889
|
-
if (statusEnabled && lastStatusSentAt && Date.now() - lastStatusSentAt < 3000) {
|
|
890
|
-
statusEnabled = false
|
|
891
|
-
log('live agent status unsupported by this backend; disabling status heartbeat' + (detail ? ': ' + String(detail).slice(0, 160) : ''))
|
|
892
|
-
} else {
|
|
893
|
-
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
894
|
-
}
|
|
1030
|
+
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
895
1031
|
} else {
|
|
896
1032
|
log('event ' + k)
|
|
897
1033
|
}
|
package/src/ws.mjs
CHANGED
|
@@ -101,12 +101,6 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
101
101
|
const api = {
|
|
102
102
|
/** Fire-and-forget send (no-op if the socket isn't open). */
|
|
103
103
|
send(obj) { try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)) } catch { /* closing */ } },
|
|
104
|
-
/** Show the agent as typing in a channel (docs/WEBSOCKET.md `{type:'typing'}`). */
|
|
105
|
-
sendTyping(channelId) { api.send({ type: 'typing', channel_id: channelId }) },
|
|
106
|
-
/** Broadcast a richer live activity state for the agent in a channel — the
|
|
107
|
-
* backend fans it out as `agent:status` (see docs/AGENT_STATUS.md). state is
|
|
108
|
-
* one of thinking | working | typing | done. */
|
|
109
|
-
sendStatus(channelId, state, detail) { api.send({ type: 'agent_status', channel_id: channelId, state, ...(detail ? { detail: String(detail).slice(0, 120) } : {}) }) },
|
|
110
104
|
close() {
|
|
111
105
|
closed = true
|
|
112
106
|
clearKeepalive()
|