openvisio-agent 0.19.11 → 0.20.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/src/watch.mjs CHANGED
@@ -5,12 +5,13 @@
5
5
  // than written to disk from a pasted heredoc.
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
8
9
  import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
9
10
  import { homedir } from 'node:os'
10
11
  import { join, dirname } from 'node:path'
11
12
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
13
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
14
+ import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
14
15
  import { createMastraMemory } from './memory.mjs'
15
16
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
16
17
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -20,10 +21,24 @@ import { mapConcurrent } from './concurrency.mjs'
20
21
  import { releaseAuthorizedPause } from './authorization-resume.mjs'
21
22
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
22
23
  import { createTaskTypeLookup } from './task-types.mjs'
24
+ import { createThreadContextResolver } from './thread-context.mjs'
23
25
  import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
24
26
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
25
27
  import { createMastraAcpRunner } from './mastra-harness.mjs'
26
28
  import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
29
+ import { createAgentJournal } from './agent-journal.mjs'
30
+
31
+ function runtimeJournal(options, supplied) {
32
+ let journal
33
+ try { journal = supplied || createAgentJournal(options) } catch { /* studio is optional */ }
34
+ return {
35
+ runId: journal?.runId || `${process.pid}-${Date.now()}`,
36
+ emit(type, data) {
37
+ try { Promise.resolve(journal?.emit(type, data)).catch(() => {}) } catch { /* telemetry cannot block work */ }
38
+ },
39
+ async close() { try { await journal?.close() } catch { /* shutdown must finish without the viewer */ } },
40
+ }
41
+ }
27
42
 
28
43
  function findTicketWorktree(workspaceRoot, ticketId) {
29
44
  if (!workspaceRoot || ticketId == null) return ''
@@ -318,13 +333,13 @@ export async function runWatch({ flags }) {
318
333
  }
319
334
 
320
335
  // Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
321
- function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt, canCoordinate = false }) {
336
+ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false }) {
322
337
  const canCode = !!workdir
323
338
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
324
339
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
325
340
  // its own ACP session and worktree, so cancellation, permissions and context
326
341
  // cannot bleed across concurrently assigned tickets.
327
- const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, systemPrompt })
342
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
328
343
  if (mastraRunner) return mastraRunner
329
344
  let child = null
330
345
  // The model the CURRENT session was spawned with. runCycle can pass a different
@@ -346,11 +361,20 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
346
361
  let turnDidMcpTaskRead = false
347
362
  let turnDidMcpTaskUpdate = false
348
363
  let turnOutputText = ''
364
+ let turnCycleId = null
365
+ let turnRuntimePid = null
366
+ const childCycleIds = new WeakMap()
367
+ const safeText = (value, max = 2000) => Object.values(mcpHeaders || {}).filter(Boolean).reduce((text, secret) => text.split(String(secret)).join('[redacted]'), String(value || '')).slice(0, max)
368
+ const emit = (type, data = {}) => {
369
+ try { Promise.resolve(onEvent?.(type, { cycleId: turnCycleId, provider: agent, runtime: 'claude-stream', watcherPid: process.pid, runtimePid: turnRuntimePid, workdir: workdir || '', model: sessionModel, ...data })).catch(() => {}) } catch { /* telemetry is best effort */ }
370
+ }
349
371
  const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
350
372
  const settleTurn = (o) => {
351
373
  clearCycleTimer()
352
374
  const r = resolveTurn
353
375
  resolveTurn = null
376
+ const finalText = typeof o.result === 'string' ? o.result.trim() : turnOutputText.trim()
377
+ if (r && finalText) emit('output.final', { text: safeText(finalText, 8000), status: o.subtype || 'ok' })
354
378
  const mcpCalls = [...turnToolCalls]
355
379
  if (r) r({
356
380
  ...o,
@@ -392,6 +416,9 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
392
416
  const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
393
417
  const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
394
418
  child = c
419
+ turnRuntimePid = c.pid || null
420
+ childCycleIds.set(c, turnCycleId)
421
+ emit('process.started', { status: 'starting' })
395
422
  turnsThisSession = 0
396
423
  sessionStartedAt = Date.now()
397
424
  let localBuf = ''
@@ -403,6 +430,26 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
403
430
  const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
404
431
  if (!line.trim()) continue
405
432
  let o; try { o = JSON.parse(line) } catch { continue }
433
+ if (o.type === 'system' && o.subtype === 'init') emit('process.ready', { status: 'running', sessionId: typeof o.session_id === 'string' ? o.session_id : null })
434
+ if (o.type === 'assistant' && Array.isArray(o.message?.content)) {
435
+ for (const block of o.message.content) {
436
+ if (block.type === 'tool_use') {
437
+ const title = block.name === 'Bash' && typeof block.input?.command === 'string' ? block.input.command : block.name
438
+ const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(block.name) ? block.input?.file_path || block.input?.path : ''
439
+ emit('tool.started', { toolCallId: safeText(block.id, 200), name: safeText(block.name, 300), title: safeText(title, 2000), status: 'in_progress', locations: typeof filePath === 'string' && filePath ? [{ path: safeText(filePath, 1000) }] : [] })
440
+ // TodoWrite is an explicit published plan, not model reasoning.
441
+ if (block.name === 'TodoWrite' && Array.isArray(block.input?.todos)) emit('plan.updated', { entries: block.input.todos.slice(0, 100).map((entry) => ({ content: safeText(entry.content), status: ['pending', 'in_progress', 'completed'].includes(entry.status) ? entry.status : 'unknown', priority: entry.priority })) })
442
+ } else if (block.type === 'text' && block.text) emit('output.progress', { messageId: typeof o.message.id === 'string' ? o.message.id : null, text: safeText(block.text, 8000) })
443
+ }
444
+ }
445
+ if (o.type === 'user' && Array.isArray(o.message?.content)) {
446
+ for (const block of o.message.content) if (block.type === 'tool_result') {
447
+ const use = turnToolUses.get(block.tool_use_id)
448
+ const title = use?.name === 'Bash' && typeof use.input?.command === 'string' ? use.input.command : use?.name || 'tool'
449
+ const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(use?.name) ? use.input?.file_path || use.input?.path : ''
450
+ emit('tool.finished', { toolCallId: safeText(block.tool_use_id, 200), name: safeText(use?.name || 'tool', 300), title: safeText(title), status: block.is_error ? 'failed' : 'completed', locations: typeof filePath === 'string' && filePath ? [{ path: safeText(filePath, 1000) }] : [] })
451
+ }
452
+ }
406
453
  if (debug) logEvent(o)
407
454
  const evidence = claudeEventEvidence(o, turnToolUses)
408
455
  if (evidence.outputText) turnOutputText += (turnOutputText ? ' ' : '') + evidence.outputText
@@ -440,13 +487,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
440
487
  })
441
488
  c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
442
489
  c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
490
+ c.on('close', (code, signal) => emit('process.stopped', { cycleId: childCycleIds.get(c) ?? null, runtimePid: c.pid || null, status: 'exited', exitCode: code, signal }))
443
491
  log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
444
492
  ? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
445
493
  : ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
446
494
  }
