openvisio-agent 0.19.12 → 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'
@@ -25,6 +26,19 @@ import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './ass
25
26
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
26
27
  import { createMastraAcpRunner } from './mastra-harness.mjs'
27
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
+ }
28
42
 
29
43
  function findTicketWorktree(workspaceRoot, ticketId) {
30
44
  if (!workspaceRoot || ticketId == null) return ''
@@ -319,13 +333,13 @@ export async function runWatch({ flags }) {
319
333
  }
320
334
 
321
335
  // Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
322
- 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 }) {
323
337
  const canCode = !!workdir
324
338
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
325
339
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
326
340
  // its own ACP session and worktree, so cancellation, permissions and context
327
341
  // cannot bleed across concurrently assigned tickets.
328
- 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 })
329
343
  if (mastraRunner) return mastraRunner
330
344
  let child = null
331
345
  // The model the CURRENT session was spawned with. runCycle can pass a different
@@ -347,11 +361,20 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
347
361
  let turnDidMcpTaskRead = false
348
362
  let turnDidMcpTaskUpdate = false
349
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
+ }
350
371
  const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
351
372
  const settleTurn = (o) => {
352
373
  clearCycleTimer()
353
374
  const r = resolveTurn
354
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' })
355
378
  const mcpCalls = [...turnToolCalls]
356
379
  if (r) r({
357
380
  ...o,
@@ -393,6 +416,9 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
393
416
  const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
394
417
  const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
395
418
  child = c
419
+ turnRuntimePid = c.pid || null
420
+ childCycleIds.set(c, turnCycleId)
421
+ emit('process.started', { status: 'starting' })
396
422
  turnsThisSession = 0
397
423
  sessionStartedAt = Date.now()
398
424
  let localBuf = ''
@@ -404,6 +430,26 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
404
430
  const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
405
431
  if (!line.trim()) continue
406
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
+ }
407
453
  if (debug) logEvent(o)
408
454
  const evidence = claudeEventEvidence(o, turnToolUses)
409
455
  if (evidence.outputText) turnOutputText += (turnOutputText ? ' ' : '') + evidence.outputText
@@ -441,13 +487,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
441
487
  })
442
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' }) })
443
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 }))
444
491
  log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
445
492
  ? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
446
493
  : ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
447
494
  }
448
495
 
