openvisio-agent 0.16.2 → 0.17.1

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