openvisio-agent 0.19.10 → 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,18 @@ 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'
21
23
  import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
22
24
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
23
25
  import { createMastraAcpRunner } from './mastra-harness.mjs'
@@ -61,6 +63,7 @@ const REPLY_DISCIPLINE = [
61
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.',
62
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.',
63
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.',
64
67
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
65
68
  ].join('\n')
66
69
 
@@ -102,7 +105,7 @@ const CODE_CHARTER = [
102
105
  '',
103
106
  'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
104
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.',
105
- ' 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.',
106
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.',
107
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.',
108
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.',
@@ -122,7 +125,7 @@ const CODE_FULL = [
122
125
  ' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
123
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.',
124
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.',
125
- ' 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.',
126
129
  'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
127
130
  ].join('\n')
128
131
 
@@ -140,7 +143,7 @@ const CODE_FAST = [
140
143
  // missed — TASKS especially.
141
144
  const INTRO = [
142
145
  'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
143
- '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.',
144
147
  'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
145
148
  ].join('\n')
146
149
  const SWEEP = [
@@ -260,6 +263,7 @@ export async function runWatch({ flags }) {
260
263
  : flags.workdir ? String(flags.workdir)
261
264
  : flags.workspace ? String(flags.workspace) : ''
262
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 })
263
267
  if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
264
268
 
265
269
  // Use a specific Codex ID; configured chat and code tiers remain independent.
@@ -313,303 +317,15 @@ export async function runWatch({ flags }) {
313
317
  return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
314
318
  }
315
319
 
316
- // ── opencode cycle runner ─────────────────────────────────────────────────────
317
- // opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
318
- // so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
319
- // The openvisio-team MCP is declared in a private per-agent config. The actual
320
- // code workspace is passed through --dir, so two agents never overwrite each
321
- // other's MCP identity even when they work in the same repository.
322
- // `--auto` approves tool use non-interactively.
323
- // Same { runCycle, canCode } contract as the Claude runner.
324
- function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
325
- const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
326
- const bin = onPath('opencode') || 'opencode'
327
- const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
328
- const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode })
329
- let configured = false
330
- let cancelActive = null
331
- const ensureConfig = () => {
332
- if (configured) return
333
- configured = true
334
- try {
335
- mkdirSync(configDir, { recursive: true })
336
- mkdirSync(workspace, { recursive: true })
337
- if (opencodeConfig) {
338
- writeJson(opencodeConfigPath, opencodeConfig, true)
339
- } else {
340
- log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
341
- }
342
- } catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
343
- }
344
-
345
- function runCycle(prompt, cycleModel) {
346
- return new Promise((resolve) => {
347
- ensureConfig()
348
- const m = cycleModel || model
349
- // opencode has no system-prompt flag; each run is a fresh process, so fold the
350
- // charter/creds into the message (still not re-accumulated across cycles).
351
- const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
352
- // JSON mode is the evidence boundary. Formatted stdout only tells us that
353
- // OpenCode exited; raw events tell us which tools actually completed.
354
- const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
355
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
356
- let terminationResult = null
357
- let cancel = null
358
- let outputText = '', jsonlBuffer = ''
359
- const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
360
- const finish = (o) => {
361
- if (done) return
362
- done = true
363
- if (cancelActive === cancel) cancelActive = null
364
- clearTimeout(timer)
365
- const calls = [...mcpCalls]
366
- log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
367
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
368
- }
369
- const terminate = (subtype) => {
370
- terminationResult ||= { type: 'result', subtype }
371
- return stopModelProcess(child).then(() => finish(terminationResult))
372
- }
373
- const inspectLine = (line) => {
374
- const value = String(line || '').trim()
375
- if (!value) return
376
- let event
377
- try { event = JSON.parse(value) } catch {
378
- if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
379
- return
380
- }
381
- const evidence = opencodeEventEvidence(event)
382
- if (evidence.outputText) {
383
- outputText += ' ' + evidence.outputText
384
- if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
385
- }
386
- if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
387
- if (!evidence.tool) return
388
- if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
389
- if (evidence.failed && evidence.toolError) {
390
- const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
391
- log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
392
- }
393
- try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
394
- if (evidence.mcpTool) {
395
- mcpCalls.add(evidence.mcpTool)
396
- if (evidence.failed) mcpErrors.add(evidence.mcpTool)
397
- else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
398
- }
399
- const repositoryWasChanged = didRepoMutation
400
- if (evidence.didChannelMessage && repositoryWasChanged) didResultMessage = true
401
- didCode ||= !!evidence.didCode
402
- didRepoMutation ||= !!evidence.didRepoMutation
403
- didMessage ||= !!evidence.didMessage
404
- didChannelMessage ||= !!evidence.didChannelMessage
405
- didMcpTaskRead ||= !!evidence.didMcpTaskRead
406
- didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
407
- }
408
- const timer = setTimeout(() => {
409
- log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
410
- void terminate('timeout')
411
- }, maxCycleMs)
412
- cancel = () => terminate('canceled')
413
- cancelActive = cancel
414
- log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
415
- try {
416
- child = spawn(bin, args, {
417
- ...modelProcessOptions,
418
- cwd: configDir,
419
- env: {
420
- ...process.env,
421
- OPENCODE_CONFIG: opencodeConfigPath,
422
- // Inline config has higher precedence than a project opencode.json.
423
- // This prevents a stale generated workspace file from replacing this
424
- // agent's URL or credentials while keeping normal project settings.
425
- ...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
426
- },
427
- stdio: ['ignore', 'pipe', 'inherit'],
428
- })
429
- child.stdout?.on('data', (data) => {
430
- jsonlBuffer += String(data)
431
- const lines = jsonlBuffer.split('\n')
432
- jsonlBuffer = lines.pop() ?? ''
433
- for (const line of lines) inspectLine(line)
434
- })
435
- }
436
- 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' }) }
437
- child.on('close', (code) => {
438
- inspectLine(jsonlBuffer); jsonlBuffer = ''
439
- const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
440
- log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
441
- finish(terminationResult || { type: 'result', subtype })
442
- })
443
- child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
444
- })
445
- }
446
-
447
- log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
448
- return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
449
- }
450
-
451
- // Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
452
- // for each event and inject only this agent's OpenVisio MCP config on the command
453
- // line. That avoids inheriting unrelated user MCP servers while keeping the
454
- // user's normal Codex authentication. We deliberately never use Codex's dangerous
455
- // approval/sandbox bypass flag.
456
- function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
457
- const bin = onPath('codex') || 'codex'
458
- const defaultCwd = workdir || OV_DIR
459
- const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
460
- let cancelActive = null
461
-
462
- function runCycle(prompt, cycleModel, cycleOptions = {}) {
463
- return new Promise((resolve) => {
464
- const m = cycleModel || model
465
- const cycleCwd = cycleOptions.workdir || defaultCwd
466
- const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
467
- const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
468
- const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
469
- const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
470
- ...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
471
- ...(m ? ['--model', m] : []),
472
- ...(mcpOverride ? ['-c', mcpOverride] : []),
473
- full]
474
- let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
475
- let terminationResult = null
476
- let cancel = null
477
- let policyBlock = null
478
- const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
479
- let didMcpTaskRead = false, didMcpTaskUpdate = false
480
- const finish = (o) => {
481
- if (done) return
482
- done = true
483
- if (cancelActive === cancel) cancelActive = null
484
- clearTimeout(timer)
485
- const calls = [...mcpCalls]
486
- const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
487
- log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
488
- resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
489
- }
490
- const terminate = (subtype) => {
491
- terminationResult ||= { type: 'result', subtype }
492
- return stopModelProcess(child).then(() => finish(terminationResult))
493
- }
494
- const inspectDiagnostic = (value) => {
495
- const s = String(value || '')
496
- stderrBuffer = (stderrBuffer + s).slice(-24_000)
497
- policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
498
- }
499
- const forwardDiagnostic = (value, flush = false) => {
500
- const incoming = String(value || '')
501
- inspectDiagnostic(incoming)
502
- stderrLineBuffer += incoming
503
- const lines = stderrLineBuffer.split('\n')
504
- stderrLineBuffer = lines.pop() ?? ''
505
- for (const line of lines) {
506
- if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
507
- }
508
- if (flush && stderrLineBuffer) {
509
- if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
510
- stderrLineBuffer = ''
511
- }
512
- }
513
- const inspectLine = (line) => {
514
- const s = line.trim()
515
- if (!s) return
516
- policyBlock = codexPolicyBlock(s) || policyBlock
517
- try {
518
- const event = JSON.parse(s)
519
- const evidence = codexEventEvidence(event)
520
- if (evidence.outputText) outputText += ' ' + evidence.outputText
521
- if (evidence.mcpTool) {
522
- mcpCalls.add(evidence.mcpTool)
523
- try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
524
- if (evidence.failed) {
525
- mcpErrors.add(evidence.mcpTool)
526
- if (evidence.toolError) {
527
- const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
528
- const detail = String(evidence.toolError)
529
- const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
530
- mcpErrorDetails.set(evidence.mcpTool, safe)
531
- }
532
- } else if (evidence.completed) {
533
- mcpErrors.delete(evidence.mcpTool)
534
- mcpErrorDetails.delete(evidence.mcpTool)
535
- }
536
- }
537
- if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
538
- didCode ||= !!evidence.didCode
539
- didRepoMutation ||= !!evidence.didRepoMutation
540
- didMessage ||= !!evidence.didMessage
541
- didChannelMessage ||= !!evidence.didChannelMessage
542
- didMcpTaskRead ||= !!evidence.didMcpTaskRead
543
- didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
544
- } catch { /* non-JSON diagnostic */ }
545
- if (debug) log(' · ' + s.slice(0, 220))
546
- }
547
- const timer = setTimeout(() => {
548
- log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
549
- void terminate('timeout')
550
- }, maxCycleMs)
551
- cancel = () => terminate('canceled')
552
- cancelActive = cancel
553
- log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
554
- try {
555
- // Always inspect Codex JSONL so a successful process exit cannot be
556
- // mistaken for completed work. Keep it out of normal logs unless debug.
557
- child = spawn(bin, args, {
558
- ...modelProcessOptions,
559
- cwd: cycleCwd,
560
- env: {
561
- ...process.env,
562
- ...(mcpUrl ? {
563
- OPENVISIO_CODEX_MCP_URL: mcpUrl,
564
- OPENVISIO_CODEX_API_KEY: mcpHeaders?.['x-agent-api-key'] || '',
565
- OPENVISIO_CODEX_IDENTIFIER: mcpHeaders?.['x-agent-identifier'] || '',
566
- } : {}),
567
- },
568
- stdio: ['ignore', 'pipe', 'pipe'],
569
- })
570
- if (child.stdout) child.stdout.on('data', (d) => {
571
- jsonlBuffer += String(d)
572
- const lines = jsonlBuffer.split('\n')
573
- jsonlBuffer = lines.pop() ?? ''
574
- for (const line of lines) inspectLine(line)
575
- })
576
- if (child.stderr) child.stderr.on('data', (d) => {
577
- forwardDiagnostic(d)
578
- })
579
- } catch (e) {
580
- log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
581
- return finish({ type: 'result', subtype: 'spawn-failed' })
582
- }
583
- child.on('close', (code) => {
584
- inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
585
- const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
586
- log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
587
- finish(terminationResult || { type: 'result', subtype })
588
- })
589
- child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
590
- })
591
- }
592
-
593
- if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
594
- log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
595
- return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
596
- }
597
-
598
- // ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
599
- // One persistent stream-json session, poked with a prompt per cycle. Recycled
600
- // after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
601
- 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 }) {
602
322
  const canCode = !!workdir
603
323
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
604
324
  // Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
605
325
  // its own ACP session and worktree, so cancellation, permissions and context
606
326
  // cannot bleed across concurrently assigned tickets.
607
- 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 })
608
328
  if (mastraRunner) return mastraRunner