449
- function runCycle(prompt, cycleModel) {
496
+ function runCycle(prompt, cycleModel, cycleOptions = {}) {
450
497
  return new Promise((resolve) => {
498
+ turnCycleId = cycleOptions.cycleId ?? null
451
499
  // A per-cycle model override (e.g. chat on a cheaper model than code) — a
452
500
  // change means the warm session must be respawned with the new --model.
453
501
  const resolvedCycleModel = resolveClaudeModel(cycleModel)
@@ -462,6 +510,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
462
510
  child = null
463
511
  }
464
512
  ensureSession()
513
+ if (child) childCycleIds.set(child, turnCycleId)
465
514
  turnsThisSession++
466
515
  turnToolCalls = new Set()
467
516
  turnToolUses = new Map()
@@ -503,8 +552,35 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
503
552
  // One WebSocket identity feeds independent serialized work/reply queues. Each
504
553
  // accepted source keeps its own context; direct MCP reconciliation recovers work
505
554
  // without model spend while idle.
506
- function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
507
- 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 || '' })
508
584
  let handle = null
509
585
  // Activity belongs to an individual cycle. Independent ticket workers must
510
586
  // not clear or overwrite each other's status targets.
@@ -519,20 +595,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
519
595
  const authRule = agent !== 'claude'
520
596
  ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
521
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.`
522
- 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.`
523
599
  // Keep the static charter stable so provider prompt caching can reuse it.
524
600
  // Actual cache hits and billing depend on the runtime and model provider.
525
601
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
526
602
  const runnerOptions = {
527
603
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
528
- cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
604
+ cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
529
605
  }
530
606
  // One watcher and one WS subscription, with a bounded pool of isolated coding
531
- // runtimes. Replies remain serialized so one agent cannot answer two messages
532
- // 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.
533
609
  const MAX_CONCURRENT_WORKERS = 3
534
610
  const activeWorkRunners = new Set()
535
- const createWorkRunner = (control, workerWorkdir = '') => createCycleRunner({
611
+ const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
536
612
  ...runnerOptions,
537
613
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
538
614
  onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
@@ -544,7 +620,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
544
620
  const activeReplyRunners = new Set()
545
621
  const replyRunners = createRunnerPool({
546
622
  capacity: 8,
547
- create: ({ key, canCoordinate }) => createCycleRunner({
623
+ create: ({ key, canCoordinate }) => makeRunner({
548
624
  ...runnerOptions, workdir: '', canCoordinate,
549
625
  systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
550
626
  cfgKey: identifier + '-reply-' + slugify(key),
@@ -553,7 +629,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
553
629
  const codexPushGuide = agent === 'codex' && canCode
554
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.'
555
631
  : ''
556
- 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.'
557
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
558
634
  const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
559
635
  const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
@@ -573,7 +649,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
573
649
  const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
574
650
  item.control.runner = runner
575
651
  activeWorkRunners.add(runner)
576
- 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)) }
577
653
  finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
578
654
  },
579
655
  onError: (error, item) => {
@@ -590,7 +666,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
590
666
  const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
591
667
  activeReplyRunners.add(runner)
592
668
  item.control.runner = runner
593
- 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, '')) }
594
670
  finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
595
671
  },
596
672
  onError: (error, item) => {
@@ -607,16 +683,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
607
683
  // Mentions we've already reacted to (by message id) — the backend can re-deliver
608
684
  // an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
609
685
  // agent reply to the SAME message twice.
610
- const replayPath = join(OV_DIR, 'watch-' + slug + '-replay.json')
686
+ const replayPath = join(stateDir, 'watch-' + slug + '-replay.json')
611
687
  let replayState = {}
612
688
  try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
613
689
  const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
614
690
  const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
615
691
  const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
616
692
  const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
617
- const memory = createMastraMemory({
618
- ledgerPath: join(OV_DIR, 'watch-' + slug + '-memory.json'),
619
- 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'),
620
697
  resourceId: identifier,
621
698
  })
622
699
  // Completion delivery is runtime-owned for assigned coding work. Persist both
@@ -629,9 +706,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
629
706
  // This is distinct from a policy block: any later human ticket change resumes
630
707
  // the work, but reconnects and the watcher's own blocker update do not.
631
708
  const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
632
- // A policy-blocked task stays paused across reconnects. It is released only
633
- // after the ticket itself carries explicit authorization or is completed/
634
- // 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
635
711
  // attempting the same rejected egress action.
636
712
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
637
713
  const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
@@ -651,6 +727,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
651
727
  recentMentionSignatures: [...recentMentionSignatures],
652
728
  seenActivities: [...seenActivities],
653
729
  deliveredReplies: [...deliveredReplies],
730
+ pendingReplyDeliveries: [...pendingReplyDeliveries],
654
731
  failedTaskVersions: [...failedTaskVersions],
655
732
  blockedTasks: [...blockedTasks],
656
733
  blockedTaskRepos: [...blockedTaskRepos],
@@ -713,7 +790,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
713
790
  const markMentionHandled = (message, channelId) => {
714
791
  pruneMentionSignatures()
715
792
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
716
- const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
793
+ const duplicate = idKey ? seenMentions.has(idKey) : !!signatureKey && recentMentionSignatures.has(signatureKey)
717
794
  if (duplicate) return true
718
795
  if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
719
796
  if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
@@ -725,19 +802,46 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
725
802
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
726
803
  // source independently through work, recovery, and delivery.
727
804
  let backlogProbeBusy = false
728
- let lastInboxSignature = ''
729
805
  let selfAgentId = null
806
+ let selfOrganizationId = null
730
807
  let completionRecipientName = ''
731
808
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
732
809
  const rememberSelfAgent = (self) => {
733
- if (!self || typeof self !== 'object') return
810
+ if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
734
811
  if (self.id != null) selfAgentId = Number(self.id)
735
812
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
736
813
  const addedBy = agentAddedByName(self)
737
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
738
844
  }
739
- const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
740
- const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
741
845
  let mcpToolNames = null
742
846
  let mcpToolDiscoveryPromise = null
743
847
  let mcpToolDiscoveryWarned = false
@@ -756,24 +860,25 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
756
860
  }).finally(() => { mcpToolDiscoveryPromise = null })
757
861
  return mcpToolDiscoveryPromise
758
862
  }
759
- const mcpSupports = async (name) => {
760
- try { return (await discoverMcpTools()).has(name) }
863
+ const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
864
+ let names
865
+ try { names = await discoverMcpTools() }
761
866
  catch (e) {
762
867
  if (!mcpToolDiscoveryWarned) {
763
868
  mcpToolDiscoveryWarned = true
764
869
  log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
765
870
  }
766
- return null
871
+ return { called: false, reason: 'discovery-unavailable' }
767
872
  }
768
- }
769
- const callOptionalMcpTool = async (name, args) => {
770
- const supported = await mcpSupports(name)
771
- if (supported !== true) return { called: false, reason: 'not-advertised' }
772
- 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) } }
773
879
  catch (e) {
774
880
  if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
775
881
  try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
776
- return { called: false, reason: 'not-available' }
777
882
  }
