openvisio-agent 0.19.9 → 0.19.11

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
@@ -8,16 +8,19 @@ import { spawn, spawnSync } from 'node:child_process'
8
8
  import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
- import { fileURLToPath } from 'node:url'
12
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
13
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
14
- import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.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'
15
14
  import { createMastraMemory } from './memory.mjs'
16
- import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
15
+ import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
17
16
  import { createMcpHttpClient } from './mcp-http.mjs'
18
- import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
19
- import { buildCodexMcpOverride } from './codex-config.mjs'
20
17
  import { createCycleQueue } from './cycle-queue.mjs'
18
+ import { createRunnerPool } from './runner-pool.mjs'
19
+ import { mapConcurrent } from './concurrency.mjs'
20
+ import { releaseAuthorizedPause } from './authorization-resume.mjs'
21
+ import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
22
+ import { createTaskTypeLookup } from './task-types.mjs'
23
+ import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
21
24
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
25
  import { createMastraAcpRunner } from './mastra-harness.mjs'
23
26
  import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
@@ -60,6 +63,7 @@ const REPLY_DISCIPLINE = [
60
63
  ' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_agents + list_projects + list_tasks and then get_ticket as needed. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
61
64
  ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
62
65
  ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
66
+ ' • NEW THREADS BELONG IN YOUR DEDICATED CHANNEL. Reply to an existing request in its original thread. For any fresh top-level thread (results, blockers, or an introduction), use only your own dedicated agent channel, resolved from live list_channels by your exact identity/name. Never open a fresh thread in general, a project channel, or another agent’s channel. If no unique dedicated channel exists, keep results on the ticket and report the missing channel in an existing source thread. Never invent a channel or tool.',
63
67
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
64
68
  ].join('\n')
65
69
 