609
- // opencode drives cycles differently — a headless `opencode run` per cycle rather
610
- // than a persistent stream-json session. Same { runCycle, canCode } contract.
611
- if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
612
- if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
613
329
  let child = null
614
330
  // The model the CURRENT session was spawned with. runCycle can pass a different
615
331
  // model per cycle (cheap for chat, stronger for code) — a change recycles the
@@ -779,7 +495,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
779
495
  child = null
780
496
  return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
781
497
  }
782
- return { runCycle, canCode, cancelCurrent }
498
+ return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
783
499
  }
784
500
 
785
501
  // ── the backend WS loop ──────────────────────────────────────────────────────
@@ -799,12 +515,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
799
515
  const canCode = !!workdir
800
516
  // The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
801
517
  // agent_identifier + agent_api_key as arguments. Hand them over up front.
802
- const authRule = agent === 'codex'
518
+ const authRule = agent !== 'claude'
803
519
  ? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
804
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.`
805
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.`
806
- // The STATIC charter + creds are the session system prompt (cached, billed once),
807
- // 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.
808
524
  const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
809
525
  const runnerOptions = {
810
526
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
@@ -820,30 +536,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
820
536
  ...(workerWorkdir ? { workdir: workerWorkdir } : {}),
821
537
  onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
822
538
  })
