openvisio-agent 0.17.0 → 0.17.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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": {
@@ -29,12 +29,22 @@ const assertions = [
29
29
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
30
30
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
31
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")],
32
35
  ['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
33
36
  ['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
34
37
  ['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
35
38
  ['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
36
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')],
37
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, activeTaskRef, result.policyBlock)') && watcher.includes('Action required: Alex is blocked')],
46
+ ['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
47
+ ['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
38
48
  ['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
39
49
  ]
40
50
  for (const [label, ok] of assertions) {
package/src/events.mjs CHANGED
@@ -48,6 +48,17 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
48
48
  }
49
49
  }
50
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
+ const commit = /\bcommit\s+([0-9a-f]{7,40})\b/i.exec(reason)?.[1] || ''
58
+ const push = /\bgit\s+push(?:\s+-\S+)*(?:\s+\S+)*\s+(\S+)\s+(\S+)\s*$/i.exec(command)
59
+ return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
60
+ }
61
+
51
62
  // A mention event means this agent's name appeared somewhere, not necessarily
52
63
  // that the request was addressed to it. Reject a later-agent hand-off before a
53
64
  // 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 { agentStateRequest, 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
@@ -345,7 +345,8 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
345
345
  ...(m ? ['--model', m] : []),
346
346
  ...(mcpOverride ? ['-c', mcpOverride] : []),
347
347
  full]
348
- 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
349
350
  const mcpCalls = new Set(), mcpErrors = new Set()
350
351
  let didMcpTaskRead = false, didMcpTaskUpdate = false
351
352
  const finish = (o) => {
@@ -354,7 +355,12 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
354
355
  clearTimeout(timer)
355
356
  const calls = [...mcpCalls]
356
357
  log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
357
- resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], 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
358
364
  }
359
365
  const inspectLine = (line) => {
360
366
  const s = line.trim()
@@ -387,18 +393,27 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
387
393
  try {
388
394
  // Always inspect Codex JSONL so a successful process exit cannot be
389
395
  // mistaken for completed work. Keep it out of normal logs unless debug.
390
- child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
396
+ child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
391
397
  if (child.stdout) child.stdout.on('data', (d) => {
392
398
  jsonlBuffer += String(d)
393
399
  const lines = jsonlBuffer.split('\n')
394
400
  jsonlBuffer = lines.pop() ?? ''
395
401
  for (const line of lines) inspectLine(line)
396
402
  })
403
+ if (child.stderr) child.stderr.on('data', (d) => {
404
+ inspectDiagnostic(d)
405
+ process.stderr.write(d)
406
+ })
397
407
  } catch (e) {
398
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`)')
399
409
  return finish({ type: 'result', subtype: 'spawn-failed' })
400
410
  }
401
- 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
+ })
402
417
  child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
403
418
  })
404
419
  }
@@ -532,10 +547,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
532
547
  const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
533
548
  let handle = null
534
549
  const statusBackoff = new Map()
535
- // Channels the agent is actively working in this cycle drives the live
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.
538
- const statusTargets = new Set()
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() }
539
553
  const sendStatus = (channelId, state) => {
540
554
  if (!backend || !['thinking', 'working', 'typing'].includes(state)) return
541
555
  const key = Number(channelId)
@@ -552,7 +566,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
552
566
  log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
553
567
  })
554
568
  }
555
- 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) }
556
570
  const canCode = !!workdir
557
571
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
558
572
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
@@ -563,14 +577,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
563
577
  const runnerOptions = {
564
578
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
565
579
  cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
566
- // The moment the agent calls post_message it is about to speak → "typing".
567
- onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
568
580
  }
569
581
  // One watcher and one WS subscription, but two independent model lanes. This
570
582
  // avoids duplicate event delivery while mentions can be answered during code.
571
583
  const runners = {
572
- work: createCycleRunner(runnerOptions),
573
- 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') } }),
574
586
  }
575
587
  const fullPrompt = canCode ? CODE_FULL : CYCLE
576
588
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
@@ -581,8 +593,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
581
593
  let liteModel = chatModel || model
582
594
 
583
595
  const lanes = {
584
- work: { busy: false, queued: null, pending: [] },
585
- reply: { busy: false, queued: null, pending: [] },
596
+ work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
597
+ reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
586
598
  }
587
599
  // Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
588
600
  // different agent re-triggers), so a noisy stream of task:updated events doesn't
@@ -596,9 +608,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
596
608
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
597
609
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
598
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 : [])
599
616
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
600
617
  const persistReplay = () => {
601
- 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 */ }
602
619
  }
603
620
  // Context lines from the events themselves (the WS payload already carries the
604
621
  // channel + message / task), so the agent acts on THEM directly instead of
@@ -633,7 +650,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
633
650
  }
634
651
  const ensureMcpSession = async () => {
635
652
  if (mcpSessionId) return
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)
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.2' } } }, false)
637
654
  if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
638
655
  await mcpPayload(res)
639
656
  mcpSessionId = res.headers.get('mcp-session-id') || ''
@@ -663,6 +680,87 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
663
680
  try { return JSON.parse(text) } catch { return { text } }
664
681
  }
665
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 publishBlocker = async ({ prompt, taskRef, notice, pause = false }) => {
705
+ const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
706
+ const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
707
+ const ticketId = Number(taskRef?.ticketId ?? sentenceTask?.[1] ?? jsonTask?.[1])
708
+ const projectId = Number(taskRef?.projectId ?? sentenceTask?.[2] ?? jsonTask?.[2])
709
+ const channelMatch = /channel\s+(\d+)/i.exec(prompt)
710
+ const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
711
+ const channelId = Number(channelMatch?.[1] ?? taskRef?.channelId)
712
+
713
+ let delivered = false
714
+ if (Number.isFinite(channelId)) {
715
+ try {
716
+ await callMcpTool('post_message', {
717
+ channel_id: channelId,
718
+ ...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
719
+ content: notice,
720
+ })
721
+ delivered = true
722
+ } catch (e) {
723
+ log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
724
+ }
725
+ }
726
+ if (!Number.isFinite(ticketId) || !Number.isFinite(projectId)) {
727
+ if (!Number.isFinite(channelId)) log('blocker has no source channel or ticket to update')
728
+ return
729
+ }
730
+
731
+ const key = `${projectId}:${ticketId}`
732
+ if (pause) { blockedTasks.add(key); persistReplay() }
733
+ try {
734
+ await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
735
+ delivered = true
736
+ return
737
+ } catch (e) {
738
+ log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
739
+ }
740
+ const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
741
+ const ticket = current.ticket ?? current.task ?? current
742
+ const description = String(ticket.description || '')
743
+ if (!description.includes(notice)) {
744
+ await callMcpTool('update_ticket', {
745
+ project_id: projectId,
746
+ ticket_id: ticketId,
747
+ description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${notice}`,
748
+ })
749
+ delivered = true
750
+ }
751
+ if (!delivered) throw new Error('no blocker delivery path succeeded')
752
+ }
753
+
754
+ const reportPolicyBlock = async (prompt, taskRef, block) => {
755
+ const command = block?.command || 'the requested external repository action'
756
+ const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
757
+ const approval = block?.commit && block?.branch
758
+ ? `Reply with: “I explicitly authorize Alex to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
759
+ : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
760
+ const notice = `Action required: Alex is blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} The ticket is paused until that approval is recorded.`
761
+ return publishBlocker({ prompt, taskRef, notice, pause: true })
762
+ }
763
+
666
764
  // Reconcile everything that may have arrived while disconnected. This spends no
667
765
  // model tokens unless the tools actually report pending work.
668
766
  const reconcileBacklog = async () => {
@@ -689,6 +787,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
689
787
  const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
690
788
  const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
691
789
  if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
790
+ const taskKey = `${project.id}:${task.id}`
791
+ if (blockedTasks.has(taskKey)) {
792
+ const approvalText = [task.title, task.description].filter(Boolean).join(' ')
793
+ 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)) {
794
+ blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
795
+ log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
796
+ } else continue
797
+ }
692
798
  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 })
693
799
  }
694
800
  }
@@ -715,7 +821,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
715
821
  const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
716
822
  const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
717
823
  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.`)