@@ -101,7 +105,7 @@ const CODE_CHARTER = [
101
105
  '',
102
106
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
103
107
  ' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
104
- ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
108
+ ' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the dedicated agent channel after the PR and ticket handoff are confirmed.',
105
109
  ' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
106
110
  ' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
107
111
  ' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
@@ -121,7 +125,7 @@ const CODE_FULL = [
121
125
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
122
126
  ' 4. COMMIT + PUSH YOUR BRANCH: stage only the specific paths or hunks changed for this task; inspect git diff --cached before committing. If unrelated changes are already staged, use an isolated worktree. Commit with a clear message, then publish only your own agent/* branch using the runtime-specific authorized push flow. Never --force, never push to main/master, never merge.',
123
127
  ' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
124
- ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
128
+ ' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified dedicated-channel completion message and deduplicates it across reconnects.',
125
129
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
126
130
  ].join('\n')
127
131
 
@@ -139,7 +143,7 @@ const CODE_FAST = [
139
143
  // missed — TASKS especially.
140
144
  const INTRO = [
141
145
  'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
142
- 'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: say in first person that you are here, pick up tasks assigned to you, and answer @mentions. Do not state your name; the app already displays it. Keep it to 1-2 warm, professional sentences.',
146
+ 'Find your own dedicated agent channel using list_channels and your exact identity/name, then post_message there ONCE: say in first person that you are here, pick up assigned tasks, and answer @mentions. Never use general or another agent’s channel for a fresh thread. If no unique dedicated channel exists, do not invent one. Do not state your name; the app already displays it. Keep it to 1-2 sentences.',
143
147
  'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
144
148
  ].join('\n')
145
149
  const SWEEP = [
@@ -259,6 +263,7 @@ export async function runWatch({ flags }) {
259
263
  : flags.workdir ? String(flags.workdir)
260
264
  : flags.workspace ? String(flags.workspace) : ''
261
265
  const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
266
+ if (workdir) configurePrPublishing({ workspace: workdir, enabled: !flags['no-pr-push'] && saved?.prPublishing !== false })
262
267
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
263
268
 
264
269
  // Use a specific Codex ID; configured chat and code tiers remain independent.
@@ -312,303 +317,15 @@ export async function runWatch({ flags }) {
312
317
  return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
313
318
  }
314
319
 
315
- // ── opencode cycle runner ─────────────────────────────────────────────────────
316
- // opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
317
- // so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
318
- // The openvisio-team MCP is declared in a private per-agent config. The actual
319
- // code workspace is passed through --dir, so two agents never overwrite each
320
- // other's MCP identity even when they work in the same repository.
321
- // `--auto` approves tool use non-interactively.
322
- // Same { runCycle, canCode } contract as the Claude runner.
323
- function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
324
- const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
325
- const bin = onPath('opencode') || 'opencode'
326
- const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
327
- const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode })
328
- let configured = false
329
- let cancelActive = null
330
- const ensureConfig = () => {
331
- if (configured) return
332
- configured = true
333
- try {
334
- mkdirSync(configDir, { recursive: true })
335
- mkdirSync(workspace, { recursive: true })
336
- if (opencodeConfig) {
337
- writeJson(opencodeConfigPath, opencodeConfig, true)
338
- } else {
339
- log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
340
- }
341
- } catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
342
- }
343
-
344
- function runCycle(prompt, cycleModel) {
345
- return new Promise((resolve) => {
346
- ensureConfig()
347
- const m = cycleModel || model
348
- // opencode has no system-prompt flag; each run is a fresh process, so fold the
349
- // charter/creds into the message (still not re-accumulated across cycles).
350
- const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
351
- // JSON mode is the evidence boundary. Formatted stdout only tells us that
352
- // OpenCode exited; raw events tell us which tools actually completed.
353
- const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
354
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
355
- let terminationResult = null
356
- let cancel = null
357
- let outputText = '', jsonlBuffer = ''
358
- const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
359
- const finish = (o) => {
360
- if (done) return
361
- done = true
362
- if (cancelActive === cancel) cancelActive = null
363
- clearTimeout(timer)
364
- const calls = [...mcpCalls]
365
- log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
366
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
367
- }
368
- const terminate = (subtype) => {
369
- terminationResult ||= { type: 'result', subtype }
370
- return stopModelProcess(child).then(() => finish(terminationResult))
371
- }
372
- const inspectLine = (line) => {
373
- const value = String(line || '').trim()
374
- if (!value) return
375
- let event
376
- try { event = JSON.parse(value) } catch {
377
- if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
378
- return
379
- }
380
- const evidence = opencodeEventEvidence(event)
381
- if (evidence.outputText) {
382
- outputText += ' ' + evidence.outputText
383
- if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
384
- }
385
- if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
386
- if (!evidence.tool) return
387
- if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
388
- if (evidence.failed && evidence.toolError) {
389
- const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
390
- log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
391
- }
392
- try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
393
- if (evidence.mcpTool) {
394
- mcpCalls.add(evidence.mcpTool)
395
- if (evidence.failed) mcpErrors.add(evidence.mcpTool)
396
- else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
397
- }
398
- const repositoryWasChanged = didRepoMutation
399
- if (evidence.didChannelMessage && repositoryWasChanged) didResultMessage = true
400
- didCode ||= !!evidence.didCode
401
- didRepoMutation ||= !!evidence.didRepoMutation
402
- didMessage ||= !!evidence.didMessage
403
- didChannelMessage ||= !!evidence.didChannelMessage
404
- didMcpTaskRead ||= !!evidence.didMcpTaskRead
405
- didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
406
- }
407
- const timer = setTimeout(() => {
408
- log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
409
- void terminate('timeout')
410
- }, maxCycleMs)
411
- cancel = () => terminate('canceled')
412
- cancelActive = cancel
413
- log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
414
- try {
415
- child = spawn(bin, args, {
416
- ...modelProcessOptions,
417
- cwd: configDir,
418
- env: {
419
- ...process.env,
420
- OPENCODE_CONFIG: opencodeConfigPath,
421
- // Inline config has higher precedence than a project opencode.json.
422
- // This prevents a stale generated workspace file from replacing this
423
- // agent's URL or credentials while keeping normal project settings.
424
- ...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
425
- },
426
- stdio: ['ignore', 'pipe', 'inherit'],
427
- })
428
- child.stdout?.on('data', (data) => {
429
- jsonlBuffer += String(data)
430
- const lines = jsonlBuffer.split('\n')
431
- jsonlBuffer = lines.pop() ?? ''
432
- for (const line of lines) inspectLine(line)
433
- })
434
- }
435
- catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
436
- child.on('close', (code) => {
437
- inspectLine(jsonlBuffer); jsonlBuffer = ''
438
- const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
439
- log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
440
- finish(terminationResult || { type: 'result', subtype })
441
- })
442
- child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
443
- })
444
- }
445
-
446
- log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
447
- return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
448
- }
449
-
450
- // Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
451
- // for each event and inject only this agent's OpenVisio MCP config on the command
452
- // line. That avoids inheriting unrelated user MCP servers while keeping the
453
- // user's normal Codex authentication. We deliberately never use Codex's dangerous
454
- // approval/sandbox bypass flag.
455
- function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
456
- const bin = onPath('codex') || 'codex'
457
- const defaultCwd = workdir || OV_DIR
458
- const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
459
- let cancelActive = null
460
-
461
- function runCycle(prompt, cycleModel, cycleOptions = {}) {
462
- return new Promise((resolve) => {
463
- const m = cycleModel || model
464
- const cycleCwd = cycleOptions.workdir || defaultCwd
465
- const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
466
- const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
467
- const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
468
- const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
469
- ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
470
- ...(m ? ['--model', m] : []),
471
- ...(mcpOverride ? ['-c', mcpOverride] : []),
472
- full]
473
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
474
- let terminationResult = null
475
- let cancel = null
476
- let policyBlock = null
477
- const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
478
- let didMcpTaskRead = false, didMcpTaskUpdate = false
479
- const finish = (o) => {
480
- if (done) return
481
- done = true
482
- if (cancelActive === cancel) cancelActive = null
483
- clearTimeout(timer)
484
- const calls = [...mcpCalls]
485
- const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
486
- log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
487
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
488
- }
489
- const terminate = (subtype) => {
490
- terminationResult ||= { type: 'result', subtype }
491
- return stopModelProcess(child).then(() => finish(terminationResult))
492
- }
493
- const inspectDiagnostic = (value) => {
494
- const s = String(value || '')
495
- stderrBuffer = (stderrBuffer + s).slice(-24_000)
496
- policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
497
- }
498
- const forwardDiagnostic = (value, flush = false) => {
499
- const incoming = String(value || '')
500
- inspectDiagnostic(incoming)
501
- stderrLineBuffer += incoming
502
- const lines = stderrLineBuffer.split('\n')
503
- stderrLineBuffer = lines.pop() ?? ''
504
- for (const line of lines) {
505
- if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
506
- }
507
- if (flush && stderrLineBuffer) {
508
- if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
509
- stderrLineBuffer = ''
510
- }
511
- }
512
- const inspectLine = (line) => {
513
- const s = line.trim()
514
- if (!s) return
515
- policyBlock = codexPolicyBlock(s) || policyBlock
516
- try {
517
- const event = JSON.parse(s)
518
- const evidence = codexEventEvidence(event)
519
- if (evidence.outputText) outputText += ' ' + evidence.outputText
520
- if (evidence.mcpTool) {
521
- mcpCalls.add(evidence.mcpTool)
522
- try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
523
- if (evidence.failed) {
524
- mcpErrors.add(evidence.mcpTool)
525
- if (evidence.toolError) {
526
- const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
527
- const detail = String(evidence.toolError)
528
- const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
529
- mcpErrorDetails.set(evidence.mcpTool, safe)
530
- }
531
- } else if (evidence.completed) {
532
- mcpErrors.delete(evidence.mcpTool)
533
- mcpErrorDetails.delete(evidence.mcpTool)
534
- }
535
- }
536
- if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
537
- didCode ||= !!evidence.didCode
538
- didRepoMutation ||= !!evidence.didRepoMutation
539
- didMessage ||= !!evidence.didMessage
540
- didChannelMessage ||= !!evidence.didChannelMessage
541
- didMcpTaskRead ||= !!evidence.didMcpTaskRead
542
- didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
543
- } catch { /* non-JSON diagnostic */ }
544
- if (debug) log(' · ' + s.slice(0, 220))
545
- }
546
- const timer = setTimeout(() => {
547
- log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
548
- void terminate('timeout')
549
- }, maxCycleMs)
550
- cancel = () => terminate('canceled')
551
- cancelActive = cancel
552
- log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
553
- try {
554
- // Always inspect Codex JSONL so a successful process exit cannot be
555
- // mistaken for completed work. Keep it out of normal logs unless debug.
556
- child = spawn(bin, args, {
557
- ...modelProcessOptions,
558
- cwd: cycleCwd,
559
- env: {
560
- ...process.env,
561
- ...(mcpUrl ? {
562
- OPENVISIO_CODEX_MCP_URL: mcpUrl,
563
- OPENVISIO_CODEX_API_KEY: mcpHeaders?.['x-agent-api-key'] || '',
564
- OPENVISIO_CODEX_IDENTIFIER: mcpHeaders?.['x-agent-identifier'] || '',
565
- } : {}),
566
- },
567
- stdio: ['ignore', 'pipe', 'pipe'],
568
- })
569
- if (child.stdout) child.stdout.on('data', (d) => {
570
- jsonlBuffer += String(d)
571
- const lines = jsonlBuffer.split('\n')
572
- jsonlBuffer = lines.pop() ?? ''
573
- for (const line of lines) inspectLine(line)
574
- })
575
- if (child.stderr) child.stderr.on('data', (d) => {
576
- forwardDiagnostic(d)
577
- })
578
- } catch (e) {
579
- log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
580
- return finish({ type: 'result', subtype: 'spawn-failed' })
581
- }
582
- child.on('close', (code) => {
583
- inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
584
- const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
585
- log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
586
- finish(terminationResult || { type: 'result', subtype })
587
- })
588
- child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
589
- })
590
- }
591
-
592
- if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
593
- log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
594
- return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
595
- }
596
-
597
- // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
598
- // One persistent stream-json session, poked with a prompt per cycle. Recycled
599
- // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
600
- function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
320
+ // 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 }) {
601
322
  const canCode = !!workdir