823
- const replyRunners = new Map()
824
- const replyRunnerFor = (delivery) => {
825
- const key = delivery ? `${delivery.channelId ?? '?'}:${delivery.parentId ?? 'root'}` : 'global'
826
- let runner = replyRunners.get(key)
827
- if (!runner) {
828
- runner = createCycleRunner({
829
- ...runnerOptions,
830
- workdir: '',
831
- systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
832
- cfgKey: identifier + '-reply-' + slugify(key),
833
- onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
834
- })
835
- } else replyRunners.delete(key)
836
- replyRunners.set(key, runner)
837
- while (replyRunners.size > 8) {
838
- const oldestKey = replyRunners.keys().next().value
839
- const oldest = replyRunners.get(oldestKey)
840
- replyRunners.delete(oldestKey)
841
- oldest?.close?.()
842
- }
843
- return runner
844
- }
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
+ })
845
552
  const codexPushGuide = agent === 'codex' && canCode
846
- ? '\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.'
847
554
  : ''
848
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.'
849
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
@@ -861,11 +568,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
861
568
  concurrency: MAX_CONCURRENT_WORKERS,
862
569
  groupKey: (item) => item.workdir || workdir || 'shared-workspace',
863
570
  run: async (item) => {
864
- 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 })
865
573
  item.control.runner = runner
