openvisio-agent 0.16.2 → 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.2",
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
@@ -34,6 +34,20 @@ 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
+
37
51
  // A mention event means this agent's name appeared somewhere, not necessarily
38
52
  // that the request was addressed to it. Reject a later-agent hand-off before a
39
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, taskIsCompleted } 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
@@ -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
@@ -594,6 +608,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
594
608
  let lastTaskSignature = ''
595
609
  let lastTaskTriggeredAt = 0
596
610
  let lastInboxSignature = ''
611
+ let selfAgentId = null
597
612
  let mcpSessionId = ''
598
613
  let mcpRpcId = 0
599
614
 
@@ -618,7 +633,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
618
633
  }
619
634
  const ensureMcpSession = async () => {
620
635
  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.16.2' } } }, 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)
622
637
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
623
638
  await mcpPayload(res)
624
639
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -658,6 +673,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
658
673
  const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
659
674
  const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
660
675
  if (!self?.id) throw new Error('list_agents did not return this BYO agent')
676
+ selfAgentId = Number(self.id)
661
677
  const projectsData = toolData(await callMcpTool('list_projects'))
662
678
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
663
679
  const assigned = []
@@ -673,7 +689,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
673
689
  const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
674
690
  const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
675
691
  if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
676
- assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId })
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 })
677
693
  }
678
694
  }
679
695
  const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
@@ -696,8 +712,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
696
712
  if (signature !== lastTaskSignature || retryDue) {
697
713
  lastTaskSignature = signature
698
714
  lastTaskTriggeredAt = Date.now()
699
- log('backlog reconciliation found ' + assigned.length + ' assigned task(s) -> coding cycle')
700
- void drain('full', `Backlog reconciliation verified these open tasks are assigned to YOU: ${JSON.stringify(assigned)}. Start with the highest-priority/oldest one immediately. 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. 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, then update the ticket with the evidence and move it to done when appropriate.`)
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.`)
701
719
  }
702
720
  }
703
721
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
@@ -737,19 +755,26 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
737
755
  const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
738
756
  try {
739
757
  const result = await runners[laneName].runCycle(prompt, useModel)
740
- // A zero exit is not proof of work. Assigned-ticket cycles must both use
741
- // the task MCP and leave repository evidence. A simple `ls` no longer
742
- // counts as completion. Do not retry a verified real blocker.
743
- 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 || '')
744
- const incomplete = !result?.didRepoMutation || !result?.didMcpTaskRead || !result?.didMcpTaskUpdate
745
- if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete && !legitimateNoWork) {
746
- 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(', '))
747
765
  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 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
+ }
749
774
  }
750
775
  } finally {
751
776
  if (heartbeat) clearInterval(heartbeat)
752
- for (const c of targets) { sendStatus(c, 'done'); statusTargets.delete(c) }
777
+ for (const c of targets) statusTargets.delete(c)
753
778
  lane.busy = false
754
779
  if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
755
780
  }
@@ -798,33 +823,41 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
798
823
  try { writeJson(configPath(slug), { ...(readConfig(slug) || {}), model: codeModel, chatModel: liteModel !== codeModel ? liteModel : '' }, true) } catch { /* best-effort */ }
799
824
  }
800
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
+
801
857
  function onEvent(k, d) {
802
858
  const raw = d && typeof d === 'object' ? d : {}
803
- // The backend has NO `task:assigned` event a task assigned to an agent arrives
804
- // as `task:created` (assigned on create) or `task:updated` (assignee changed),
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.`)
859
+ if (k === 'task:assigned' || k === 'task:updated') {
860
+ void handleTaskSignal(k, raw)
828
861
  } else if (k === 'agent:mention') {
829
862
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
830
863
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
@@ -884,14 +917,7 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
884
917
  } else void drain('fast', ctx)
885
918
  } else if (k === 'error') {
886
919
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
887
- // Older backend stages reject agent_status. Stop its heartbeat after the
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
- }
920
+ log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
895
921
  } else {
896
922
  log('event ' + k)
897
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()