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 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.16.1",
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. Your LAST action every cycle is to update/move the ticket with update_ticket when appropriate and post a short channel result with the branch, PR link, and test result. @mention the requester by their EXACT full name.',
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 when appropriate, and post a channel reply with the summary + PR link/branch that @mentions the requester by their exact full name.',
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
- let statusEnabled = true
533
- let lastStatusSentAt = 0
534
+ const statusBackoff = new Map()
534
535
  // Channels the agent is actively working in this cycle — drives the live
535
- // agent:status broadcast (thinking working typing done).
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 (!statusEnabled) return
539
- try { if (handle) { lastStatusSentAt = Date.now(); handle.sendStatus(channelId, state) } } catch { /* best-effort */ }
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 seenMentions = new Set()
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.16.1' } } }, false)
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) => /^(?:done|complete|completed|closed)$/i.test(String(t.name || '').trim())).map((t) => Number(t.id)))
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.deleted_at && !doneIds.has(Number(task.type_id ?? task.typeId)) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
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
- if (mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
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
- log('backlog reconciliation found ' + assigned.length + ' assigned task(s) -> coding cycle')
687
- void drain('full', `Backlog reconciliation verified these open tasks are assigned to YOU: ${JSON.stringify(assigned)}. Start with the highest-priority/oldest one immediately. Do not call nonexistent get_marching_orders or poll_inbox tools, and do not merely acknowledge. Use get_ticket if more detail is needed, move it to the active column with update_ticket, complete the repository work, verify it, open the PR, move the ticket to done when appropriate, and report evidence.`)
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
- // A zero exit is not proof of work. Assigned-ticket cycles must both use
728
- // the task MCP and leave repository evidence. A simple `ls` no longer
729
- // counts as completion. Do not retry a verified real blocker.
730
- const legitimateNoWork = /\b(?:no (?:open |assigned |pending )?tasks?|not assigned to (?:me|you)|assigned to (?:another|someone else)|blocked|cannot|can't|missing|need access|permission|unclear)\b/i.test(result?.outputText || '')
731
- const incomplete = !result?.didRepoMutation || !result?.didMcpTaskRead || !result?.didMcpTaskUpdate
732
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete && !legitimateNoWork) {
733
- const missing = [!result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
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 another acknowledgement or plan. Resume now. First use get_ticket/list_tasks and list_task_types, then do the repository work, verify it, commit and push an agent/* branch, open the PR, call update_ticket with the correct board column, and post exactly one result update with evidence. If a real blocker appears, report it once.`, codeModel)
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) { sendStatus(c, 'done'); statusTargets.delete(c) }
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
- // The backend has NO `task:assigned` event a task assigned to an agent arrives
791
- // as `task:created` (assigned on create) or `task:updated` (assignee changed),
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); if (seenMentions.size > 500) seenMentions.clear()
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
- // Older backend stages reject agent_status. Stop its heartbeat after the
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()