866
574
  activeWorkRunners.add(runner)
867
575
  try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
868
- finally { runner.close?.(); activeWorkRunners.delete(runner); item.control.runner = null }
576
+ finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
869
577
  },
870
578
  onError: (error, item) => {
871
579
  pauseFailedTask(item.taskRef)
@@ -874,11 +582,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
874
582
  },
875
583
  }),
876
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}`,
877
587
  run: async (item) => {
878
- 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)
879
591
  item.control.runner = runner
880
592
  try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
881
- finally { item.control.runner = null }
593
+ finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
882
594
  },
883
595
  onError: (error, item) => {
884
596
  pauseFailedTask(item.taskRef)
@@ -958,7 +670,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
958
670
  trimMap(failedTaskVersions)
959
671
  seenTasks.add(key)
960
672
  trimSeen(seenTasks)
961
- lastTaskSignature = ''
962
673
  persistReplay()
963
674
  }
964
675
  const finalizeFailedTaskPause = async (taskRef) => {
@@ -990,6 +701,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
990
701
  persistReplay()
991
702
  return false
992
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
+ }
993
712
  const markMentionHandled = (message, channelId) => {
994
713
  pruneMentionSignatures()
995
714
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
@@ -1005,8 +724,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1005
724
  // hoping poll_inbox re-surfaces the same item. Each queue entry retains that
1006
725
  // source independently through work, recovery, and delivery.
1007
726
  let backlogProbeBusy = false
1008
- let lastTaskSignature = ''
1009
- let lastTaskTriggeredAt = 0
1010
727
  let lastInboxSignature = ''
1011
728
  let selfAgentId = null
1012
729
  let completionRecipientName = ''
@@ -1067,6 +784,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1067
784
  try { return JSON.parse(text) } catch { return { text } }
1068
785
  }
1069
786
 
787
+ const taskTypeSets = createTaskTypeLookup({ load: (projectId) => callMcpTool('list_task_types', { project_id: projectId }).then(toolData) })
788
+
1070
789
  const callMcpReadWithRetry = async (name, args = {}) => {
1071
790
  try { return await callMcpTool(name, args) }
1072
791
  catch (first) {
@@ -1084,19 +803,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1084
803
  }
1085
804
  const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
1086
805
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1087
- 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) => {
1088
807
  const [tasksResult, typesResult] = await Promise.all([
1089
808
  callMcpReadWithRetry('list_tasks', { project_id: project.id }),
1090
- callMcpReadWithRetry('list_task_types', { project_id: project.id }).catch((error) => {
1091
- log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
1092
- return null
1093
- }),
809
+ taskTypeSets(project.id),
1094
810
  ])
1095
811
  const tasksData = toolData(tasksResult)
1096
- const typesData = typesResult ? toolData(typesResult) : {}
1097
- const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1098
- 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)))
1099
- const reviewIds = new Set(types.filter((type) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
812
+ const { done: doneIds, review: reviewIds } = typesResult
1100
813
  return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
1101
814
  const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
1102
815
  const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
@@ -1111,7 +824,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1111
824
  state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
1112
825
  project: String(project.name || ''),
1113
826
  }))
1114
- }))
827
+ })
1115
828
  return groups.flat()
1116
829
  }
1117
830
 
@@ -1163,8 +876,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1163
876
  const controlKey = threadControlKey(channelId, parentId)
1164
877
  if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
1165
878
  }
1166
- 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 }) => {
1167
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
+ }
1168
886
  const message = String(content || '').trim()
1169
887
  if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
1170
888
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
@@ -1182,7 +900,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1182
900
  const run = (async () => {
1183
901
  if (parentId != null) {
1184
902
  const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1185
- const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
903
+ const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
1186
904
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
1187
905
  if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
1188
906
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
@@ -1215,24 +933,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1215
933
  }
1216
934
 
1217
935
  const statusChannelCache = new Map()
936
+ let dedicatedChannelRead = null
1218
937
  const projectStatusChannel = async (projectId) => {
1219
- const key = Number(projectId)
1220
- const cached = statusChannelCache.get(key)
938
+ const cached = statusChannelCache.get('self')
1221
939
  if (cached && cached.expiresAt > Date.now()) return cached.channelId
1222
- try {
1223
- const data = toolData(await callMcpTool('list_channels', { project_id: key }))
1224
- 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 : []
1225
- const usable = channels.filter((c) => Number.isFinite(Number(c.id)))
1226
- const preferred = usable.find((c) => /^(?:general|team|project|dev|development)$/i.test(String(c.name || '').trim())) || usable[0]
1227
- const channelId = preferred ? Number(preferred.id) : null
1228
- statusChannelCache.set(key, { channelId, expiresAt: Date.now() + 5 * 60_000 })
1229
- if (channelId == null) log('no channel available for project ' + key + '; task activity cannot be displayed')
1230
- return channelId
1231
- } catch (e) {
1232
- statusChannelCache.set(key, { channelId: null, expiresAt: Date.now() + 30_000 })
1233
- log('cannot resolve activity channel for project ' + key + ': ' + (e?.message || e))
1234
- return null
1235
- }
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
1236
960
  }
1237
961
 
1238
962
  const announceIntroduction = async () => {
@@ -1241,7 +965,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1241
965
  const project = projects.find((item) => Number.isFinite(Number(item.id)))
1242
966
  if (!project) { log('no project available for first-connection introduction'); return false }
1243
967
  const channelId = await projectStatusChannel(project.id)
1244
- if (!Number.isFinite(Number(channelId))) return false
968
+ if (channelId == null || !Number.isFinite(Number(channelId))) return false
1245
969
  await postMessageOnce({
1246
970
  key: `intro:${identifier}:${project.id}`,
1247
971
  projectId: Number(project.id),
@@ -1276,9 +1000,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1276
1000
  try {
1277
1001
  const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
1278
1002
  if (comment.called) log('posted verified ticket comment for #' + ticketId)
1279
- 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')
1280
1004
  } catch (e) {
1281
- 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')
1282
1006
  }
1283
1007
  reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
1284
1008
  }
@@ -1296,8 +1020,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1296
1020
  log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
1297
1021
  return true
1298
1022
  }
1299
- const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
1300
- if (!Number.isFinite(channelId)) return false
1023
+ const channelId = await projectStatusChannel(projectId)
1024
+ if (channelId == null || !Number.isFinite(channelId)) return false
1301
1025
  await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
1302
1026
  reportedCompletions.add(report.key); trimSeen(reportedCompletions)
1303
1027
  pendingCompletionReports.delete(taskKey); persistReplay()
@@ -1379,7 +1103,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1379
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}.”`
1380
1104
  : 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