824
+ const activityChannel = await projectStatusChannel(next.projectId)
825
+ 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], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
719
826
  }
720
827
  }
721
828
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
@@ -734,13 +841,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
734
841
  const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
735
842
  const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
736
843
 
737
- async function drain(kind, context) {
844
+ async function drain(kind, context, targetChannels = [], taskRef = null) {
738
845
  const laneName = kind === 'full' ? 'work' : 'reply'
739
846
  const lane = lanes[laneName]
740
847
  if (context) lane.pending.push(context)
848
+ if (taskRef) lane.taskRefs.push(taskRef)
849
+ for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
741
850
  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 }
742
851
  lane.busy = true
743
852
  const ctx = lane.pending.splice(0)
853
+ const activeTaskRef = lane.taskRefs.splice(0)[0] || null
854
+ const targets = [...lane.targets]
855
+ lane.targets.clear()
856
+ laneStatusTargets[laneName] = new Set(targets)
744
857
  // credNote + charter live in the cached system prompt now — the per-cycle
745
858
  // message is just the event context + the small base instruction.
746
859
  const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
@@ -750,14 +863,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
750
863
  log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
751
864
  // Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
752
865
  // through a long cycle; onTool flips it to "typing" when post_message fires.
753
- const targets = [...statusTargets]
754
- emitStatus('working')
755
- const heartbeat = targets.length ? setInterval(() => emitStatus('working'), 9000) : null
866
+ emitLaneStatus(laneName, 'working')
867
+ // Backend activity TTL is refreshed well before expiry, but only once every
868
+ // 20 seconds so long coding runs do not create needless network/battery load.
869
+ const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
756
870
  try {
757
871
  const result = await runners[laneName].runCycle(prompt, useModel)
872
+ if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
873
+ log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
874
+ try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
875
+ catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
876
+ return
877
+ }
878
+ if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
879
+ const notice = `Alex is blocked: the coding cycle ended with ${result.subtype}. No completion is being claimed and the ticket remains open for retry.`
880
+ log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
881
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
882
+ catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
883
+ return
884
+ }
885
+ if (kind !== 'full' && result?.mcpErrors?.length) {
886
+ const notice = `Alex is blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. No success is being claimed; the item needs retry or intervention.`
887
+ log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
888
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
889
+ catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
890
+ return
891
+ }
758
892
  // Model prose never proves success or a blocker. Full cycles must produce