447
495
 
448
- function runCycle(prompt, cycleModel) {
496
+ function runCycle(prompt, cycleModel, cycleOptions = {}) {
449
497
  return new Promise((resolve) => {
498
+ turnCycleId = cycleOptions.cycleId ?? null
450
499
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
451
500
  // change means the warm session must be respawned with the new --model.
452
501
  const resolvedCycleModel = resolveClaudeModel(cycleModel)
@@ -461,6 +510,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
461
510
  child = null
462
511
  }
463
512
  ensureSession()
513
+ if (child) childCycleIds.set(child, turnCycleId)
464
514
  turnsThisSession++
465
515
  turnToolCalls = new Set()
466
516
  turnToolUses = new Map()
@@ -502,8 +552,35 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
502
552
  // One WebSocket identity feeds independent serialized work/reply queues. Each
503
553
  // accepted source keeps its own context; direct MCP reconciliation recovers work
504
554
  // without model spend while idle.
505
- function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
506
- const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
555
+ function loopBackendWs(options) {
556
+ const watcher = createBackendWatcher(options)
557
+ const stop = () => void watcher.close().then(() => process.exit(0))
558
+ process.once('SIGTERM', stop)
559
+ process.once('SIGINT', stop)
560
+ return watcher.done
561
+ }
562
+
563
+ // Production orchestration with replaceable I/O boundaries for deterministic
564
+ // workspace simulations. Tests run this same routing, queue and delivery code.
565
+ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
566
+ const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
567
+ const stateDir = dependencies.stateDir || OV_DIR
568
+ const makeRunner = dependencies.createRunner || createCycleRunner
569
+ const autoStart = dependencies.autoStart !== false
570
+ let stopping = false
571
+ const pendingOperations = new Set()
572
+ const trackOperation = (promise) => {
573
+ const tracked = Promise.resolve(promise).finally(() => pendingOperations.delete(tracked))
574
+ pendingOperations.add(tracked)
575
+ return tracked
576
+ }
577
+ const log = dependencies.log || ((m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n'))
578
+ const journal = runtimeJournal({ stateDir, agent: { identifier, slug, provider: agent }, secrets: [apiKey] }, dependencies.journal)
579
+ const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, ...data })
580
+ let cycleSequence = 0
581
+ let mcpSequence = 0
582
+ const cycleContext = new AsyncLocalStorage()
583
+ emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
507
584
  let handle = null
508
585
  // Activity belongs to an individual cycle. Independent ticket workers must
509
586
  // not clear or overwrite each other's status targets.
@@ -518,20 +595,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
518
595
  const authRule = agent !== 'claude'
519
596
  ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
520
597
  : `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. The credentials are given here; do NOT hunt for them.`
521
- const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: never invent or call comment_ticket unless that exact tool appears or tool_search finds it. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
598
+ const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: discover an advertised ticket-comment action and follow its input schema; never invent a tool name or call an unadvertised action. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
522
599
  // Keep the static charter stable so provider prompt caching can reuse it.
523
600
  // Actual cache hits and billing depend on the runtime and model provider.
524
601
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
525
602
  const runnerOptions = {
526
603
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
527
- cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
604
+ cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
528
605
  }
529
606
  // One watcher and one WS subscription, with a bounded pool of isolated coding
530
- // runtimes. Replies remain serialized so one agent cannot answer two messages
531
- // at once and race duplicate-delivery guards.
607
+ // runtimes. Replies serialize within a thread while independent threads run
608
+ // concurrently behind the same delivery guards.
532
609
  const MAX_CONCURRENT_WORKERS = 3
533
610
  const activeWorkRunners = new Set()
534
- const createWorkRunner = (control, workerWorkdir = '') => createCycleRunner({
611
+ const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
535
612
  ...runnerOptions,
536
613
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
537
614
  onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
@@ -543,7 +620,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
543
620
  const activeReplyRunners = new Set()
544
621
  const replyRunners = createRunnerPool({
545
622
  capacity: 8,
546
- create: ({ key, canCoordinate }) => createCycleRunner({
623
+ create: ({ key, canCoordinate }) => makeRunner({
547
624
  ...runnerOptions, workdir: '', canCoordinate,
548
625
  systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
549
626
  cfgKey: identifier + '-reply-' + slugify(key),
@@ -552,7 +629,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
552
629
  const codexPushGuide = agent === 'codex' && canCode
553
630
  ? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
554
631
  : ''
555
- const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. Never call comment_ticket unless that exact optional tool is present or tool_search finds it.'
632
+ const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. For ticket comments, discover an advertised action and follow its input schema; never invent a tool name or call an unadvertised action.'
556
633
  const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
557
634
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
558
635
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
@@ -572,7 +649,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
572
649
  const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
573
650
  item.control.runner = runner
574
651
  activeWorkRunners.add(runner)
575
- try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
652
+ try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir)) }
576
653
  finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
577
654
  },
578
655
  onError: (error, item) => {
@@ -589,7 +666,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
589
666
  const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
590
667
  activeReplyRunners.add(runner)
591
668
  item.control.runner = runner
592
- try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
669
+ try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '')) }
593
670
  finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
594
671
  },
595
672
  onError: (error, item) => {
@@ -606,16 +683,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
606
683
  // Mentions we've already reacted to (by message id) — the backend can re-deliver
607
684
  // an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
608
685
  // agent reply to the SAME message twice.
609
- const replayPath = join(OV_DIR, 'watch-' + slug + '-replay.json')
686
+ const replayPath = join(stateDir, 'watch-' + slug + '-replay.json')
610
687
  let replayState = {}
611
688
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
612
689
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
613
690
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
614
691
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
615
692
  const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
616
- const memory = createMastraMemory({
617
- ledgerPath: join(OV_DIR, 'watch-' + slug + '-memory.json'),
618
- databasePath: join(OV_DIR, 'watch-' + slug + '-mastra.db'),
693
+ const pendingReplyDeliveries = new Map(Array.isArray(replayState.pendingReplyDeliveries) ? replayState.pendingReplyDeliveries : [])
694
+ const memory = dependencies.memory || createMastraMemory({
695
+ ledgerPath: join(stateDir, 'watch-' + slug + '-memory.json'),
696
+ databasePath: join(stateDir, 'watch-' + slug + '-mastra.db'),
619
697
  resourceId: identifier,
620
698
  })
621
699
  // Completion delivery is runtime-owned for assigned coding work. Persist both
@@ -628,9 +706,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
628
706
  // This is distinct from a policy block: any later human ticket change resumes
629
707
  // the work, but reconnects and the watcher's own blocker update do not.
630
708
  const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
631
- // A policy-blocked task stays paused across reconnects. It is released only
632
- // after the ticket itself carries explicit authorization or is completed/
633
- // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
709
+ // A policy-blocked task stays paused across reconnects. Helper blocks are
710
+ // released by verified local configuration, or cleared when completed/unassigned. This prevents a 30-minute reconciliation retry from repeatedly
634
711
  // attempting the same rejected egress action.
635
712
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
636
713
  const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
@@ -650,6 +727,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
650
727
  recentMentionSignatures: [...recentMentionSignatures],
651
728
  seenActivities: [...seenActivities],
652
729
  deliveredReplies: [...deliveredReplies],
730
+ pendingReplyDeliveries: [...pendingReplyDeliveries],
653
731
  failedTaskVersions: [...failedTaskVersions],
654
732
  blockedTasks: [...blockedTasks],
655
733
  blockedTaskRepos: [...blockedTaskRepos],
@@ -712,7 +790,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
712
790
  const markMentionHandled = (message, channelId) => {
713
791
  pruneMentionSignatures()
714
792
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
715
- const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
793
+ const duplicate = idKey ? seenMentions.has(idKey) : !!signatureKey && recentMentionSignatures.has(signatureKey)
716
794
  if (duplicate) return true
717
795
  if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
718
796
  if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
@@ -724,19 +802,46 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
724
802
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
725
803
  // source independently through work, recovery, and delivery.
726
804
  let backlogProbeBusy = false
727
- let lastInboxSignature = ''
728
805
  let selfAgentId = null
806
+ let selfOrganizationId = null
729
807
  let completionRecipientName = ''
730
808
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
731
809
  const rememberSelfAgent = (self) => {
732
- if (!self || typeof self !== 'object') return
810
+ if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
733
811
  if (self.id != null) selfAgentId = Number(self.id)
734
812
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
735
813
  const addedBy = agentAddedByName(self)
736
814
  if (addedBy) completionRecipientName = addedBy
815
+ const organizationId = self.organization_id ?? self.organizationId
816
+ if (validBackendId(organizationId)) {
817
+ selfOrganizationId = Number(organizationId)
818
+ handle?.subscribeOrganization?.(selfOrganizationId)
819
+ }
820
+ }
821
+ const mcpClient = dependencies.mcpClient || createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
822
+ const callMcpTool = (name, args = {}) => {
823
+ if (stopping) return Promise.reject(new Error('Watcher is stopping'))
824
+ const toolCallId = `${journal.runId}:mcp:${++mcpSequence}`
825
+ const metadata = { cycleId: cycleContext.getStore()?.cycleId || null, toolCallId, name, title: name, source: 'watcher', provider: agent, runtime: 'watcher', locations: [] }
826
+ emit('tool.started', { ...metadata, status: 'in_progress' })
827
+ return trackOperation(Promise.resolve().then(() => mcpClient.callTool(name, args)).then((result) => {
828
+ emit('tool.finished', { ...metadata, status: 'completed' })
829
+ return result
830
+ }, (error) => {
831
+ emit('tool.finished', { ...metadata, status: 'failed' })
832
+ throw error
833
+ }))
834
+ }
835
+ let selfRead = null
836
+ const ensureSelfAgent = async () => {
837
+ if (selfAgentId != null) return
838
+ if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
839
+ const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row.identifier || row.slug || '') === identifier)
840
+ if (!self?.id) throw new Error('list_agents did not return this BYO agent')
841
+ rememberSelfAgent(self)
842
+ }).finally(() => { selfRead = null })
843
+ await selfRead
737
844
  }
738
- const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
739
- const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
740
845
  let mcpToolNames = null
741
846
  let mcpToolDiscoveryPromise = null
742
847
  let mcpToolDiscoveryWarned = false
@@ -755,24 +860,25 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
755
860
  }).finally(() => { mcpToolDiscoveryPromise = null })
756
861
  return mcpToolDiscoveryPromise
757
862
  }
758
- const mcpSupports = async (name) => {
759
- try { return (await discoverMcpTools()).has(name) }
863
+ const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
864
+ let names
865
+ try { names = await discoverMcpTools() }
760
866
  catch (e) {
761
867
  if (!mcpToolDiscoveryWarned) {
762
868
  mcpToolDiscoveryWarned = true
763
869
  log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
764
870
  }
765
- return null
871
+ return { called: false, reason: 'discovery-unavailable' }
766
872
  }
767
- }
768
- const callOptionalMcpTool = async (name, args) => {
769
- const supported = await mcpSupports(name)
770
- if (supported !== true) return { called: false, reason: 'not-advertised' }
771
- try { return { called: true, result: await callMcpTool(name, args) } }
873
+ // Select once from advertised capabilities. A failed write may already have
874
+ // reached the server, so never try another comment endpoint after an error.
875
+ const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
876
+ if (!name) return { called: false, reason: 'not-advertised' }
877
+ const args = { project_id: projectId, ticket_id: ticketId, ...(name === 'create_task_comment' ? { content } : { text: content }) }
878
+ try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
772
879
  catch (e) {
773
880
  if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
774
881
  try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
775
- return { called: false, reason: 'not-available' }
776
882
  }
777
883
  throw e
778
884
  }
@@ -811,9 +917,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
811
917
  const tasksData = toolData(tasksResult)
812
918
  const { done: doneIds, review: reviewIds } = typesResult
813
919
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
814
- const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
815
- const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
816
- const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
920
+ const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
817
921
  return assignedHere && !taskIsCompleted(task, doneIds)
818
922
  }).map((task) => ({
819
923
  id: task.id,
@@ -858,9 +962,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
858
962
  // rendered as this agent. A persisted delivery key closes the crash/reconnect
859
963
  // gap; an in-flight promise closes the two-lane race inside one watcher.
860
964
  const messageDeliveries = new Map()
861
- const threadControlKey = (channelId, parentId) => Number.isFinite(Number(channelId)) && parentId != null ? `thread:${Number(channelId)}:${parentId}` : ''
965
+ const threadControlKey = (channelId, parentId) => validBackendId(channelId) && validBackendId(parentId) ? `thread:${Number(channelId)}:${parentId}` : ''
862
966
  const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
863
967
  const cancelThread = (channelId, parentId, summary) => {
968
+ // A subsequent explicit mention may reactivate the thread. Remove replies
969
+ // belonging to the canceled request now so reactivation cannot revive them.
970
+ for (const [key, delivery] of pendingReplyDeliveries) {
971
+ if (!sameDeliveryThread(delivery, channelId, parentId)) continue
972
+ pendingReplyDeliveries.delete(key)
973
+ deliveredReplies.add(key)
974
+ memory.remember({ key, kind: 'delivery', state: 'suppressed', summary: 'The source request was cancelled.', refs: { channelId, threadId: parentId } })
975
+ }
976
+ trimSeen(deliveredReplies)
977
+ persistReplay()
864
978
  const controlKey = threadControlKey(channelId, parentId)
865
979
  if (!controlKey) return
866
980
  memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
@@ -876,7 +990,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
876
990
  const controlKey = threadControlKey(channelId, parentId)
877
991
  if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
878
992
  }
993
+ const saveOwnThread = (channelId, rootId, content) => {
994
+ memory.remember({ key: `own-root:${channelId}:${rootId}`, kind: 'thread', state: 'authored', summary: content, refs: { channelId: Number(channelId), threadId: rootId }, meta: { rootContent: content } })
995
+ if (!memory.has(threadControlKey(channelId, rootId), 'cancelled')) activateThread(channelId, rootId, content)
996
+ }
997
+ const resolveOwnThread = createThreadContextResolver({
998
+ read: (cid, root) => {
999
+ const cached = memory.get(`own-root:${cid}:${root}`)?.meta?.rootContent
1000
+ if (cached) return cached
1001
+ // Previous releases recorded the posted message id but not ownership.
1002
+ // This also recovers roots when the thread API returns only replies.
1003
+ const previous = memory.recall({ channelId: cid }, 1000).find((node) => node.kind === 'delivery' && node.state === 'rendered' && node.refs?.threadId == null && String(node.meta?.messageId) === String(root))
1004
+ if (previous?.summary) saveOwnThread(cid, root, previous.summary)
1005
+ return previous?.summary || ''
1006
+ },
1007
+ save: saveOwnThread,
1008
+ cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
1009
+ identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1010
+ load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1011
+ })
879
1012
  const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1013
+ if (stopping) throw new Error('Watcher is stopping')
880
1014
  const deliveryKey = String(key || '')
881
1015
  if (parentId == null) {
882
1016
  channelId = await projectStatusChannel(projectId)
@@ -884,12 +1018,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
884
1018
  projectId = undefined // dedicated channels may be organization-scoped
885
1019
  }
886
1020
  const message = String(content || '').trim()
887
- if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
888
- if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
1021
+ if (!deliveryKey || !validBackendId(channelId) || (parentId != null && !validBackendId(parentId)) || !message) return { posted: false, reason: 'invalid-delivery' }
1022
+ if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1023
+ if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
1024
+ return { posted: false, reason: 'remembered' }
1025
+ }
889
1026
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
890
1027
  const controlKey = threadControlKey(channelId, parentId)
891
1028
  const suppressCancelled = () => {
1029
+ if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
892
1030
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1031
+ pendingReplyDeliveries.delete(deliveryKey)
893
1032
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
894
1033
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
895
1034
  return { posted: false, reason: 'thread-cancelled' }
@@ -897,6 +1036,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
897
1036
  const initiallyCancelled = suppressCancelled()
898
1037
  if (initiallyCancelled) return initiallyCancelled
899
1038
 
1039
+ // Store generated threaded replies before delivery. A transport outage must
1040
+ // retry the authoritative delivery check, not pay for another model turn.
1041
+ if (parentId != null) {
1042
+ pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled })
1043
+ persistReplay()
1044
+ }
900
1045
  const run = (async () => {
901
1046
  if (parentId != null) {
902
1047
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
@@ -915,6 +1060,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
915
1060
  // Re-check immediately before the irreversible post.
916
1061
  const newlyCancelled = suppressCancelled()
917
1062
  if (newlyCancelled) return newlyCancelled
1063
+ if (stopping) throw new Error('Watcher is stopping')
918
1064
 
919
1065
  sendStatus(Number(channelId), 'typing')
920
1066
  const result = toolData(await callMcpTool('post_message', {
@@ -923,15 +1069,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
923
1069
  ...(parentId != null ? { parent_id: Number(parentId) } : {}),
924
1070
  content: message,
925
1071
  }))
1072
+ const postedId = result.id ?? result.message?.id ?? result.message_id
1073
+ if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
926
1074
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
927
1075
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
928
1076
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
929
1077
  return { posted: true, result }
930
- })().finally(() => messageDeliveries.delete(deliveryKey))
1078
+ })().then((result) => {
1079
+ if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
1080
+ return result
1081
+ }).finally(() => messageDeliveries.delete(deliveryKey))
931
1082
  messageDeliveries.set(deliveryKey, run)