602
323
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
603
324
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
604
325
  // its own ACP session and worktree, so cancellation, permissions and context
605
326
  // cannot bleed across concurrently assigned tickets.
606
- const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
327
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, systemPrompt })
607
328
  if (mastraRunner) return mastraRunner
608
- // opencode drives cycles differently — a headless `opencode run` per cycle rather
609
- // than a persistent stream-json session. Same { runCycle, canCode } contract.
610
- if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
611
- if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
612
329
  let child = null
613
330
  // The model the CURRENT session was spawned with. runCycle can pass a different
614
331
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
@@ -778,7 +495,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
778
495
  child = null
779
496
  return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
780
497
  }
781
- return { runCycle, canCode, cancelCurrent }
498
+ return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
782
499
  }
783
500
 
784
501
  // ── the backend WS loop ──────────────────────────────────────────────────────
@@ -798,12 +515,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
798
515
  const canCode = !!workdir
799
516
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
800
517
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
801
- const authRule = agent === 'codex'
518
+ const authRule = agent !== 'claude'
802
519
  ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
803
520
  : `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.`
804
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.`
805
- // The STATIC charter + creds are the session system prompt (cached, billed once),
806
- // NOT re-sent in every cycle's user message — the big token saving.
522
+ // Keep the static charter stable so provider prompt caching can reuse it.
523
+ // Actual cache hits and billing depend on the runtime and model provider.
807
524
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
808
525
  const runnerOptions = {
809
526
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
@@ -819,30 +536,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
819
536
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
820
537
  onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
821
538
  })
822
- const replyRunners = new Map()
823
- const replyRunnerFor = (delivery) => {
824
- const key = delivery ? `${delivery.channelId ?? '?'}:${delivery.parentId ?? 'root'}` : 'global'
825
- let runner = replyRunners.get(key)
826
- if (!runner) {
827
- runner = createCycleRunner({
828
- ...runnerOptions,
829
- workdir: '',
830
- systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
831
- cfgKey: identifier + '-reply-' + slugify(key),
832
- onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
833
- })
834
- } else replyRunners.delete(key)
835
- replyRunners.set(key, runner)
836
- while (replyRunners.size > 8) {
837
- const oldestKey = replyRunners.keys().next().value
838
- const oldest = replyRunners.get(oldestKey)
839
- replyRunners.delete(oldestKey)
840
- oldest?.close?.()
841
- }
842
- return runner
843
- }
539
+ const workRunners = createRunnerPool({
540
+ create: ({ control, workdir }) => createWorkRunner(control, workdir),
541
+ capacity: 6,
542
+ })
543
+ const activeReplyRunners = new Set()
544
+ const replyRunners = createRunnerPool({
545
+ capacity: 8,
546
+ create: ({ key, canCoordinate }) => createCycleRunner({
547
+ ...runnerOptions, workdir: '', canCoordinate,
548
+ systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
549
+ cfgKey: identifier + '-reply-' + slugify(key),
550
+ }),
551
+ })
844
552
  const codexPushGuide = agent === 'codex' && canCode
845
- ? '\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. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
553
+ ? '\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.'
846
554
  : ''
847
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.'
848
556
  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
@@ -860,11 +568,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
860
568
  concurrency: MAX_CONCURRENT_WORKERS,
861
569
  groupKey: (item) => item.workdir || workdir || 'shared-workspace',
862
570
  run: async (item) => {
863
- const runner = createWorkRunner(item.control, item.workdir)
571
+ const sessionKey = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.workdir || workdir])
572
+ const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
864
573
  item.control.runner = runner
865
574
  activeWorkRunners.add(runner)
866
575
  try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
867
- finally { runner.close?.(); activeWorkRunners.delete(runner); item.control.runner = null }
576
+ finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
868
577
  },
869
578
  onError: (error, item) => {
870
579
  pauseFailedTask(item.taskRef)
@@ -873,11 +582,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
873
582
  },
874
583
  }),
875
584
  reply: createCycleQueue({
585
+ concurrency: 3,
586
+ groupKey: (item) => item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
876
587
  run: async (item) => {
877
- const runner = replyRunnerFor(item.delivery)
588
+ const key = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.kind === 'coord'])
589
+ const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
590
+ activeReplyRunners.add(runner)
878
591
  item.control.runner = runner
879
592
  try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
880
- finally { item.control.runner = null }
593
+ finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
881
594
  },
882
595
  onError: (error, item) => {
883
596
  pauseFailedTask(item.taskRef)
@@ -957,7 +670,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
957
670
  trimMap(failedTaskVersions)
958
671
  seenTasks.add(key)
959
672
  trimSeen(seenTasks)
960
- lastTaskSignature = ''
961
673
  persistReplay()
962
674
  }
963
675
  const finalizeFailedTaskPause = async (taskRef) => {
@@ -989,6 +701,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
989
701
  persistReplay()
990
702
  return false
991
703
  }
704
+ const resumeAuthorized = (key) => {
705
+ const resumed = releaseAuthorizedPause(key, {
706
+ blocked: blockedTasks, repos: blockedTaskRepos, failed: failedTaskVersions, seen: seenTasks,
707
+ authorized: (cwd) => repositoryHasPrPushAuthorization({ cwd }),
708
+ })
709
+ if (resumed) { persistReplay(); log('PR authorization ready; resuming ' + key) }
710
+ return resumed
711
+ }
992
712
  const markMentionHandled = (message, channelId) => {
993
713
  pruneMentionSignatures()
994
714
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
@@ -1004,8 +724,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1004
724
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
1005
725
  // source independently through work, recovery, and delivery.
1006
726
  let backlogProbeBusy = false
1007
- let lastTaskSignature = ''
1008
- let lastTaskTriggeredAt = 0
1009
727
  let lastInboxSignature = ''
1010
728
  let selfAgentId = null
1011
729
  let completionRecipientName = ''
@@ -1066,6 +784,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1066
784
  try { return JSON.parse(text) } catch { return { text } }
1067
785
  }
1068
786
 
787
+ const taskTypeSets = createTaskTypeLookup({ load: (projectId) => callMcpTool('list_task_types', { project_id: projectId }).then(toolData) })
788
+
1069
789
  const callMcpReadWithRetry = async (name, args = {}) => {
1070
790
  try { return await callMcpTool(name, args) }
1071
791
  catch (first) {
@@ -1075,38 +795,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1075
795
  }
1076
796
 
1077
797
  const loadPendingTickets = async () => {
798
+ if (selfAgentId == null) {
799
+ const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
800
+ const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
801
+ if (!self?.id) throw new Error('list_agents did not return this BYO agent')
802
+ rememberSelfAgent(self)
803
+ }
1078
804
  const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
1079
805
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1080
- const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
806
+ const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
1081
807
  const [tasksResult, typesResult] = await Promise.all([
1082
808
  callMcpReadWithRetry('list_tasks', { project_id: project.id }),
1083
- callMcpReadWithRetry('list_task_types', { project_id: project.id }).catch((error) => {
1084
- log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
1085
- return null
1086
- }),
809
+ taskTypeSets(project.id),
1087
810
  ])
1088
811
  const tasksData = toolData(tasksResult)
1089
- const typesData = typesResult ? toolData(typesResult) : {}
1090
- const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1091
- const doneIds = new Set(types.filter((type) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
812
+ const { done: doneIds, review: reviewIds } = typesResult
1092
813
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
1093
814
  const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
1094
815
  const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
1095
816
  const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
1096
817
  return assignedHere && !taskIsCompleted(task, doneIds)
1097
818
  }).map((task) => ({
819
+ id: task.id,
820
+ projectId: project.id,
821
+ awaitingReview: taskIsAwaitingReview(task, reviewIds),
1098
822
  slug: ticketDisplaySlug(task),
1099
823
  title: String(task.title || 'Untitled ticket'),
1100
824
  state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
1101
825
  project: String(project.name || ''),
1102
826
  }))
1103
- }))
827
+ })
1104
828
  return groups.flat()
1105
829
  }
1106
830
 
1107
- const answerPendingTickets = async ({ delivery, who }) => {
831
+ const answerPendingTickets = async ({ delivery, who, text }) => {
1108
832
  try {
1109
833
  const tickets = await loadPendingTickets()
834
+ // Assignments already authorize pickup. A board lookup should not leave
835
+ // discovered work idle until a second human nudge arrives.
836
+ if (canCode && !assignmentStatusOnly(text)) {
837
+ await routeAssignments({
838
+ request: { slugs: [] }, loadTickets: async () => tickets, handleTask: handleTaskSignal,
839
+ isCancelled: () => memory.has(threadControlKey(delivery?.channelId, delivery?.parentId), 'cancelled'),
840
+ })
841
+ }
1110
842
  const prefix = who ? `@${who} ` : ''
1111
843
  const details = tickets.slice(0, 8).map((ticket) => `${ticket.slug || ticket.title}${ticket.slug ? ` “${ticket.title}”` : ''}${ticket.state ? ` (${ticket.state})` : ''}${ticket.project ? ` in ${ticket.project}` : ''}`)
1112
844
  const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
@@ -1144,8 +876,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1144
876
  const controlKey = threadControlKey(channelId, parentId)
1145
877
  if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
1146
878
  }
1147
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
879
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
1148
880
  const deliveryKey = String(key || '')
881
+ if (parentId == null) {
882
+ channelId = await projectStatusChannel(projectId)
883
+ if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
884
+ projectId = undefined // dedicated channels may be organization-scoped
885
+ }
1149
886
  const message = String(content || '').trim()
1150
887
  if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
1151
888
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
@@ -1163,7 +900,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1163
900
  const run = (async () => {
1164
901
  if (parentId != null) {
1165
902
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1166
- const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
903
+ const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
1167
904
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
1168
905
  if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
1169
906
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
@@ -1196,24 +933,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1196
933
  }
1197
934
 
1198
935
  const statusChannelCache = new Map()
936
+ let dedicatedChannelRead = null
1199
937
  const projectStatusChannel = async (projectId) => {
1200
- const key = Number(projectId)
1201
- const cached = statusChannelCache.get(key)
938
+ const cached = statusChannelCache.get('self')
1202
939
  if (cached && cached.expiresAt > Date.now()) return cached.channelId
1203
- try {
1204
- const data = toolData(await callMcpTool('list_channels', { project_id: key }))
1205
- const channels = Array.isArray(data.channels) ? data.channels : Array.isArray(data.data?.channels) ? data.data.channels : Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : []
1206
- const usable = channels.filter((c) => Number.isFinite(Number(c.id)))
1207
- const preferred = usable.find((c) => /^(?:general|team|project|dev|development)$/i.test(String(c.name || '').trim())) || usable[0]
1208
- const channelId = preferred ? Number(preferred.id) : null
1209
- statusChannelCache.set(key, { channelId, expiresAt: Date.now() + 5 * 60_000 })
1210
- if (channelId == null) log('no channel available for project ' + key + '; task activity cannot be displayed')
1211
- return channelId
1212
- } catch (e) {
1213
- statusChannelCache.set(key, { channelId: null, expiresAt: Date.now() + 30_000 })
1214
- log('cannot resolve activity channel for project ' + key + ': ' + (e?.message || e))
1215
- return null
1216
- }
940
+ if (dedicatedChannelRead) return dedicatedChannelRead
941
+ dedicatedChannelRead = (async () => {
942
+ try {
943
+ if (selfAgentId == null) {
944
+ const agents = toolData(await callMcpTool('list_agents')).agents || []
945
+ rememberSelfAgent(agents.find((entry) => String(entry.identifier || entry.slug || '') === identifier))
946
+ }
947
+ const data = toolData(await callMcpTool('list_channels'))
948
+ const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
949
+ const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
950
+ const channelId = channel ? Number(channel.id) : null
951
+ statusChannelCache.set('self', { channelId, expiresAt: Date.now() + (channelId ? 300_000 : 10_000) })
952
+ if (channelId == null) log('No unique dedicated agent channel; top-level delivery stays pending.')
953
+ return channelId
954
+ } catch (error) {
955
+ log('Dedicated channel lookup failed: ' + (error?.message || error))
956
+ return null
957
+ }
958
+ })().finally(() => { dedicatedChannelRead = null })
959
+ return dedicatedChannelRead
1217
960
  }
1218
961
 
1219
962
  const announceIntroduction = async () => {
@@ -1222,7 +965,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1222
965
  const project = projects.find((item) => Number.isFinite(Number(item.id)))
1223
966
  if (!project) { log('no project available for first-connection introduction'); return false }
1224
967
  const channelId = await projectStatusChannel(project.id)
1225
- if (!Number.isFinite(Number(channelId))) return false
968
+ if (channelId == null || !Number.isFinite(Number(channelId))) return false
1226
969
  await postMessageOnce({
1227
970
  key: `intro:${identifier}:${project.id}`,
1228
971
  projectId: Number(project.id),
@@ -1257,9 +1000,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1257
1000
  try {
1258
1001
  const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
1259
1002
  if (comment.called) log('posted verified ticket comment for #' + ticketId)
1260
- else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and project-channel report')
1003
+ else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
1261
1004
  } catch (e) {
1262
- log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified project-channel report')
1005
+ log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
1263
1006
  }
1264
1007
  reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
1265
1008
  }
@@ -1277,8 +1020,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1277
1020
  log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
1278
1021
  return true
1279
1022
  }
1280
- const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
1281
- if (!Number.isFinite(channelId)) return false
1023
+ const channelId = await projectStatusChannel(projectId)
1024
+ if (channelId == null || !Number.isFinite(channelId)) return false
1282
1025
  await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
1283
1026
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1284
1027
  pendingCompletionReports.delete(taskKey); persistReplay()
@@ -1360,7 +1103,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1360
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}.”`
1361
1104
  : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