778
883
  throw e
779
884
  }
@@ -812,9 +917,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
812
917
  const tasksData = toolData(tasksResult)
813
918
  const { done: doneIds, review: reviewIds } = typesResult
814
919
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
815
- const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
816
- const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
817
- const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
920
+ const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
818
921
  return assignedHere && !taskIsCompleted(task, doneIds)
819
922
  }).map((task) => ({
820
923
  id: task.id,
@@ -859,9 +962,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
859
962
  // rendered as this agent. A persisted delivery key closes the crash/reconnect
860
963
  // gap; an in-flight promise closes the two-lane race inside one watcher.
861
964
  const messageDeliveries = new Map()
862
- 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}` : ''
863
966
  const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
864
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()
865
978
  const controlKey = threadControlKey(channelId, parentId)
866
979
  if (!controlKey) return
867
980
  memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
@@ -897,6 +1010,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
897
1010
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
898
1011
  })
899
1012
  const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1013
+ if (stopping) throw new Error('Watcher is stopping')
900
1014
  const deliveryKey = String(key || '')
901
1015
  if (parentId == null) {
902
1016
  channelId = await projectStatusChannel(projectId)
@@ -904,12 +1018,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
904
1018
  projectId = undefined // dedicated channels may be organization-scoped
905
1019
  }
906
1020
  const message = String(content || '').trim()
907
- if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
908
- 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
+ }
909
1026
  if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
910
1027
  const controlKey = threadControlKey(channelId, parentId)
911
1028
  const suppressCancelled = () => {
1029
+ if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
912
1030
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1031
+ pendingReplyDeliveries.delete(deliveryKey)
913
1032
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
914
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 } })
915
1034
  return { posted: false, reason: 'thread-cancelled' }
@@ -917,6 +1036,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
917
1036
  const initiallyCancelled = suppressCancelled()
918
1037
  if (initiallyCancelled) return initiallyCancelled
919
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
+ }
920
1045
  const run = (async () => {
921
1046
  if (parentId != null) {
922
1047
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
@@ -935,6 +1060,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
935
1060
  // Re-check immediately before the irreversible post.
936
1061
  const newlyCancelled = suppressCancelled()
937
1062
  if (newlyCancelled) return newlyCancelled
1063
+ if (stopping) throw new Error('Watcher is stopping')
938
1064
 
939
1065
  sendStatus(Number(channelId), 'typing')
940
1066
  const result = toolData(await callMcpTool('post_message', {
@@ -949,11 +1075,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
949
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 } })
950
1076
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
951
1077
  return { posted: true, result }
952
- })().finally(() => messageDeliveries.delete(deliveryKey))
1078
+ })().then((result) => {
1079
+ if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
1080
+ return result
1081
+ }).finally(() => messageDeliveries.delete(deliveryKey))
953
1082
  messageDeliveries.set(deliveryKey, run)
954
1083
  return run
955
1084
  }
956
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
+
957
1098
  const statusChannelCache = new Map()
958
1099
  let dedicatedChannelRead = null
959
1100
  const projectStatusChannel = async (projectId) => {
@@ -1004,7 +1145,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1004
1145
  const taskKey = `${projectId}:${ticketId}`
1005
1146
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1006
1147
  const ticket = current.ticket ?? current.task ?? current
1007
- 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 })
1008
1150
  if (!report) return false
1009
1151
  const memoryKey = `ticket:${projectId}:${ticketId}`
1010
1152
  memory.remember({
@@ -1020,9 +1162,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1020
1162
  // handoff or trap the ticket in reconnect retries.
1021
1163
  if (!reportedTaskComments.has(report.key)) {
1022
1164
  try {
1023
- 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 })
1024
1166
  if (comment.called) log('posted verified ticket comment for #' + ticketId)
1025
- 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')
1026
1168
  } catch (e) {
1027
1169
  log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
1028
1170
  }
@@ -1088,10 +1230,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1088
1230
  refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
1089
1231
  })
1090
1232
  try {
1091
- const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
1233
+ const comment = await callOptionalTicketComment({ projectId, ticketId, content: ticketNotice })
1092
1234
  if (comment.called) { delivered = true; return }
1093
- log('comment_ticket is not exposed; recording the blocker in the ticket description for #' + ticketId)
1094
- } 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
+ }
1095
1240
  const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1096
1241
  const ticket = current.ticket ?? current.task ?? current
1097
1242
  const description = String(ticket.description || '')
@@ -1121,17 +1266,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1121
1266
  }
1122
1267
  const command = block?.command || 'the requested external repository action'
1123
1268
  const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
1124
- const approval = block?.commit && block?.branch
1125
- ? `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}.”`
1126
- : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
1127
- 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.`
1128
- 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
1129
1271
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1130
1272
  }