932
1083
  return run
933
1084
  }
934
1085
 
1086
+ let deliveryProbeBusy = false
1087
+ const flushPendingReplies = async () => {
1088
+ if (stopping || deliveryProbeBusy) return
1089
+ deliveryProbeBusy = true
1090
+ try {
1091
+ await mapConcurrent([...pendingReplyDeliveries.values()], 3, async (delivery) => {
1092
+ try { await postMessageOnce(delivery) }
1093
+ catch (error) { log('reply remains pending: ' + (error?.message || error)) }
1094
+ })
1095
+ } finally { deliveryProbeBusy = false }
1096
+ }
1097
+
935
1098
  const statusChannelCache = new Map()
936
1099
  let dedicatedChannelRead = null
937
1100
  const projectStatusChannel = async (projectId) => {
@@ -982,7 +1145,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
982
1145
  const taskKey = `${projectId}:${ticketId}`
983
1146
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
984
1147
  const ticket = current.ticket ?? current.task ?? current
985
- const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName })
1148
+ const completionTypes = await taskTypeSets(projectId)
1149
+ const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName, completedTypeIds: completionTypes.done, reviewTypeIds: completionTypes.review })
986
1150
  if (!report) return false
987
1151
  const memoryKey = `ticket:${projectId}:${ticketId}`