1381
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.`
1382
- 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.`
1383
1107
  return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
1384
1108
  }
1385
1109
 
@@ -1396,122 +1120,103 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1396
1120
  rememberSelfAgent(self)
1397
1121
  const projectsData = toolData(await callMcpTool('list_projects'))
1398
1122
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1399
- const assigned = []
1400
1123
  const mentionActivity = []
1401
1124
  let activityReplayTouched = false
1402
- for (const project of projects) {
1403
- const [tasksData, typesData, activityData] = await Promise.all([
1404
- callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
1405
- callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
1406
- // Chat history is optional for assignment pickup. An unavailable
1407
- // activity feed must not strand otherwise verified pending tickets.
1408
- callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
1409
- log('activity reconciliation unavailable: ' + (error?.message || error))
1410
- return {}
1411
- }),
1412
- ])
1413
- const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
1414
- 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)))
1415
- 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)))
1416
- for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
1417
- const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
1418
- const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
1419
- if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
1420
- const taskKey = `${project.id}:${task.id}`
1421
- if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
1422
- memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
1423
- if (pendingCompletionReports.has(taskKey)) {
1424
- const activityChannel = await projectStatusChannel(project.id)
1425
- try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
1426
- 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
1427
1152
  }
1428
- if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
1429
- clearFailedTask(taskKey)
1430
- // Release the in-flight de-dupe key at handoff. If a reviewer moves
1431
- // the ticket back to an actionable column, that update must start a
1432
- // fresh work cycle.
1433
- seenTasks.delete(taskKey)
1434
- continue
1435
- }
1436
- if (failedTaskIsPaused(taskKey, task)) {
1437
- seenTasks.add(taskKey)
1438
- log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
1439
- continue
1440
- }
1441
- if (blockedTasks.has(taskKey)) {
1442
- const blockedRepo = blockedTaskRepos.get(taskKey)
1443
- const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1444
- if (helperAuthorized) {
1445
- blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
1446
- log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
1447
- } else continue
1448
- }
1449
- 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 } })
1450
- 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 })
1451
- }
1452
- const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
1453
- const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
1454
- for (const item of activities) {
1455
- const text = JSON.stringify(item)
1456
- const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
1457
- const data = item?.data && typeof item.data === 'object' ? item.data : null
1458
- const activityMessage = item?.message && typeof item.message === 'object'
1459
- ? item.message
1460
- : data?.message && typeof data.message === 'object' ? data.message : item
1461
- const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
1462
- const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
1463
- const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
1464
- const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
1465
- const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
1466
- const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
1467
- const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
1468
- // Search only the source message, never the whole activity envelope: an
1469
- // old tagged thread root can otherwise make another agent's new reply
1470
- // look addressed to this watcher. Explicit mentions and human follow-ups
1471
- // in a persistently owned thread both enter the guarded per-message path.
1472
- const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
1473
- if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
1474
- seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
1475
- // The same logical mention may already have arrived over WebSocket.
1476
- // Share the id/signature guard instead of starting a second model turn.
1477
- // Recover the former pre-routing acknowledgement bug exactly once
1478
- // when an owned-thread source has no received ledger entry.
1479
- if (markMentionHandled(activityMessage, activityChannelId)) {
1480
- const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
1481
- const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
1482
- 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
1483
1158
  }
1484
- mentionActivity.push({
1485
- projectId: project.id,
1486
- project: project.name,
1487
- channelId: activityChannelId,
1488
- message: activityMessage,
1489
- activity: item,
1490
- })
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 })
1491
1162
  }