1131
1273
 
1132
1274
  // Reconcile everything that may have arrived while disconnected. This spends no
1133
1275
  // model tokens unless the tools actually report pending work.
1134
1276
  const reconcileBacklog = async () => {
1277
+ if (stopping) return
1135
1278
  if (!mcpUrl || backlogProbeBusy) return
1136
1279
  backlogProbeBusy = true
1137
1280
  try {
@@ -1153,9 +1296,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1153
1296
  ])
1154
1297
  const { done: doneIds, review: reviewIds } = typesData
1155
1298
  for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1156
- const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
1157
- const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
1158
- if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
1299
+ if (!taskBelongsToAgent(task, { id: selfAgentId, identifier })) continue
1159
1300
  const taskKey = `${project.id}:${task.id}`
1160
1301
  if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1161
1302
  memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
@@ -1207,7 +1348,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1207
1348
  const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1208
1349
  const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1209
1350
  const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1210
- const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1351
+ const activityParentId = messageParentId(activityMessage)
1211
1352
  const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1212
1353
  const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1213
1354
  const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
@@ -1232,29 +1373,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1232
1373
  project: project.name,
1233
1374
  channelId: activityChannelId,
1234
1375
  message: activityMessage,
1235
- activity: item,
1376
+ activity: item, activityKey,
1236
1377
  })
1237
1378
  }
1238
1379
  }
1239
1380
  } catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
1240
1381
  })
1241
1382
  if (activityReplayTouched) persistReplay()