1362
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.`
1363
- const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; I won't retry automatically.`
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.`
1364
1107
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1365
1108
  }
1366
1109
 
@@ -1377,117 +1120,103 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1377
1120
  rememberSelfAgent(self)
1378
1121
  const projectsData = toolData(await callMcpTool('list_projects'))
1379
1122
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1380
- const assigned = []
1381
1123
  const mentionActivity = []
1382
1124
  let activityReplayTouched = false
1383
- for (const project of projects) {
1384
- const [tasksData, typesData, activityData] = await Promise.all([
1385
- callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1386
- callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
1387
- callMcpTool('list_activity', { project_id: project.id }).then(toolData),
1388
- ])
1389
- const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1390
- const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
1391
- const reviewIds = new Set(taskTypes.filter((t) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
1392
- for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1393
- const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
1394
- const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
1395
- if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
1396
- const taskKey = `${project.id}:${task.id}`
1397
- if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1398
- memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
1399
- if (pendingCompletionReports.has(taskKey)) {
1400
- const activityChannel = await projectStatusChannel(project.id)
1401
- try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
1402
- catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
1125
+ await mapConcurrent(projects, 3, async (project) => {
1126
+ try {
1127
+ const assigned = []
1128
+ const [tasksData, typesData] = await Promise.all([
1129
+ callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1130
+ taskTypeSets(project.id),
1131
+ ])
1132
+ const { done: doneIds, review: reviewIds } = typesData
1133
+ 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
1137
+ const taskKey = `${project.id}:${task.id}`
1138
+ if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1139
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
1140
+ if (pendingCompletionReports.has(taskKey)) {
1141
+ const activityChannel = await projectStatusChannel(project.id)
1142
+ try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
1143
+ catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
1144
+ }
1145
+ if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
1146
+ clearFailedTask(taskKey)
1147
+ // Release the in-flight de-dupe key at handoff. If a reviewer moves
1148
+ // the ticket back to an actionable column, that update must start a
1149
+ // fresh work cycle.
1150
+ seenTasks.delete(taskKey)
1151
+ continue
1403
1152
  }
1404
- if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
1405
- clearFailedTask(taskKey)
1406
- // Release the in-flight de-dupe key at handoff. If a reviewer moves
1407
- // the ticket back to an actionable column, that update must start a
1408
- // fresh work cycle.
1409
- seenTasks.delete(taskKey)
1410
- continue
1411
- }
1412
- if (failedTaskIsPaused(taskKey, task)) {
1413
- seenTasks.add(taskKey)
1414
- log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
1415
- continue
1416
- }
1417
- if (blockedTasks.has(taskKey)) {
1418
- const blockedRepo = blockedTaskRepos.get(taskKey)
1419
- const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1420
- if (helperAuthorized) {
1421
- blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
1422
- log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
1423
- } else continue
1424
- }
1425
- memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
1426
- assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
1427
- }
1428
- const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1429
- const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1430
- for (const item of activities) {
1431
- const text = JSON.stringify(item)
1432
- const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1433
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1434
- const activityMessage = item?.message && typeof item.message === 'object'
1435
- ? item.message
1436
- : data?.message && typeof data.message === 'object' ? data.message : item
1437
- const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1438
- const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1439
- const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1440
- const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1441
- const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1442
- const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1443
- const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1444
- // Search only the source message, never the whole activity envelope: an
1445
- // old tagged thread root can otherwise make another agent's new reply
1446
- // look addressed to this watcher. Explicit mentions and human follow-ups
1447
- // in a persistently owned thread both enter the guarded per-message path.
1448
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1449
- if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1450
- seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1451
- // The same logical mention may already have arrived over WebSocket.
1452
- // Share the id/signature guard instead of starting a second model turn.
1453
- // Recover the former pre-routing acknowledgement bug exactly once
1454
- // when an owned-thread source has no received ledger entry.
1455
- if (markMentionHandled(activityMessage, activityChannelId)) {
1456
- const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1457
- const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1458
- if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
1153
+ resumeAuthorized(taskKey)
1154
+ if (failedTaskIsPaused(taskKey, task)) {
1155
+ seenTasks.add(taskKey)
1156
+ log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
1157
+ continue
1459
1158
  }
1460
- mentionActivity.push({
1461
- projectId: project.id,
1462
- project: project.name,
1463
- channelId: activityChannelId,
1464
- message: activityMessage,
1465
- activity: item,
1466
- })
1159
+ if (blockedTasks.has(taskKey)) continue
1160
+ memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
1161
+ assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
1467
1162
  }
1468
- }
1469
- }
1470
- if (activityReplayTouched) persistReplay()
1471
- if (!assigned.length) lastTaskSignature = ''
1472
- else {
1473
- const signature = JSON.stringify(assigned)
1474
- const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
1475
- if (signature !== lastTaskSignature || retryDue) {
1476
- lastTaskSignature = signature
1477
- lastTaskTriggeredAt = Date.now()
1478
1163
  const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
1479
- const candidates = [...assigned].sort((a, b) => (priorityRank[String(a.priority).toLowerCase()] ?? 9) - (priorityRank[String(b.priority).toLowerCase()] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))
1480
- log('backlog reconciliation found ' + assigned.length + ' assigned task(s); verifying independent queue entries')
1481
- for (const next of candidates) {
1164
+ assigned.sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))
1165
+ for (const next of assigned) {
1482
1166
  const key = `${next.projectId}:${next.id}`
1483
1167
  if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
1484
- if (retryDue) seenTasks.delete(key)
1485
- // Share verification, ownership, dedupe, and chat-only routing with
1486
- // live events. Every ticket keeps its own context and result.
1168
+ // Live ownership/state verification remains inside handleTaskSignal.
1169
+ seenTasks.delete(key)
1487
1170
  await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
1488
1171
  }
1489
- }
1490
- }
1172
+ const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1173
+ log('activity reconciliation unavailable: ' + (error?.message || error))
1174
+ return {}
1175
+ })
1176
+ const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1177
+ const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1178
+ for (const item of activities) {
1179
+ const text = JSON.stringify(item)
1180
+ const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1181
+ const data = item?.data && typeof item.data === 'object' ? item.data : null
1182
+ const activityMessage = item?.message && typeof item.message === 'object'
1183
+ ? item.message
1184
+ : data?.message && typeof data.message === 'object' ? data.message : item
1185
+ const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1186
+ const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1187
+ const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1188
+ const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1189
+ const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1190
+ const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1191
+ const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1192
+ // Search only the source message, never the whole activity envelope: an
1193
+ // old tagged thread root can otherwise make another agent's new reply
1194
+ // look addressed to this watcher. Explicit mentions and human follow-ups
1195
+ // in a persistently owned thread both enter the guarded per-message path.
1196
+ const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1197
+ if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1198
+ seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1199
+ // The same logical mention may already have arrived over WebSocket.
1200
+ // Share the id/signature guard instead of starting a second model turn.
1201
+ // Recover the former pre-routing acknowledgement bug exactly once
1202
+ // when an owned-thread source has no received ledger entry.
1203
+ if (markMentionHandled(activityMessage, activityChannelId)) {
1204
+ const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1205
+ const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1206
+ if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
1207
+ }
1208
+ mentionActivity.push({
1209
+ projectId: project.id,
1210
+ project: project.name,
1211
+ channelId: activityChannelId,
1212
+ message: activityMessage,
1213
+ activity: item,
1214
+ })
1215
+ }
1216
+ }
1217
+ } catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
1218
+ })
1219
+ if (activityReplayTouched) persistReplay()
1491
1220
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1492
1221
  if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1493
1222
  lastInboxSignature = inboxSignature
@@ -1525,20 +1254,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1525
1254
  }
1526
1255
  // The next task update or periodic reconciliation must be allowed to queue
1527
1256
  // this work again. An acknowledgement is not a terminal task signature.
1528
- lastTaskSignature = ''
1529
1257
  }
1530
1258
 
1531
1259
  function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1532
1260
  const laneName = kind === 'full' ? 'work' : 'reply'
1533
1261
  const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
1534
1262
  const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
1535
- const control = { cancelled: false, runner: null, statusTargets: new Set() }
1263
+ const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1536
1264
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
1537
1265
  }
1538
1266
 
1539
1267
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1540
1268
  const laneName = kind === 'full' ? 'work' : 'reply'
1541
1269
  const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
1270
+ const cycleStartedAt = performance.now()
1271
+ log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
1542
1272
  const ctx = context ? [context] : []
1543
1273
  const activeTaskRef = taskRef
1544
1274
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
@@ -1571,18 +1301,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1571
1301
  if (activeTaskRef) {
1572
1302
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1573
1303
  const ticket = data.ticket ?? data.task ?? data
1304
+ const typeSets = await taskTypeSets(activeTaskRef.projectId)
1574
1305
  const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1575
1306
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1576
1307
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1577
1308
  !((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
1578
- taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1309
+ taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1579
1310
  releaseTaskForRetry(activeTaskRef, prompt)
1580
1311
  log('queued ticket no longer actionable; skipped before model start')
1581
1312
  return
1582
1313
  }
1583
1314
  }
1584
1315
  if (cycleControl.cancelled) return
1316
+ log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1585
1317
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1318
+ if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1586
1319
  if (result?.model && result.model !== useModel) {
1587
1320
  // Persist only a successfully selected model and only if the user has
1588
1321
  // not changed that tier while this cycle was running.
@@ -1731,6 +1464,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1731
1464
  }
1732
1465
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1733
1466
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
1467
+ const typeSets = await taskTypeSets(projectId)
1734
1468
  const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1735
1469
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1736
1470
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
@@ -1745,7 +1479,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1745
1479
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1746
1480
  blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1747
1481
  }
1748
- if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1482
+ if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1749
1483
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1750
1484
  if (pendingCompletionReports.has(key)) {
1751
1485
  const activityChannel = await projectStatusChannel(projectId)
@@ -1756,6 +1490,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1756
1490
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1757
1491
  return
1758
1492
  }
1493
+ resumeAuthorized(key)
1759
1494
  const hadFailedRevision = failedTaskVersions.has(key)
1760
1495
  if (failedTaskIsPaused(key, ticket)) {
1761
1496
  seenTasks.add(key)
@@ -1763,17 +1498,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1763
1498
  return
1764
1499
  }
1765
1500
  if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
1766
- if (blockedTasks.has(key)) {
1767
- const blockedRepo = blockedTaskRepos.get(key)
1768
- const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1769
- if (helperAuthorized) {
1770
- blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
1771
- log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
1772
- } else {
1773
- log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
1774
- return
1775
- }
1776
- }
1501
+ if (blockedTasks.has(key)) { log(kind + ' ticket authorization is still unavailable'); return }
1777
1502
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1778
1503
  seenTasks.add(key); trimSeen(seenTasks)
1779
1504
  const title = String(ticket.title || hinted.title || '')
@@ -1788,7 +1513,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1788
1513
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1789
1514
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1790
1515
  const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
1791
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1516
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the dedicated agent channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1792
1517
  } catch (e) {
1793
1518
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1794
1519
  }
@@ -1848,7 +1573,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1848
1573
  // redirect can cancel its process. Codex additionally delegates the final
1849
1574
  // post to the watcher for an authoritative last-moment thread check.
1850
1575
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1851
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
1576
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: agent === 'codex' }
1852
1577
  : null
1853
1578
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1854
1579
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1875,9 +1600,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1875
1600
  }
1876
1601
  return
1877
1602
  }
1603
+ const assignment = assignmentRequest(text)
1604
+ if (assignment) {
1605
+ const delivery = conversationDelivery('assignment-routing')
1606
+ const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
1607
+ // Enqueue each verified ticket separately, using the same ownership,
1608
+ // revision and queue dedupe gates as live assignment events.
1609
+ void (async () => {
1610
+ try {
1611
+ if (!canCode) throw new Error('This watcher is configured with --chat-only and has no coding worker. Enable its coding workspace to execute assigned tickets.')
1612
+ const count = await routeAssignments({ request: assignment, loadTickets: loadPendingTickets, handleTask: handleTaskSignal, isCancelled })
1613
+ if (count === 0 && delivery && !isCancelled()) {
1614
+ await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I have no open assignments ready for implementation; tickets awaiting review or testing stay in their current stage.` })
1615
+ }
1616
+ } catch (error) {
1617
+ log('assignment routing failed: ' + (error?.message || error))
1618
+ if (delivery && !isCancelled()) await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I couldn't route the assigned work: ${error?.message || error}` })
1619
+ }
1620
+ })().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
1621
+ return
1622
+ }
1878
1623
  if (cid != null && conversationAsksPendingTickets(text)) {
1879
1624
  log('pending-ticket question -> watcher-owned MCP lookup')
1880
- void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
1625
+ void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
1881
1626
  return
1882
1627
  }