1492
- }
1493
- }
1494
- if (activityReplayTouched) persistReplay()
1495
- if (!assigned.length) lastTaskSignature = ''
1496
- else {
1497
- const signature = JSON.stringify(assigned)
1498
- const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
1499
- if (signature !== lastTaskSignature || retryDue) {
1500
- lastTaskSignature = signature
1501
- lastTaskTriggeredAt = Date.now()
1502
1163
  const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
1503
- 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 || '')))
1504
- log('backlog reconciliation found ' + assigned.length + ' assigned task(s); verifying independent queue entries')
1505
- 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) {
1506
1166
  const key = `${next.projectId}:${next.id}`
1507
1167
  if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
1508
- if (retryDue) seenTasks.delete(key)
1509
- // Share verification, ownership, dedupe, and chat-only routing with
1510
- // live events. Every ticket keeps its own context and result.
1168
+ // Live ownership/state verification remains inside handleTaskSignal.
1169
+ seenTasks.delete(key)
1511
1170
  await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
1512
1171
  }
1513
- }
1514
- }
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()
1515
1220
  const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
1516
1221
  if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
1517
1222
  lastInboxSignature = inboxSignature
@@ -1549,20 +1254,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1549
1254
  }
1550
1255
  // The next task update or periodic reconciliation must be allowed to queue