1242
- const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1243
- if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1244
- lastInboxSignature = inboxSignature
1383
+ if (mentionActivity.length) {
1245
1384
  log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
1246
1385
  // Replay each real message through the exact same delivery path as a live
1247
1386
  // WebSocket mention. This preserves thread ids and lets postMessageOnce
1248
1387
  // consult the rendered thread before any reply is emitted.
1249
1388
  for (const mention of mentionActivity) {
1250
- onEvent('agent:mention', {
1389
+ void onEvent('agent:mention', {
1251
1390
  project_id: mention.projectId,
1252
1391
  channel_id: mention.channelId,
1253
1392
  message: mention.message,
1254
1393
  _mentionAlreadyMarked: true,
1394
+ })?.then((result) => {
1395
+ if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
1255
1396
  })
1256
1397
  }
1257
- } else if (!mentionActivity.length) lastInboxSignature = ''
1398
+ }
1258
1399
  } catch (e) {
1259
1400
  log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
1260
1401
  } finally { backlogProbeBusy = false }
@@ -1279,22 +1420,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1279
1420
  }
1280
1421
 
1281
1422
  function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1423
+ if (stopping) return Promise.resolve({ status: 'canceled' })
1282
1424
  const laneName = kind === 'full' ? 'work' : 'reply'
1283
1425
  const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
1284
1426
  const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
1285
- const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1286
- 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
+ })
1287
1437
  }
1288
1438
 
1289
1439
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1290
1440
  const laneName = kind === 'full' ? 'work' : 'reply'
1291
1441
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1292
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)) })
1293
1446
  log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1294
1447
  const ctx = context ? [context] : []
1295
1448
  const activeTaskRef = taskRef
1296
1449
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
1297
1450
  const runnerOptions = {
1451
+ cycleId: cycleControl.cycleId, attempt: 1,
1298
1452
  ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1299
1453
  ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1300
1454
  }
@@ -1324,10 +1478,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1324
1478
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1325
1479
  const ticket = data.ticket ?? data.task ?? data
1326
1480
  const typeSets = await taskTypeSets(activeTaskRef.projectId)
1327
- const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1328
- const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1329
1481
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1330
- !((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
1482
+ !taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
1331
1483
  taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1332
1484
  releaseTaskForRetry(activeTaskRef, prompt)
1333
1485
  log('queued ticket no longer actionable; skipped before model start')
@@ -1335,8 +1487,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1335
1487
  }
1336
1488
  }
1337
1489
  if (cycleControl.cancelled) return
1490
+ cycleControl.outcome = 'running'
1338
1491
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1339
1492
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1493
+ cycleControl.outcome = result?.subtype || 'ok'
1340
1494
  if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1341
1495
  if (result?.model && result.model !== useModel) {
1342
1496
  // Persist only a successfully selected model and only if the user has
@@ -1369,6 +1523,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1369
1523
  }
1370
1524
  const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
1371
1525
  if (replyMutationErrors.length) {
1526
+ cycleControl.outcome = 'blocked'
1372
1527
  const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
1373
1528
  log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
1374
1529
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
@@ -1384,13 +1539,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1384
1539
  const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
1385
1540
  const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
1386
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 })
1387
1544
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1388
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}`
1389
- 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'
1390
1548
  if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
1391
1549
  const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1392
1550
  const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
1393
1551
  if (!cycleSucceeded(recovery) || recoveryMissing.length) {
1552
+ cycleControl.outcome = 'blocked'
1394
1553
  if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
1395
1554
  try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
1396
1555
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
@@ -1476,7 +1635,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1476
1635
  const hinted = taskFromEvent(raw)
1477
1636
  const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
1478
1637
  const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
1479
- 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 }
1480
1639
  try {
1481
1640
  if (selfAgentId == null) {
1482
1641
  const agentsData = toolData(await callMcpTool('list_agents'))
@@ -1486,10 +1645,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1486
1645
  }
1487
1646
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1488
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 }
1489
1649
  const typeSets = await taskTypeSets(projectId)
1490
- const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1491
- const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1492
- const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1650
+ const belongsToSelf = taskBelongsToAgent(ticket, { id: selfAgentId, identifier })
1493
1651
  const key = `${projectId}:${ticketId}`
1494
1652
  if (!belongsToSelf) {
1495
1653
  for (const [name, queue] of Object.entries(queues)) {
@@ -1543,19 +1701,36 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1543
1701
 
1544
1702
  const mentionIntake = new Map()
1545
1703
  function onEvent(k, d) {
1546
- if (k !== 'agent:mention') return routeEvent(k, d)
1704
+ if (stopping) return
1705
+ if (k !== 'agent:mention' && k !== 'channel:message') return trackOperation(routeEvent(k, d))
1547
1706
  const cid = d?.channel_id ?? d?.channelId
1548
1707
  const message = d?.message || {}
1549
- const parent = message.parent_id ?? message.parentId
1550
- const key = `${cid}:${parent ?? message.id ?? message.message_id}`
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}`
1551
1713
  // Keep redirects ordered while a legacy root is being verified. Other
