openvisio-agent 0.16.1 → 0.17.0
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 +49 -0
- package/src/events.mjs +22 -0
- package/src/watch.mjs +103 -64
- 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.0",
|
|
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,49 @@
|
|
|
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
|
+
['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
|
|
33
|
+
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
34
|
+
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
35
|
+
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
36
|
+
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
37
|
+
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
38
|
+
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
39
|
+
]
|
|
40
|
+
for (const [label, ok] of assertions) {
|
|
41
|
+
if (!ok) failures.push(label)
|
|
42
|
+
else process.stdout.write(`✓ ${label}\n`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (failures.length) {
|
|
46
|
+
process.stderr.write(`\nCERTIFICATION FAILED (${failures.length})\n\n${failures.join('\n\n')}\n`)
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
process.stdout.write('\nCERTIFICATION PASSED\n')
|
package/src/events.mjs
CHANGED
|
@@ -26,6 +26,28 @@ export function taskAgentId(task) {
|
|
|
26
26
|
return task.agent_id != null ? task.agent_id : (task.agentId != null ? task.agentId : null)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export function taskIsCompleted(task, completedTypeIds = new Set()) {
|
|
30
|
+
if (!task || typeof task !== 'object') return false
|
|
31
|
+
if (task.deleted_at || task.deletedAt || task.completed_at || task.completedAt || task.closed_at || task.closedAt || task.archived_at || task.archivedAt) return true
|
|
32
|
+
if (completedTypeIds.has(Number(task.type_id ?? task.typeId ?? task.status_id ?? task.statusId))) return true
|
|
33
|
+
const state = [task.status, task.state, task.type?.name, task.task_type?.name].filter(Boolean).join(' ')
|
|
34
|
+
return /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(state)
|
|
35
|
+
}
|
|
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
|
+
|
|
29
51
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
30
52
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
31
53
|
// 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 } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, 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
|
|
@@ -73,7 +73,7 @@ const CODE_CHARTER = [
|
|
|
73
73
|
'',
|
|
74
74
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
75
75
|
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back.',
|
|
76
|
-
' 2. FINISH, then REPORT.
|
|
76
|
+
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Post a channel result ONLY when this cycle includes a specific source channel/thread from a human request, and reply in that thread. Backlog-only work has no implied audience: update the ticket with evidence and do not announce it in an unrelated channel.',
|
|
77
77
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
78
78
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
79
79
|
'',
|
|
@@ -89,7 +89,7 @@ const CODE_FULL = [
|
|
|
89
89
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
90
90
|
' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
|
|
91
91
|
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
92
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket
|
|
92
|
+
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link only in a source thread explicitly supplied by the event. For backlog-only tickets, do not post_message or seek a channel to announce completion.',
|
|
93
93
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
94
94
|
].join('\n')
|
|
95
95
|
|
|
@@ -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))
|
|
@@ -353,7 +354,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
353
354
|
clearTimeout(timer)
|
|
354
355
|
const calls = [...mcpCalls]
|
|
355
356
|
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 })
|
|
357
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
357
358
|
}
|
|
358
359
|
const inspectLine = (line) => {
|
|
359
360
|
const s = line.trim()
|
|
@@ -368,6 +369,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
368
369
|
if (item?.type === 'mcp_tool_call') {
|
|
369
370
|
const tool = String(item.tool || item.name || item.method || 'unknown').replace(/^openvisio-team[.:/]/, '')
|
|
370
371
|
mcpCalls.add(tool)
|
|
372
|
+
try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
|
|
371
373
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
372
374
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
373
375
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
@@ -415,7 +417,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
415
417
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
416
418
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
417
419
|
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 })
|
|
420
|
+
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
419
421
|
let child = null
|
|
420
422
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
421
423
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -526,17 +528,29 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
526
528
|
// over the WS; each pushes ONE Claude cycle. Serialized (one cycle at a time) — events
|
|
527
529
|
// arriving while busy are coalesced into a single follow-up cycle so a burst doesn't
|
|
528
530
|
// 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 }) {
|
|
531
|
+
function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
530
532
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
531
533
|
let handle = null
|
|
532
|
-
|
|
533
|
-
let lastStatusSentAt = 0
|
|
534
|
+
const statusBackoff = new Map()
|
|
534
535
|
// Channels the agent is actively working in this cycle — drives the live
|
|
535
|
-
//
|
|
536
|
+
// Agent state is an authenticated REST call. The backend then fans out the
|
|
537
|
+
// documented channel:agent:* event to the app; it is never a WS client frame.
|
|
536
538
|
const statusTargets = new Set()
|
|
537
539
|
const sendStatus = (channelId, state) => {
|
|
538
|
-
if (!
|
|
539
|
-
|
|
540
|
+
if (!backend || !['thinking', 'working', 'typing'].includes(state)) return
|
|
541
|
+
const key = Number(channelId)
|
|
542
|
+
if ((statusBackoff.get(key) || 0) > Date.now()) return
|
|
543
|
+
let request
|
|
544
|
+
try { request = agentStateRequest(backend, key, state, apiKey, identifier) } catch { return }
|
|
545
|
+
void fetch(request.url, request.init).then(async (res) => {
|
|
546
|
+
if (res.ok) { statusBackoff.delete(key); return }
|
|
547
|
+
const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160)
|
|
548
|
+
statusBackoff.set(key, Date.now() + 30_000)
|
|
549
|
+
log(`agent state HTTP ${res.status}${body ? ': ' + body : ''}; backing off 30s`)
|
|
550
|
+
}).catch((e) => {
|
|
551
|
+
statusBackoff.set(key, Date.now() + 30_000)
|
|
552
|
+
log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
|
|
553
|
+
})
|
|
540
554
|
}
|
|
541
555
|
const emitStatus = (state) => { for (const c of statusTargets) sendStatus(c, state) }
|
|
542
556
|
const canCode = !!workdir
|
|
@@ -577,7 +591,15 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
577
591
|
// Mentions we've already reacted to (by message id) — the backend can re-deliver
|
|
578
592
|
// an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
|
|
579
593
|
// agent reply to the SAME message twice.
|
|
580
|
-
const
|
|
594
|
+
const replayPath = join(OV_DIR, 'watch-' + slug + '-replay.json')
|
|
595
|
+
let replayState = {}
|
|
596
|
+
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
597
|
+
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
598
|
+
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
599
|
+
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
600
|
+
const persistReplay = () => {
|
|
601
|
+
try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities] }, true) } catch { /* best-effort */ }
|
|
602
|
+
}
|
|
581
603
|
// Context lines from the events themselves (the WS payload already carries the
|
|
582
604
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
583
605
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -586,6 +608,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
586
608
|
let lastTaskSignature = ''
|
|
587
609
|
let lastTaskTriggeredAt = 0
|
|
588
610
|
let lastInboxSignature = ''
|
|
611
|
+
let selfAgentId = null
|
|
589
612
|
let mcpSessionId = ''
|
|
590
613
|
let mcpRpcId = 0
|
|
591
614
|
|
|
@@ -610,7 +633,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
610
633
|
}
|
|
611
634
|
const ensureMcpSession = async () => {
|
|
612
635
|
if (mcpSessionId) return
|
|
613
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.
|
|
636
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.0' } } }, false)
|
|
614
637
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
615
638
|
await mcpPayload(res)
|
|
616
639
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -650,6 +673,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
650
673
|
const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
|
|
651
674
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
652
675
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
676
|
+
selfAgentId = Number(self.id)
|
|
653
677
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
654
678
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
655
679
|
const assigned = []
|
|
@@ -660,12 +684,12 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
660
684
|
callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
|
|
661
685
|
callMcpTool('list_activity', { project_id: project.id }).then(toolData),
|
|
662
686
|
])
|
|
663
|
-
const doneIds = new Set((Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []).filter((t) =>
|
|
687
|
+
const doneIds = new Set((Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []).filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
664
688
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
665
689
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
666
690
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
667
|
-
if (!task
|
|
668
|
-
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId })
|
|
691
|
+
if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
|
|
692
|
+
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 })
|
|
669
693
|
}
|
|
670
694
|
}
|
|
671
695
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
@@ -673,9 +697,14 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
673
697
|
for (const item of activities) {
|
|
674
698
|
const text = JSON.stringify(item)
|
|
675
699
|
const lower = text.toLowerCase()
|
|
676
|
-
|
|
700
|
+
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
701
|
+
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
702
|
+
seenActivities.add(activityKey); trimSeen(seenActivities)
|
|
703
|
+
mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
|
|
704
|
+
}
|
|
677
705
|
}
|
|
678
706
|
}
|
|
707
|
+
if (mentionActivity.length) persistReplay()
|
|
679
708
|
if (!assigned.length) lastTaskSignature = ''
|
|
680
709
|
else {
|
|
681
710
|
const signature = JSON.stringify(assigned)
|
|
@@ -683,8 +712,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
683
712
|
if (signature !== lastTaskSignature || retryDue) {
|
|
684
713
|
lastTaskSignature = signature
|
|
685
714
|
lastTaskTriggeredAt = Date.now()
|
|
686
|
-
|
|
687
|
-
|
|
715
|
+
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
716
|
+
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
717
|
+
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
718
|
+
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.`)
|
|
688
719
|
}
|
|
689
720
|
}
|
|
690
721
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -724,19 +755,26 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
724
755
|
const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
|
|
725
756
|
try {
|
|
726
757
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
758
|
+
// Model prose never proves success or a blocker. Full cycles must produce
|
|
759
|
+
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
760
|
+
const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
761
|
+
const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
|
|
762
|
+
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
|
|
763
|
+
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)
|
|
764
|
+
if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
734
765
|
log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
735
|
-
await runners.work.runCycle(`The assigned task is NOT complete. Missing evidence: ${missing.join('; ')}. Do not post
|
|
766
|
+
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)
|
|
767
|
+
const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
|
|
768
|
+
if (recoveryIncomplete) {
|
|
769
|
+
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
770
|
+
if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
|
|
771
|
+
lastTaskSignature = ''
|
|
772
|
+
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
773
|
+
}
|
|
736
774
|
}
|
|
737
775
|
} finally {
|
|
738
776
|
if (heartbeat) clearInterval(heartbeat)
|
|
739
|
-
for (const c of targets)
|
|
777
|
+
for (const c of targets) statusTargets.delete(c)
|
|
740
778
|
lane.busy = false
|
|
741
779
|
if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
742
780
|
}
|
|
@@ -785,33 +823,41 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
785
823
|
try { writeJson(configPath(slug), { ...(readConfig(slug) || {}), model: codeModel, chatModel: liteModel !== codeModel ? liteModel : '' }, true) } catch { /* best-effort */ }
|
|
786
824
|
}
|
|
787
825
|
|
|
826
|
+
const handleTaskSignal = async (kind, raw) => {
|
|
827
|
+
const hinted = taskFromEvent(raw)
|
|
828
|
+
const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
|
|
829
|
+
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
830
|
+
if (ticketId == null || projectId == null) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
831
|
+
try {
|
|
832
|
+
if (selfAgentId == null) {
|
|
833
|
+
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
834
|
+
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
835
|
+
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
836
|
+
}
|
|
837
|
+
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
838
|
+
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
839
|
+
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
840
|
+
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
841
|
+
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
842
|
+
const key = `${projectId}:${ticketId}`
|
|
843
|
+
if (!belongsToSelf) { seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
844
|
+
if (taskIsCompleted(ticket)) { seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
|
|
845
|
+
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
846
|
+
seenTasks.add(key); trimSeen(seenTasks)
|
|
847
|
+
const title = String(ticket.title || hinted.title || '')
|
|
848
|
+
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
849
|
+
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
850
|
+
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
851
|
+
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.`)
|
|
852
|
+
} catch (e) {
|
|
853
|
+
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
788
857
|
function onEvent(k, d) {
|
|
789
858
|
const raw = d && typeof d === 'object' ? d : {}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
793
|
-
// cycle confirm ownership via get_marching_orders before acting.
|
|
794
|
-
if (k === 'task:created' || k === 'task:updated') {
|
|
795
|
-
const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
|
|
796
|
-
const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
|
|
797
|
-
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
|
|
798
|
-
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
|
|
799
|
-
if (agentId == null) return // not assigned to an agent — ignore
|
|
800
|
-
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
801
|
-
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
802
|
-
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
803
|
-
if (agIdent != null && agIdent !== identifier) return
|
|
804
|
-
const key = `${t.id}:${agentId}`
|
|
805
|
-
if (t.id != null && seenTasks.has(key)) return
|
|
806
|
-
if (t.id != null) { seenTasks.add(key); if (seenTasks.size > 500) seenTasks.clear() }
|
|
807
|
-
log('task ' + k.slice(5) + ' #' + (t.id != null ? t.id : '?') + ' (agent ' + agentId + ') “' + (t.title || '') + '”')
|
|
808
|
-
const desc = t.description ? ' — ' + String(t.description).replace(/\s+/g, ' ').slice(0, 400) : ''
|
|
809
|
-
const taskText = [t.title, t.description, t.type, t.kind, Array.isArray(t.labels) ? t.labels.join(' ') : t.labels].filter(Boolean).join(' ')
|
|
810
|
-
// An assigned task is presumed to require execution. Only explicit board or
|
|
811
|
-
// messaging work stays on the cheap lane; vague verbs such as "create",
|
|
812
|
-
// "make", or "improve" must not strand a coding task in coordination.
|
|
813
|
-
const kind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
814
|
-
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.`)
|
|
859
|
+
if (k === 'task:assigned' || k === 'task:updated') {
|
|
860
|
+
void handleTaskSignal(k, raw)
|
|
815
861
|
} else if (k === 'agent:mention') {
|
|
816
862
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
817
863
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -827,7 +873,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
827
873
|
// when the payload carries no id.
|
|
828
874
|
const dedupeKey = mid != null ? 'id:' + mid : 'sig:' + (cid != null ? cid : '?') + '|' + text.slice(0, 100)
|
|
829
875
|
if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
|
|
830
|
-
seenMentions.add(dedupeKey);
|
|
876
|
+
seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
|
|
831
877
|
if (requestTargetsLaterAgent(text, [slug, identifier])) {
|
|
832
878
|
log('agent:mention addressed to a later-mentioned agent — skipped')
|
|
833
879
|
return
|
|
@@ -871,14 +917,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
871
917
|
} else void drain('fast', ctx)
|
|
872
918
|
} else if (k === 'error') {
|
|
873
919
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
874
|
-
|
|
875
|
-
// first immediate rejection instead of producing an error every nine seconds.
|
|
876
|
-
if (statusEnabled && lastStatusSentAt && Date.now() - lastStatusSentAt < 3000) {
|
|
877
|
-
statusEnabled = false
|
|
878
|
-
log('live agent status unsupported by this backend; disabling status heartbeat' + (detail ? ': ' + String(detail).slice(0, 160) : ''))
|
|
879
|
-
} else {
|
|
880
|
-
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
881
|
-
}
|
|
920
|
+
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
882
921
|
} else {
|
|
883
922
|
log('event ' + k)
|
|
884
923
|
}
|
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()
|