988
1152
  memory.remember({
@@ -998,9 +1162,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
998
1162
  // handoff or trap the ticket in reconnect retries.
999
1163
  if (!reportedTaskComments.has(report.key)) {
1000
1164
  try {
1001
- const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
1165
+ const comment = await callOptionalTicketComment({ projectId, ticketId, content: report.content })
1002
1166
  if (comment.called) log('posted verified ticket comment for #' + ticketId)
1003
- else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
1167
+ else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
1004
1168
  } catch (e) {
1005
1169
  log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
1006
1170
  }
@@ -1066,10 +1230,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1066
1230
  refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
1067
1231
  })
1068
1232
  try {
1069
- const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
1233
+ const comment = await callOptionalTicketComment({ projectId, ticketId, content: ticketNotice })
1070
1234
  if (comment.called) { delivered = true; return }
1071
- log('comment_ticket is not exposed; recording the blocker in the ticket description for #' + ticketId)
1072
- } catch (e) { log('optional ticket comment failed; recording the blocker in the ticket description for #' + ticketId) }
1235
+ log('no ticket comment tool is exposed; recording the blocker in the ticket description for #' + ticketId)
1236
+ } catch (e) {
1237
+ log('ticket comment outcome is uncertain for #' + ticketId + '; no alternate ticket write will be attempted: ' + (e?.message || e))
1238
+ throw e
1239
+ }
1073
1240
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1074
1241
  const ticket = current.ticket ?? current.task ?? current