1552
1714
  // conversations can resolve and run independently.
1553
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' }
1554
1722
  const preliminary = classifyConversationTarget(message, [...selfAliases])
1555
1723
  const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
1556
- const rootContent = parent == null || irrelevant || preliminary.action === 'stand_down' ? '' : await resolveOwnThread(cid, parent)
1557
- routeEvent(k, { ...d, _threadRootContent: rootContent })
1558
- }).catch((error) => log('mention thread verification failed: ' + (error?.message || error)))
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' } })
1559
1734
  .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
1560
1735
  mentionIntake.set(key, next)
1561
1736
  return next
@@ -1564,7 +1739,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1564
1739
  function routeEvent(k, d) {
1565
1740
  const raw = d && typeof d === 'object' ? d : {}
1566
1741
  if (k === 'task:assigned' || k === 'task:updated') {
1567
- void handleTaskSignal(k, raw)
1742
+ return handleTaskSignal(k, raw)
1568
1743
  } else if (k === 'agent:mention') {
1569
1744
  const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
1570
1745
  const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
@@ -1572,8 +1747,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1572
1747
  const who = senderName(msg)
1573
1748
  // Reply IN THE SAME THREAD: parent is the thread root (the message's own id
1574
1749
  // for a top-level mention, or its parent when the mention is itself a reply).
1575
- const mid = msg.id != null ? msg.id : (msg.message_id != null ? msg.message_id : null)
1576
- 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)
1577
1752
  const threadRoot = parent != null ? parent : mid
1578
1753
  const controlKey = threadControlKey(cid, threadRoot)
1579
1754
  const threadOwned = !!controlKey && memory.has(controlKey, 'active')
@@ -1694,16 +1869,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1694
1869
  }
1695
1870
 
1696
1871
  // The WebSocket drives real-time reactions (task:created/updated assigned to us,
1697
- // agent:mention). A thread reply that @mentions the agent fires agent:mention and
1698
- // is handled in-thread above. In addition we run a DAILY (and on-startup) sweep
1699
- // so anything assigned while the agent was offline — tasks especially — is still
1700
- // 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.
1701
1875
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1702
- 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 })
1703
1877
 
1704
1878
  let authorizationProbeBusy = false
1705
1879
  const legacyAuthorizationChecks = new Set()
1706
1880
  const resumeReadyAuthorizations = async () => {
1881
+ if (stopping) return
1707
1882
  if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
1708
1883
  authorizationProbeBusy = true
1709
1884
  try {
@@ -1732,18 +1907,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1732
1907
  } catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
1733
1908
  finally { authorizationProbeBusy = false }
1734
1909
  }
1735
- const authorizationTimer = setInterval(() => void resumeReadyAuthorizations(), 2_000)
1736
- 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?.()
1737
1916
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1738
- if (mcpConfig || mcpUrl) {
1917
+ if (autoStart && (mcpConfig || mcpUrl)) {
1739
1918
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
1740
- const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
1919
+ const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
1741
1920
  if (!existsSync(introMarker)) {
1742
1921
  log('first connection — introducing self to the workspace')
1743
1922
  introTimer = setTimeout(() => {
1744
1923
  void announceIntroduction().then((delivered) => {
1745
1924
  if (!delivered) return
1746
- 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 */ }
1747
1926
  }).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
1748
1927
  }, 5000) // let the socket subscribe first
1749
1928
  }
@@ -1756,32 +1935,42 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1756
1935
  log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
1757
1936
  }
1758
1937
 