1551
1256
  // this work again. An acknowledgement is not a terminal task signature.
1552
- lastTaskSignature = ''
1553
1257
  }
1554
1258
 
1555
1259
  function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
1556
1260
  const laneName = kind === 'full' ? 'work' : 'reply'
1557
1261
  const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
1558
1262
  const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
1559
- const control = { cancelled: false, runner: null, statusTargets: new Set() }
1263
+ const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
1560
1264
  return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
1561
1265
  }
1562
1266
 
1563
1267
  async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
1564
1268
  const laneName = kind === 'full' ? 'work' : 'reply'
1565
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}`)
1566
1272
  const ctx = context ? [context] : []
1567
1273
  const activeTaskRef = taskRef
1568
1274
  const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
@@ -1595,18 +1301,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1595
1301
  if (activeTaskRef) {
1596
1302
  const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
1597
1303
  const ticket = data.ticket ?? data.task ?? data
1304
+ const typeSets = await taskTypeSets(activeTaskRef.projectId)
1598
1305
  const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1599
1306
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1600
1307
  if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
1601
1308
  !((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
1602
- taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1309
+ taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1603
1310
  releaseTaskForRetry(activeTaskRef, prompt)
1604
1311
  log('queued ticket no longer actionable; skipped before model start')
1605
1312
  return
1606
1313
  }
1607
1314
  }
1608
1315
  if (cycleControl.cancelled) return
1316
+ log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1609
1317
  const result = await runner.runCycle(prompt, useModel, runnerOptions)
1318
+ if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
1610
1319
  if (result?.model && result.model !== useModel) {
1611
1320
  // Persist only a successfully selected model and only if the user has
1612
1321
  // not changed that tier while this cycle was running.
@@ -1755,6 +1464,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1755
1464
  }
1756
1465
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1757
1466
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
1467
+ const typeSets = await taskTypeSets(projectId)
1758
1468
  const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
1759
1469
  const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
1760
1470
  const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
@@ -1769,7 +1479,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1769
1479
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1770
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
1771
1481
  }
1772
- if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1482
+ if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
1773
1483
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
1774
1484
  if (pendingCompletionReports.has(key)) {
1775
1485
  const activityChannel = await projectStatusChannel(projectId)
@@ -1780,6 +1490,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1780
1490
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1781
1491
  return
1782
1492
  }
1493
+ resumeAuthorized(key)
1783
1494
  const hadFailedRevision = failedTaskVersions.has(key)
1784
1495
  if (failedTaskIsPaused(key, ticket)) {
1785
1496
  seenTasks.add(key)
@@ -1787,17 +1498,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1787
1498
  return
1788
1499
  }
1789
1500
  if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
1790
- if (blockedTasks.has(key)) {
1791
- const blockedRepo = blockedTaskRepos.get(key)
1792
- const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
1793
- if (helperAuthorized) {
1794
- blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
1795
- log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
1796
- } else {
1797
- log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
1798
- return
1799
- }
1800
- }
1501
+ if (blockedTasks.has(key)) { log(kind + ' ticket authorization is still unavailable'); return }
1801
1502
  if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
1802
1503
  seenTasks.add(key); trimSeen(seenTasks)
1803
1504
  const title = String(ticket.title || hinted.title || '')
@@ -1812,7 +1513,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1812
1513
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1813
1514
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1814
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.` : ''
1815
- 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 })
1816
1517
  } catch (e) {
1817
1518
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1818
1519
  }