1075
1242
  const description = String(ticket.description || '')
@@ -1099,17 +1266,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1099
1266
  }
1100
1267
  const command = block?.command || 'the requested external repository action'
1101
1268
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
1102
- const approval = block?.commit && block?.branch
1103
- ? `Reply with: “I explicitly authorize you to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
1104
- : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
1105
- const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
1106
- const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the dedicated agent channel; I won't retry automatically.`
1269
+ const notice = `The local runtime rejected \`${command}\`${payload ? ` (${payload})` : ''}. Routine agent PR publishing uses the configured \`openvisio-agent push-pr-branch\` helper. This rejection needs a local runtime policy change; a channel reply cannot change that policy. I've paused the ticket and have not published the rejected action.`
1270
+ const ticketNotice = notice
1107
1271
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1108
1272
  }
1109
1273
 
1110
1274
  // Reconcile everything that may have arrived while disconnected. This spends no
1111
1275
  // model tokens unless the tools actually report pending work.
1112
1276
  const reconcileBacklog = async () => {
1277
+ if (stopping) return
1113
1278
  if (!mcpUrl || backlogProbeBusy) return
1114
1279
  backlogProbeBusy = true
1115
1280
  try {
@@ -1131,9 +1296,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1131
1296
  ])
1132
1297
  const { done: doneIds, review: reviewIds } = typesData
1133
1298
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1134
- const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
1135
- const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
1136
- if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
1299
+ if (!taskBelongsToAgent(task, { id: selfAgentId, identifier })) continue
1137
1300
  const taskKey = `${project.id}:${task.id}`
1138
1301
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1139
1302
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
@@ -1185,7 +1348,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1185
1348
  const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1186
1349
  const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1187
1350
  const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1188
- const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1351
+ const activityParentId = messageParentId(activityMessage)
1189
1352
  const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1190
1353
  const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1191
1354
  const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
@@ -1193,7 +1356,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1193
1356
  // old tagged thread root can otherwise make another agent's new reply
1194
1357
  // look addressed to this watcher. Explicit mentions and human follow-ups
1195
1358
  // in a persistently owned thread both enter the guarded per-message path.
1196
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1359
+ const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
1197
1360
  if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1198
1361
  seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1199
1362
  // The same logical mention may already have arrived over WebSocket.
@@ -1203,36 +1366,36 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1203
1366
  if (markMentionHandled(activityMessage, activityChannelId)) {
1204
1367
  const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1205
1368
  const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1206
- if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
1369
+ if (memory.has(activitySourceKey, 'received')) continue
1207
1370
  }
1208
1371
  mentionActivity.push({
1209
1372
  projectId: project.id,
1210
1373
  project: project.name,
1211
1374
  channelId: activityChannelId,
1212
1375
  message: activityMessage,
1213
- activity: item,
1376
+ activity: item, activityKey,
1214
1377
  })
1215
1378
  }
1216
1379
  }
1217
1380
  } catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
1218
1381
  })
1219
1382
  if (activityReplayTouched) persistReplay()