1759
- return new Promise(() => {
1760
- // Run until killed. Tidy up the socket + timers on termination so a restarting
1761
- // service doesn't leak a half-open connection or a dangling interval.
1762
- let stopping = false
1763
- const bye = async () => {
1764
- if (stopping) return
1765
- 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 () => {
1766
1945
  clearInterval(authorizationTimer)
1946
+ clearInterval(deliveryTimer)
1947
+ clearInterval(heartbeatTimer)
1767
1948
  if (introTimer) clearTimeout(introTimer)
1768
1949
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1769
1950
  if (taskProbeTimer) clearInterval(taskProbeTimer)
1770
- try { handle && handle.close() } catch { /* noop */ }
1951
+ try { handle?.close() } catch { /* already disconnected */ }
1771
1952
  for (const queue of Object.values(queues)) {
1772
- queue.cancel(() => true, (item) => {
1773
- item.control.cancelled = true
1774
- void item.control.runner?.cancelCurrent?.()
1775
- })
1953
+ queue.cancel(() => true, (item) => { item.control.cancelled = true })
1776
1954
  }
1777
- 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])
1778
1957
  workRunners.close()
1779
1958
  replyRunners.close()
1780
- process.exit(0)
1781
- }
1782
- process.on('SIGTERM', bye)
1783
- process.on('SIGINT', bye)
1784
- })
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
+ }
1785
1974
  }
1786
1975
 
1787
1976
  // ── the warm loop ────────────────────────────────────────────────────────────
@@ -1789,16 +1978,55 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1789
1978
  const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
1790
1979
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1791
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 || '' })
1792
1986
  // Static charter as the cached system prompt (this loop's MCP authenticates via a
1793
1987
  // Bearer header, so no per-call creds are needed in the prompt).
1794
1988
  const systemPrompt = canCode ? CODE_CHARTER : CHAT_CHARTER
1795
- 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
+ }
1796
2005
  const fullPrompt = canCode ? CODE_FULL : CYCLE
1797
2006
  const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
1798
2007
  const liteModel = chatModel || model // cheaper model for chat/quick cycles
1799
2008
 
1800
2009
  const seen = new Set()
1801
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)
1802
2030
  let firstCheck = true
1803
2031
  let lastNewAt = Date.now()
1804
2032
  // A one-off prompt (intro / daily sweep) the main loop runs the next time it's
@@ -1809,11 +2037,11 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1809
2037
  if (!existsSync(introMarker)) {
1810
2038
  try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
1811
2039
  log('first run — introducing self to the workspace')
1812
- setTimeout(() => { queuedSpecial = INTRO }, 5000)
2040
+ introTimer = setTimeout(() => { queuedSpecial = INTRO }, 5000)
1813
2041
  }
1814
2042
  // The first inbox response below decides whether a startup sweep is needed.
1815
2043
  // Do not spend a model turn on every service restart when the inbox is empty.
1816
- 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)
1817
2045
  }
1818
2046
 
1819
2047
  async function check() {
@@ -1831,7 +2059,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1831
2059
 
1832
2060
  async function run() {
1833
2061
  log('up — warm watcher on ' + host + (canCode ? ' [code: ' + workdir + ']' : ''))
1834
- for (;;) {
2062
+ while (!stopping) {
1835
2063
  let delay = FAST
1836
2064
  try {
1837
2065
  // Run a queued one-off (intro / daily sweep) first when free, on the same
@@ -1844,6 +2072,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1844
2072
  continue
1845
2073
  }
1846
2074
  const res = busy ? { items: [], paused: false } : await check()
2075
+ if (stopping) break
1847
2076
  const items = Array.isArray(res.items) ? res.items : []
1848
2077
  if (firstCheck && Array.isArray(res.items)) {
1849
2078
  firstCheck = false
@@ -1887,7 +2116,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
1887
2116
  }
1888
2117
  }
1889
2118
 
1890
- return run()
2119
+ return run().finally(close)
1891
2120
  }
1892
2121
 
1893
2122
  // ── background service install (launchd / systemd) ───────────────────────────