@@ -1872,7 +1573,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1872
1573
  // redirect can cancel its process. Codex additionally delegates the final
1873
1574
  // post to the watcher for an authoritative last-moment thread check.
1874
1575
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
1875
- ? { 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' }
1876
1577
  : null
1877
1578
  // Under-the-hood model control from chat (view / switch the model the agent runs).
1878
1579
  const mcmd = cid != null ? parseModelCmd(text) : null
@@ -1957,6 +1658,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1957
1658
  log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
1958
1659
  handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
1959
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?.()
1960
1694
  let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
1961
1695
  if (mcpConfig || mcpUrl) {
1962
1696
  // Workspace ethics: a one-time hello the FIRST time this agent ever connects.
@@ -1986,6 +1720,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1986
1720
  const bye = async () => {
1987
1721
  if (stopping) return
1988
1722
  stopping = true
1723
+ clearInterval(authorizationTimer)
1989
1724
  if (introTimer) clearTimeout(introTimer)
1990
1725
  if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
1991
1726
  if (taskProbeTimer) clearInterval(taskProbeTimer)
@@ -1996,8 +1731,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1996
1731
  void item.control.runner?.cancelCurrent?.()
1997
1732
  })
1998
1733
  }
1999
- await Promise.all([...replyRunners.values(), ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
2000
- for (const runner of replyRunners.values()) runner.close?.()
1734
+ await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
1735
+ workRunners.close()
1736
+ replyRunners.close()
2001
1737
  process.exit(0)
2002
1738
  }
2003
1739
  process.on('SIGTERM', bye)