1220
- const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1221
- if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1222
- lastInboxSignature = inboxSignature
1383
+ if (mentionActivity.length) {
1223
1384
  log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1224
1385
  // Replay each real message through the exact same delivery path as a live
1225
1386
  // WebSocket mention. This preserves thread ids and lets postMessageOnce
1226
1387
  // consult the rendered thread before any reply is emitted.
1227
1388
  for (const mention of mentionActivity) {
1228
- onEvent('agent:mention', {
1389
+ void onEvent('agent:mention', {
1229
1390
  project_id: mention.projectId,
1230
1391
  channel_id: mention.channelId,
1231
1392
  message: mention.message,
1232
1393
  _mentionAlreadyMarked: true,
1394
+ })?.then((result) => {
1395
+ if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
1233
1396
  })
1234
1397
  }
1235
- } else if (!mentionActivity.length) lastInboxSignature = ''
1398
+ }
1236
1399
  } catch (e) {
1237
1400
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1238
1401
  } finally { backlogProbeBusy = false }
@@ -1257,22 +1420,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1257
1420
  }
1258
1421
 
1259
1422
  function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1423
+ if (stopping) return Promise.resolve({ status: 'canceled' })
1260
1424
  const laneName = kind === 'full' ? 'work' : 'reply'
1261
1425
  const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
1262
1426
  const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
1263
- const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1264
- return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
1427
+ if (key != null && queues[laneName].has(key)) return queues[laneName].enqueue(null, key)
1428
+ const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1429
+ const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1430
+ const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
1431
+ emit('cycle.queued', { ...cycleData, status: 'queued' })
1432
+ return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
1433
+ const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
1434
+ emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
1435
+ return result
1436
+ })
1265
1437
  }
1266
1438
 
1267
1439
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1268
1440
  const laneName = kind === 'full' ? 'work' : 'reply'
1269
1441
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1270
1442
  const cycleStartedAt = performance.now()
1443
+ cycleControl.startedAt = cycleStartedAt
1444
+ cycleControl.outcome = 'skipped'
1445
+ emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
1271
1446
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1272
1447
  const ctx = context ? [context] : []
1273
1448
  const activeTaskRef = taskRef
1274
1449
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1275
1450
  const runnerOptions = {
1451
+ cycleId: cycleControl.cycleId, attempt: 1,
1276
1452
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1277
1453
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1278
1454
  }
@@ -1302,10 +1478,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1302
1478
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1303
1479
  const ticket = data.ticket ?? data.task ?? data
1304
1480
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1305
- const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1306
- const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1307
1481
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1308
- !((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
1482
+ !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
1309
1483
  taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1310
1484
  releaseTaskForRetry(activeTaskRef, prompt)
1311
1485
  log('queued ticket no longer actionable; skipped before model start')
@@ -1313,8 +1487,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1313
1487
  }
1314
1488
  }
1315
1489
  if (cycleControl.cancelled) return
1490
+ cycleControl.outcome = 'running'
1316
1491
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1317
1492
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1493
+ cycleControl.outcome = result?.subtype || 'ok'
1318
1494
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1319
1495
  if (result?.model && result.model !== useModel) {
1320
1496
  // Persist only a successfully selected model and only if the user has
@@ -1347,6 +1523,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1347
1523
  }
1348
1524
  const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