1883
1628
  log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
@@ -1913,6 +1658,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1913
1658
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1914
1659
  handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
1915
1660
 
1661
+ let authorizationProbeBusy = false
1662
+ const legacyAuthorizationChecks = new Set()
1663
+ const resumeReadyAuthorizations = async () => {
1664
+ if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
1665
+ authorizationProbeBusy = true
1666
+ try {
1667
+ // Older ACP adapters recorded a helper denial as a generic failed cycle.
1668
+ // Recover that specific legacy state once, without reviving other failures.
1669
+ for (const key of failedTaskVersions.keys()) {
1670
+ const migrationKey = `pr-setup-migration:${key}`
1671
+ if (blockedTasks.has(key) || legacyAuthorizationChecks.has(key) || memory.has(migrationKey)) continue
1672
+ if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
1673
+ const [projectId, ticketId] = key.split(':').map(Number)
1674
+ const cwd = findTicketWorktree(workdir, ticketId)
1675
+ if (!cwd || !repositoryHasPrPushAuthorization({ cwd })) continue
1676
+ const data = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1677
+ const ticket = data.ticket ?? data.task ?? data
1678
+ legacyAuthorizationChecks.add(key)
1679
+ if (!/OPENVISIO_PR_PUSH_AUTH_REQUIRED|openvisio-agent authorize-pr-push/.test(String(ticket.description || ''))) continue
1680
+ memory.remember({ key: migrationKey, kind: 'migration', state: 'checked', summary: 'Recovered legacy PR setup block', refs: { projectId, ticketId } })
1681
+ blockedTasks.add(key); blockedTaskRepos.set(key, cwd)
1682
+ }
1683
+ for (const key of [...blockedTasks]) {
1684
+ if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
1685
+ if (!resumeAuthorized(key)) continue
1686
+ const [projectId, id] = key.split(':').map(Number)
1687
+ await handleTaskSignal('task:assigned', { task: { id, project_id: projectId } })
1688
+ }
1689
+ } catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
1690
+ finally { authorizationProbeBusy = false }
1691
+ }
1692
+ const authorizationTimer = setInterval(() => void resumeReadyAuthorizations(), 2_000)
1693
+ authorizationTimer.unref?.()
1916
1694
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1917
1695
  if (mcpConfig || mcpUrl) {
1918
1696
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
@@ -1942,6 +1720,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1942
1720
  const bye = async () => {
1943
1721
  if (stopping) return
1944
1722
  stopping = true
1723
+ clearInterval(authorizationTimer)
1945
1724
  if (introTimer) clearTimeout(introTimer)
1946
1725
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1947
1726
  if (taskProbeTimer) clearInterval(taskProbeTimer)
@@ -1952,8 +1731,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1952
1731
  void item.control.runner?.cancelCurrent?.()
1953
1732
  })
1954
1733
  }
1955
- await Promise.all([...replyRunners.values(), ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
1956
- for (const runner of replyRunners.values()) runner.close?.()
1734
+ await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
1735
+ workRunners.close()
1736
+ replyRunners.close()
1957
1737
  process.exit(0)
1958
1738
  }
1959
1739
  process.on('SIGTERM', bye)