759
893
  // runtime-observed ticket reads, repository evidence, and ticket updates.
760
- const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
894
+ const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
761
895
  const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
762
896
  if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
763
897
  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)
@@ -766,15 +900,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
766
900
  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
901
  const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
768
902
  if (recoveryIncomplete) {
769
- const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
770
- if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
903
+ if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
904
+ try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
905
+ catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
906
+ } else {
907
+ const notice = `Alex is blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. The ticket remains open and no completion is being claimed.`
908
+ try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
909
+ catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
910
+ }
911
+ if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
912
+ else {
913
+ const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
914
+ if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
915
+ }
771
916
  lastTaskSignature = ''
772
917
  log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
773
918
  }
774
919
  }
775
920
  } finally {
776
921
  if (heartbeat) clearInterval(heartbeat)
777
- for (const c of targets) statusTargets.delete(c)
922
+ laneStatusTargets[laneName].clear()
778
923
  lane.busy = false
779
924
  if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
780
925
  }
@@ -840,15 +985,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
840
985
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
841
986
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
842
987
  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 }
988
+ if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
989
+ if (taskIsCompleted(ticket)) { blockedTasks.delete(key); persistReplay(); seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
990
+ if (blockedTasks.has(key)) {
991
+ const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
992
+ 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)) {
993
+ blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
994
+ log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
995
+ } else {
996
+ log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
997
+ return
998
+ }
999
+ }
845
1000
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
846
1001
  seenTasks.add(key); trimSeen(seenTasks)
847
1002
  const title = String(ticket.title || hinted.title || '')
848
1003
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
849
1004
  const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
850
1005
  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.`)
1006
+ const activityChannel = await projectStatusChannel(projectId)
1007
+ if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1008
+ 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], { projectId, ticketId, channelId: activityChannel })
852
1009
  } catch (e) {
853
1010
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
854
1011
  }
@@ -901,9 +1058,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
901
1058
  return
902
1059
  }
903
1060
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
904
- // Light up the live status the instant we pick this up (thinking the cycle
905
- // takes it to working typing done).
906
- if (cid != null) { statusTargets.add(cid); sendStatus(cid, 'thinking') }
1061
+ // Light up the live status the instant we pick this up; drain owns the
1062
+ // subsequent working/typing heartbeat for its lane.
1063
+ if (cid != null) sendStatus(cid, 'thinking')
907
1064
  const codingMention = canCode && needsCode(text)
908
1065
  const ctx = cid != null
909
1066
  ? `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.`
@@ -912,9 +1069,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
912
1069
  const ack = cid != null
913
1070
  ? `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.`
914
1071
  : undefined
915
- void drain('coord', ack)
916
- void drain('full', ctx)
917
- } else void drain('fast', ctx)
1072
+ void drain('coord', ack, cid == null ? [] : [cid])
1073
+ void drain('full', ctx, cid == null ? [] : [cid])
1074
+ } else void drain('fast', ctx, cid == null ? [] : [cid])
918
1075
  } else if (k === 'error') {
919
1076
  const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
920
1077
  log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))