1349
1525
  if (replyMutationErrors.length) {
1526
+ cycleControl.outcome = 'blocked'
1350
1527
  const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
1351
1528
  log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
1352
1529
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
@@ -1362,13 +1539,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1362
1539
  const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
1363
1540
  const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
1364
1541
  if (kind === 'full' && cycleSucceeded(result) && missing.length) {
1542
+ cycleControl.outcome = 'recovering'
1543
+ emit('cycle.recovery', { cycleId: cycleControl.cycleId, kind, status: 'recovering', attempt: 2, missing })
1365
1544
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1366
1545
  const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then provide one distinct final result or real blocker using the original delivery rule; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${codexPushGuide}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
1367
- const recovery = await runner.runCycle(recoveryPrompt, codeModel, runnerOptions)
1546
+ const recovery = await runner.runCycle(recoveryPrompt, codeModel, { ...runnerOptions, attempt: 2 })
1547
+ cycleControl.outcome = recovery?.subtype || 'ok'
1368
1548
  if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
1369
1549
  const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1370
1550
  const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
1371
1551
  if (!cycleSucceeded(recovery) || recoveryMissing.length) {
1552
+ cycleControl.outcome = 'blocked'
1372
1553
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
1373
1554
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
1374
1555
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
@@ -1454,7 +1635,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1454
1635
  const hinted = taskFromEvent(raw)
1455
1636
  const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
1456
1637
  const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
1457
- if (ticketId == null || projectId == null) { log(kind + ' missing ticket/project identity — ignored'); return }
1638
+ if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
1458
1639
  try {
1459
1640
  if (selfAgentId == null) {
1460
1641
  const agentsData = toolData(await callMcpTool('list_agents'))
@@ -1464,10 +1645,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1464
1645
  }
1465
1646
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1466
1647
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
1648
+ if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) { log('get_ticket returned mismatched identity; ignored'); return }
1467
1649
  const typeSets = await taskTypeSets(projectId)
1468
- const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1469
- const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1470
- const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1650
+ const belongsToSelf = taskBelongsToAgent(ticket, { id: selfAgentId, identifier })
1471
1651
  const key = `${projectId}:${ticketId}`
1472
1652
  if (!belongsToSelf) {
1473
1653
  for (const [name, queue] of Object.entries(queues)) {
@@ -1519,10 +1699,47 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1519
1699
  }
1520
1700
  }
1521
1701
 
1702
+ const mentionIntake = new Map()
1522
1703
  function onEvent(k, d) {
1704
+ if (stopping) return
1705
+ if (k !== 'agent:mention' && k !== 'channel:message') return trackOperation(routeEvent(k, d))
1706
+ const cid = d?.channel_id ?? d?.channelId
1707
+ const message = d?.message || {}
1708
+ const parent = messageParentId(message)
1709
+ const messageId = message.message_id ?? message.messageId ?? message.id
1710
+ const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
1711
+ if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
1712
+ const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
1713
+ // Keep redirects ordered while a legacy root is being verified. Other
1714
+ // conversations can resolve and run independently.
1715
+ const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
1716
+ try { await ensureSelfAgent() }
1717
+ catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
1718
+ if (k === 'channel:message' && selfOrganizationId == null) return { status: 'ignored-unknown-organization' }
1719
+ // Organization fanout includes our own posts. Ignore stable self identity
1720
+ // before recipient checks, replay guards or thread cancellation run.
1721
+ if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
1722
+ const preliminary = classifyConversationTarget(message, [...selfAliases])
1723
+ const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
1724
+ let rootContent = ''
1725
+ if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
1726
+ try { rootContent = await resolveOwnThread(cid, parent) }
1727
+ catch (error) {
1728
+ if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
1729
+ log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
1730
+ }
1731
+ }
1732
+ if (!stopping) return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
1733
+ }).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
1734
+ .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
1735
+ mentionIntake.set(key, next)
1736
+ return next
1737
+ }
1738
+
1739
+ function routeEvent(k, d) {
1523
1740
  const raw = d && typeof d === 'object' ? d : {}
1524
1741
  if (k === 'task:assigned' || k === 'task:updated') {
1525
- void handleTaskSignal(k, raw)
1742
+ return handleTaskSignal(k, raw)
1526
1743
  } else if (k === 'agent:mention') {
1527
1744
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
1528
1745
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
@@ -1530,8 +1747,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1530
1747
  const who = senderName(msg)
1531
1748
  // Reply IN THE SAME THREAD: parent is the thread root (the message's own id
1532
1749
  // for a top-level mention, or its parent when the mention is itself a reply).
1533
- const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
1534
- const parent = msg.parent_id != null ? msg.parent_id : (msg.parentId != null ? msg.parentId : null)
1750
+ const mid = msg.message_id ?? msg.messageId ?? msg.id ?? null
1751
+ const parent = messageParentId(msg)
1535
1752
  const threadRoot = parent != null ? parent : mid
1536
1753
  const controlKey = threadControlKey(cid, threadRoot)
1537
1754
  const threadOwned = !!controlKey && memory.has(controlKey, 'active')
@@ -1629,13 +1846,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1629
1846
  // Light up the live status the instant we pick this up; drain owns the
1630
1847
  // subsequent working/typing heartbeat for its lane.
1631
1848
  if (cid != null) sendStatus(cid, 'thinking')
1632
- const codingMention = canCode && conversationNeedsCode(text)
1849
+ const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
1633
1850
  const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
1634
- const ctx = cid != null
1851
+ let ctx = cid != null
1635
1852
  ? replyDelivery?.watcherOwned
1636
1853
  ? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
1637
1854
  : `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : '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. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
1638
1855
  : undefined
1856
+ if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
1639
1857
  if (codingMention) {
1640
1858
  // Activity indicators make accepted work visible. Do not add a canned
1641
1859
  // pickup message; the first channel message is the verified result or a
@@ -1651,16 +1869,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1651
1869
  }
1652
1870
 
1653
1871
  // The WebSocket drives real-time reactions (task:created/updated assigned to us,
1654
- // agent:mention). A thread reply that @mentions the agent fires agent:mention and
1655
- // is handled in-thread above. In addition we run a DAILY (and on-startup) sweep
1656
- // so anything assigned while the agent was offline — tasks especially — is still
1657
- // picked up even though we don't hot-poll.
1872
+ // agent:mention and channel:message). Verified organization subscriptions
1873
+ // recover channel mentions even when the backend omits targeted dispatch.
1874
+ // Both event types share recipient, ownership and deduplication guards.
1658
1875
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1659
- handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
1876
+ handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250) }, log })
1660
1877
 
1661
1878
  let authorizationProbeBusy = false
1662
1879
  const legacyAuthorizationChecks = new Set()
1663
1880
  const resumeReadyAuthorizations = async () => {
1881
+ if (stopping) return
1664
1882
  if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
1665
1883
  authorizationProbeBusy = true
1666
1884
  try {
@@ -1689,18 +1907,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1689
1907
  } catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
1690
1908
  finally { authorizationProbeBusy = false }
1691
1909
  }
1692
- const authorizationTimer = setInterval(() => void resumeReadyAuthorizations(), 2_000)
1693
- authorizationTimer.unref?.()
1910
+ const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
1911
+ deliveryTimer?.unref?.()
1912
+ const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
1913
+ authorizationTimer?.unref?.()
1914
+ const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, workdir: workdir || '' }), 15_000) : null
1915
+ heartbeatTimer?.unref?.()
1694
1916
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1695
- if (mcpConfig || mcpUrl) {
1917
+ if (autoStart && (mcpConfig || mcpUrl)) {
1696
1918
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1697
- const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1919
+ const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
1698
1920
  if (!existsSync(introMarker)) {
1699
1921
  log('first connection — introducing self to the workspace')
1700
1922
  introTimer = setTimeout(() => {
1701
1923
  void announceIntroduction().then((delivered) => {
1702
1924
  if (!delivered) return
1703
- try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1925
+ try { mkdirSync(stateDir, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1704
1926
  }).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
1705
1927
  }, 5000) // let the socket subscribe first
1706
1928
  }
@@ -1713,32 +1935,42 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1713
1935
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
1714
1936
  }
1715
1937
 
1716
- return new Promise(() => {
1717
- // Run until killed. Tidy up the socket + timers on termination so a restarting
1718
- // service doesn't leak a half-open connection or a dangling interval.
1719
- let stopping = false
1720
- const bye = async () => {
1721
- if (stopping) return
1722
- stopping = true
1938
+ let resolveDone
1939
+ const done = new Promise((resolve) => { resolveDone = resolve })
1940
+ let closePromise
1941
+ const close = () => {
1942
+ if (closePromise) return closePromise
1943
+ stopping = true
1944
+ closePromise = (async () => {
1723
1945
  clearInterval(authorizationTimer)
1946
+ clearInterval(deliveryTimer)
1947
+ clearInterval(heartbeatTimer)
1724
1948
  if (introTimer) clearTimeout(introTimer)
1725
1949
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1726
1950
  if (taskProbeTimer) clearInterval(taskProbeTimer)
1727
- try { handle && handle.close() } catch { /* noop */ }
1951
+ try { handle?.close() } catch { /* already disconnected */ }
1728
1952
  for (const queue of Object.values(queues)) {
1729
- queue.cancel(() => true, (item) => {
1730
- item.control.cancelled = true
1731
- void item.control.runner?.cancelCurrent?.()
1732
- })
1953
+ queue.cancel(() => true, (item) => { item.control.cancelled = true })
1733
1954
  }
1734
- await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
1955
+ await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
1956
+ await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
1735
1957
  workRunners.close()
1736
1958
  replyRunners.close()
1737
- process.exit(0)
1738
- }
1739
- process.on('SIGTERM', bye)
1740
- process.on('SIGINT', bye)
1741
- })
1959
+ try { await memory.settled?.() }
1960
+ catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
1961
+ finally {
1962
+ emit('watcher.stopped', { status: 'stopped' })
1963
+ await journal.close()
1964
+ resolveDone()
1965
+ }
1966
+ })()
1967
+ return closePromise
1968
+ }
1969
+ return {
1970
+ done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
1971
+ resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
1972
+ get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
1973
+ }
1742
1974
  }
1743
1975
 
1744
1976
  // ── the warm loop ────────────────────────────────────────────────────────────
@@ -1746,16 +1978,55 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1746
1978
  const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
1747
1979
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1748
1980
  const canCode = !!workdir
1981
+ const journal = runtimeJournal({ stateDir: OV_DIR, agent: { identifier: slug, slug, provider: agent }, secrets: [key] })
1982
+ const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, source: 'relay', ...data })
1983
+ let stopping = false, cycleSequence = 0, activeCycle = null
1984
+ let introTimer, sweepTimer, closePromise
1985
+ emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
1749
1986
  // Static charter as the cached system prompt (this loop's MCP authenticates via a
1750
1987
  // Bearer header, so no per-call creds are needed in the prompt).
1751
1988
  const systemPrompt = canCode ? CODE_CHARTER : CHAT_CHARTER
1752
- const { runCycle } = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model, systemPrompt })
1989
+ const runner = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit })
1990
+ const runCycle = (prompt, selectedModel) => {
1991
+ if (stopping) return Promise.resolve({ subtype: 'canceled' })
1992
+ const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
1993
+ const kind = prompt === fullPrompt ? 'full' : prompt === fastPrompt ? 'fast' : 'sweep'
1994
+ const metadata = { cycleId, kind, lane: canCode && kind === 'full' ? 'work' : 'reply', model: selectedModel, workdir: workdir || '' }
1995
+ const startedAt = performance.now()
1996
+ emit('cycle.queued', { ...metadata, status: 'queued' })
1997
+ emit('cycle.started', { ...metadata, status: 'running', queueMs: 0 })
1998
+ let status = 'error'
1999
+ activeCycle = Promise.resolve().then(() => stopping ? { subtype: 'canceled' } : runner.runCycle(prompt, selectedModel, { cycleId })).then((result) => { status = result?.subtype || 'ok'; return result }).finally(() => {
2000
+ emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...metadata, status, durationMs: Math.round(performance.now() - startedAt) })
2001
+ activeCycle = null
2002
+ })
2003
+ return activeCycle
2004
+ }
1753
2005
  const fullPrompt = canCode ? CODE_FULL : CYCLE
1754
2006
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
1755
2007
  const liteModel = chatModel || model // cheaper model for chat/quick cycles
1756
2008
 
1757
2009
  const seen = new Set()
1758
2010
  let busy = false
2011
+ const heartbeatTimer = setInterval(() => emit('watcher.heartbeat', { status: 'running', active: busy ? 1 : 0, model, chatModel, workdir: workdir || '' }), 15_000)
2012
+ heartbeatTimer.unref?.()
2013
+ const close = () => {
2014
+ if (closePromise) return closePromise
2015
+ stopping = true
2016
+ clearInterval(heartbeatTimer)
2017
+ clearTimeout(introTimer)
2018
+ clearInterval(sweepTimer)
2019
+ closePromise = (async () => {
2020
+ await Promise.allSettled([Promise.resolve().then(() => runner.cancelCurrent?.()), activeCycle])
2021
+ try { runner.close?.() } catch { /* already closed */ }
2022
+ emit('watcher.stopped', { status: 'stopped' })
2023
+ await journal.close()
2024
+ })()
2025
+ return closePromise
2026
+ }
2027
+ const stop = () => void close().then(() => process.exit(0))
2028
+ process.once('SIGTERM', stop)
2029
+ process.once('SIGINT', stop)
1759
2030
  let firstCheck = true
1760
2031
  let lastNewAt = Date.now()
1761
2032
  // A one-off prompt (intro / daily sweep) the main loop runs the next time it's
@@ -1766,11 +2037,11 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1766
2037
  if (!existsSync(introMarker)) {
1767
2038
  try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1768
2039
  log('first run — introducing self to the workspace')
1769
- setTimeout(() => { queuedSpecial = INTRO }, 5000)
2040
+ introTimer = setTimeout(() => { queuedSpecial = INTRO }, 5000)
1770
2041
  }
1771
2042
  // The first inbox response below decides whether a startup sweep is needed.
1772
2043
  // Do not spend a model turn on every service restart when the inbox is empty.
1773
- setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + COORDINATE }, 24 * 60 * 60 * 1000)
2044
+ sweepTimer = setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + COORDINATE }, 24 * 60 * 60 * 1000)
1774
2045
  }
1775
2046
 
1776
2047
  async function check() {
@@ -1788,7 +2059,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1788
2059
 
1789
2060
  async function run() {
1790
2061
  log('up — warm watcher on ' + host + (canCode ? ' [code: ' + workdir + ']' : ''))
1791
- for (;;) {
2062
+ while (!stopping) {
1792
2063
  let delay = FAST
1793
2064
  try {
1794
2065
  // Run a queued one-off (intro / daily sweep) first when free, on the same
@@ -1801,6 +2072,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1801
2072
  continue
1802
2073
  }
1803
2074
  const res = busy ? { items: [], paused: false } : await check()
2075
+ if (stopping) break
1804
2076
  const items = Array.isArray(res.items) ? res.items : []
1805
2077
  if (firstCheck && Array.isArray(res.items)) {
1806
2078
  firstCheck = false
@@ -1844,7 +2116,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1844
2116
  }
1845
2117
  }
1846
2118
 
1847
- return run()
2119
+ return run().finally(close)
1848
2120
  }
1849
2121
 
1850
2122
  // ── background service install (launchd / systemd) ───────────────────────────