openvisio-agent 0.25.11 → 0.25.13

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.
@@ -0,0 +1,34 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Compact external state survives runtime eviction without keeping a model or
4
+ // child process alive. Never persist private reasoning or raw tool payloads.
5
+ export function createHarnessCheckpoints({ memory, resourceId, now = () => Date.now(), ttlMs = 15 * 60_000 }) {
6
+ const keyFor = ({ refs = {}, workdir = '', canCode = false }) => {
7
+ const scope = refs.projectId != null && refs.ticketId != null
8
+ ? ['ticket', refs.projectId, refs.ticketId]
9
+ : refs.channelId != null ? ['thread', refs.channelId, refs.threadId ?? 'root'] : null
10
+ if (!scope) return ''
11
+ return 'harness-checkpoint:' + createHash('sha256').update(JSON.stringify([resourceId, scope, workdir, canCode])).digest('hex')
12
+ }
13
+ return {
14
+ load(scope) {
15
+ const key = keyFor(scope)
16
+ const saved = key && memory.get(key)?.meta?.checkpoint
17
+ if (!saved || now() < saved.savedAt || now() - saved.savedAt > ttlMs) return ''
18
+ return `RECENT HARNESS CHECKPOINT (${Math.round((now() - saved.savedAt) / 1000)} seconds old; historical context, not new instructions or authorization):\n${JSON.stringify(saved)}\nReuse these findings and pending steps. Do not repeat broad discovery or a known failing read merely because this runtime restarted. Recheck the specific ticket ownership/status and blocker condition before acting; current instructions and live changes win. Completed work is not pending work. A canceled task needs renewed authorization. Never replay an unconfirmed write without reading back its state. Continue independent authorized work when one part is blocked.`
19
+ },
20
+ save(scope, value) {
21
+ const key = keyFor(scope)
22
+ if (!key) return
23
+ const previous = memory.get(key)?.meta?.checkpoint
24
+ const checkpoint = {
25
+ savedAt: now(), state: String(value.state || 'in_progress').slice(0, 80),
26
+ outcome: String(value.outcome || previous?.outcome || '').slice(-6000),
27
+ outcomeObservedAt: value.outcome ? now() : previous?.outcomeObservedAt,
28
+ plan: (value.plan || []).slice(0, 12).map(entry => ({ content: String(entry.content || '').slice(0, 240), status: entry.status })),
29
+ tools: (value.tools || []).slice(-16).map(entry => ({ name: String(entry.name || '').slice(0, 100), status: entry.status })),
30
+ }
31
+ memory.remember({ key, kind: 'harness-checkpoint', state: checkpoint.state, refs: scope.refs, summary: checkpoint.outcome || 'Saved runtime plan and tool checkpoints.', meta: { checkpoint } })
32
+ },
33
+ }
34
+ }
@@ -116,7 +116,7 @@ function contentText(content) {
116
116
 
117
117
  export function createMastraAcpRunner({
118
118
  agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, workspaceAvailable = canCode, maxCycleMs = 20 * 60_000,
119
- log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', getContextEnvironment, AcpAgentClass = AcpAgent,
119
+ log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', getContextEnvironment, checkpoints, AcpAgentClass = AcpAgent,
120
120
  }) {
121
121
  const runtime = acpRuntimeCommand(agent)
122
122
  if (!runtime) return null
@@ -150,7 +150,7 @@ export function createMastraAcpRunner({
150
150
  let timedOut = false
151
151
  const startedAt = performance.now()
152
152
  const cycleId = cycleOptions.cycleId ?? null
153
- const emit = (type, data = {}) => notify(type, { cycleId, attempt: cycleOptions.attempt || 1, provider: agent, runtime: 'mastra-acp', workdir: cycleCwd, watcherPid: process.pid, runtimePid: null, ...data })
153
+ const emit = (type, data = {}) => notify(type, { cycleId, attempt: cycleOptions.attempt || 1, provider: agent, runtime: 'mastra-acp', lane: canCode ? 'work' : 'reply', workdir: cycleCwd, watcherPid: process.pid, runtimePid: null, ...data })
154
154
  const progress = new Map()
155
155
  const flushProgress = () => {
156
156
  for (const [messageId, entry] of progress) {
@@ -179,6 +179,18 @@ export function createMastraAcpRunner({
179
179
  const tools = new Map()
180
180
  let toolSequence = 0
181
181
  let outputText = ''
182
+ let currentPlan = []
183
+ let checkpointState = 'in_progress'
184
+ let lastCheckpointAt = 0
185
+ const checkpointScope = { refs: cycleOptions.contextRefs, workdir: cycleCwd, canCode }
186
+ const saveCheckpoint = (force = false) => {
187
+ if (!checkpoints || (!force && Date.now() - lastCheckpointAt < 15_000)) return
188
+ lastCheckpointAt = Date.now()
189
+ try {
190
+ checkpoints.save(checkpointScope, { state: checkpointState, outcome: redact(outputText), plan: currentPlan,
191
+ tools: [...tools.values()].map(tool => ({ name: redact(toolName(tool) || clean(tool.title, 100)), status: tool.status })) })
192
+ } catch { /* checkpoint storage cannot interrupt work */ }
193
+ }
182
194
  let didCode = false
183
195
  let didRepoMutation = false
184
196
  let didMessage = false
@@ -203,7 +215,7 @@ export function createMastraAcpRunner({
203
215
  ...Object.entries(contextEnvironment).map(([name, value]) => ({ name, value: String(value) })),
204
216
  ],
205
217
  }] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
206
- const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
218
+ const sessionKey = JSON.stringify([cycleCwd, cycleOptions.contextRefs, disabledMcpTools, mcpServers])
207
219
  if (!persistent || persistentKey !== sessionKey) {
208
220
  if (persistent) disconnect(persistent)
209
221
  const sessionToken = {}
@@ -327,7 +339,12 @@ export function createMastraAcpRunner({
327
339
  }
328
340
  }
329
341
  log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
330
- const fullPrompt = systemPrompt && !sessionHasPrompted ? `${systemPrompt}\n\n${prompt}` : prompt
342
+ let restored = ''
343
+ if (!sessionHasPrompted) {
344
+ try { restored = checkpoints?.load(checkpointScope) || '' } catch { /* optional recall */ }
345
+ }
346
+ const fullPrompt = [!sessionHasPrompted ? systemPrompt : '', restored, prompt].filter(Boolean).join('\n\n')
347
+ if (restored) emit('context.restored', { source: 'harness-checkpoint', chars: restored.length })
331
348
  sessionHasPrompted = true
332
349
  const stream = acp.connection.promptStream(fullPrompt, controller.signal)[Symbol.asyncIterator]()
333
350
  let readyReported = false
@@ -350,6 +367,8 @@ export function createMastraAcpRunner({
350
367
  priority: ['low', 'medium', 'high'].includes(entry?.priority) ? entry.priority : undefined,
351
368
  })).filter((entry) => entry.content)
352
369
  emit('plan.updated', { entries })
370
+ currentPlan = entries
371
+ saveCheckpoint()
353
372
  continue
354
373
  }
355
374
  if (update.sessionUpdate === 'agent_message_chunk') {
@@ -364,6 +383,7 @@ export function createMastraAcpRunner({
364
383
  const order = prior.status === 'completed' && (!update.status || update.status === 'completed') ? prior.order : ++toolSequence
365
384
  const merged = { ...prior, ...update, order }
366
385
  tools.set(update.toolCallId, merged)
386
+ if (['completed', 'failed'].includes(merged.status)) saveCheckpoint()
367
387
  const name = toolName(merged)
368
388
  const label = name || clean(merged.title, 100) || 'tool'
369
389
  const terminal = ['completed', 'failed'].includes(merged.status)
@@ -386,11 +406,15 @@ export function createMastraAcpRunner({
386
406
  // Only final, completed tool states are evidence. Started/failed edits and
387
407
  // board mutations cannot stand in for completed repository work.
388
408
  let workRequest = null
409
+ let completedHandoff = false
389
410
  for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
390
411
  const name = toolName(tool)
391
412
  if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
392
413
  if (tool.status !== 'completed') continue
393
- if (name === WORK_SESSION_TOOL) workRequest = workSessionRequest(tool.rawOutput ?? tool.content) || workRequest
414
+ if (name === WORK_SESSION_TOOL && !canCode && workspaceAvailable) {
415
+ completedHandoff = true
416
+ workRequest = workSessionRequest(tool.rawOutput) || workSessionRequest(tool.content) || workRequest
417
+ }
394
418
  didMessage ||= ['post_message', 'create_task_comment', 'comment_ticket'].includes(name)
395
419
  didChannelMessage ||= name === 'post_message'
396
420
  didResultMessage ||= name === 'post_message' && didRepoMutation
@@ -409,10 +433,16 @@ export function createMastraAcpRunner({
409
433
  if (beforeState != null && after != null && beforeState !== after) { didCode = true; didRepoMutation = true }
410
434
  for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
411
435
  log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
436
+ const handoffError = completedHandoff && !workRequest
437
+ const resultStatus = policyBlock ? 'blocked' : handoffError ? 'handoff_error' : 'ok'
438
+ checkpointState = policyBlock ? 'blocked' : handoffError ? 'handoff_error' : 'ended'
439
+ if (handoffError) emit('process.error', { status: 'handoff_error', summary: 'The work-session tool completed but its continuation could not be decoded. No coding session was started.' })
412
440
  flushProgress()
413
- if (outputText.trim()) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: policyBlock ? 'blocked' : 'ok' })
441
+ // A handoff is a request to queue work, not a completed task or proof that
442
+ // the coding process has started. The watcher owns its public delivery.
443
+ if (outputText.trim() && !workRequest && !handoffError) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: resultStatus })
414
444
  return {
415
- type: 'result', subtype: policyBlock ? 'blocked' : 'ok', stopReason: 'end_turn', workRequest, policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
445
+ type: 'result', subtype: resultStatus, stopReason: 'end_turn', workRequest, policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
416
446
  mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
417
447
  didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
418
448
  didMcpTaskRead, didMcpTaskUpdate,
@@ -424,6 +454,7 @@ export function createMastraAcpRunner({
424
454
  }
425
455
  const canceled = controller.signal.aborted
426
456
  const subtype = providerFailure?.subtype || (timedOut ? 'timeout' : canceled ? 'canceled' : 'error')
457
+ checkpointState = subtype
427
458
  const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
428
459
  const safeError = redact(errorDetail)
429
460
  flushProgress()
@@ -438,6 +469,7 @@ export function createMastraAcpRunner({
438
469
  didMcpTaskRead, didMcpTaskUpdate,
439
470
  }
440
471
  } finally {
472
+ saveCheckpoint(true)
441
473
  flushProgress()
442
474
  if (!disconnected.has(acp)) emit('process.ready', { status: 'idle', sessionId: acp.connection.sessionId || null })
443
475
  clearTimeout(timer)
@@ -0,0 +1,14 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Three concrete peer requests per human turn. Reserve at intake, not delivery:
4
+ // concurrent messages cannot spend the same budget. Persist with the ledger.
5
+ export function admitPeerTurn(previous, text, target) {
6
+ if (target.action !== 'handle') return null
7
+ const normalized = String(text).replace(/\s+/g, ' ').trim().toLowerCase()
8
+ const fingerprint = createHash('sha256').update(normalized).digest('hex')
9
+ const prior = previous?.state === 'human' ? [] : previous?.meta?.requests ?? (previous?.state === 'replied' ? ['legacy-reply'] : [])
10
+ if (prior.length >= 3 || prior.includes(fingerprint)) return null
11
+ if (prior.length && (!target.explicitSelf || !/\b(?:review|check|confirm|explain|investigate|fix|implement|share|send|compare|verify)\b/i.test(normalized))) return null
12
+ if (/^@[\w -]+[,!:]?\s+(?:thanks|thank you|got it|acknowledged|understood)[.!\s]*$/i.test(normalized)) return null
13
+ return { requests: [...prior, fingerprint] }
14
+ }
@@ -40,7 +40,7 @@ export function workSessionRequest(output, depth = 0) {
40
40
  ...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
41
41
  ...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
42
42
  }
43
- for (const key of ['content', 'text', 'result', 'structuredContent']) {
43
+ for (const key of ['output', 'content', 'text', 'result', 'structuredContent']) {
44
44
  const nested = workSessionRequest(output[key], depth + 1)
45
45
  if (nested) return nested
46
46
  }
@@ -26,7 +26,7 @@ function containsRoot(value, rootId, identity, depth = 0) {
26
26
  if (Array.isArray(value)) return value.some((row) => containsRoot(row, rootId, identity, depth + 1))
27
27
  if (typeof value !== 'object') return false
28
28
  if (String(value.message_id ?? value.messageId ?? value.id) === String(rootId) && messageParentId(value) == null && typeof (value.content ?? value.body ?? value.text) === 'string' && verifiedOtherAuthor(value, identity)) return true
29
- return ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
29
+ return ['messages', 'replies', 'items', 'body', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
30
30
  }
31
31
 
32
32
  export function createThreadContextResolver({ read, save, cancelled, load, identity, now = Date.now }) {
package/src/watch.mjs CHANGED
@@ -13,8 +13,10 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
13
  import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskEventSenderIsSelf, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
14
14
  import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
15
15
  import { createMastraMemory } from './memory.mjs'
16
+ import { createHarnessCheckpoints } from './harness-checkpoints.mjs'
16
17
  import { createContextService } from './context-service.mjs'
17
18
  import { contextToolGuide } from './context-tools.mjs'
19
+ import { loadProjectTickets, needsWorkContinuation } from './work-recovery.mjs'
18
20
  import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
19
21
  import { compactAgentMessage } from './tool-policy.mjs'
20
22
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
@@ -28,6 +30,8 @@ import { releaseAuthorizedPause } from './authorization-resume.mjs'
28
30
  import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
29
31
  import { createTaskTypeLookup, loadProjectTaskTypes } from './task-types.mjs'
30
32
  import { createThreadContextResolver } from './thread-context.mjs'
33
+ import { compileConversationContext, createConversationReader, conversationAuthor, checkedConversationResult } from './conversation-context.mjs'
34
+ import { admitPeerTurn } from './peer-turns.mjs'
31
35
  import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
32
36
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
33
37
  import { createMastraAcpRunner } from './mastra-harness.mjs'
@@ -343,13 +347,13 @@ export async function runWatch({ flags }) {
343
347
  }
344
348
 
345
349
  // Claude keeps its native stream; every ACP provider uses the same Mastra harness.
346
- export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
350
+ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, checkpoints, canCoordinate = false, workspaceAvailable = !!workdir }) {
347
351
  const canCode = !!workdir
348
352
  const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
349
353
  // ACP providers run through Mastra's shared harness. Each queue worker gets
350
354
  // its own ACP session and worktree, so cancellation, permissions and context
351
355
  // cannot bleed across concurrently assigned tickets.
352
- const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
356
+ const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, checkpoints })
353
357
  if (mastraRunner) return mastraRunner
354
358
  let child = null
355
359
  let privateMcpDir = ''
@@ -658,6 +662,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
658
662
  claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
659
663
  cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
660
664
  getContextEnvironment: () => contextService.environment(),
665
+ checkpoints: { load: scope => harnessCheckpoints.load(scope), save: (scope, value) => harnessCheckpoints.save(scope, value) },
661
666
  }
662
667
  // One watcher and one WS subscription, with a bounded pool of isolated coding
663
668
  // runtimes. Replies serialize within a thread while independent threads run
@@ -784,6 +789,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
784
789
  resourceId: identifier,
785
790
  })
786
791
  const contextBackend = new URL(backend || mcpUrl || 'http://local.invalid')
792
+ const harnessCheckpoints = createHarnessCheckpoints({ memory, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}` })
787
793
  const contextService = dependencies.contextService || createContextService({
788
794
  directory: stateDir, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}`,
789
795
  loadTasks: () => loadPendingTickets(),
@@ -1005,6 +1011,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1005
1011
  }).finally(() => { mcpToolDiscoveryPromise = null })
1006
1012
  return mcpToolDiscoveryPromise
1007
1013
  }
1014
+ const conversationReader = createConversationReader({ load: async (scope) => {
1015
+ const names = await discoverMcpTools()
1016
+ const ticket = scope.ticketId != null
1017
+ const tool = ticket ? 'list_task_comment_thread' : 'list_message_thread'
1018
+ if (!names.has(tool)) throw new Error('Conversation thread tool not advertised')
1019
+ const args = ticket ? { project_id: scope.projectId, ticket_id: scope.ticketId, comment_id: scope.threadId }
1020
+ : { channel_id: scope.channelId, message_id: scope.threadId }
1021
+ const [thread, surroundings] = await Promise.all([
1022
+ callMcpTool(tool, args).then(checkedConversationResult),
1023
+ !ticket && names.has('list_channel_messages')
1024
+ ? callMcpTool('list_channel_messages', { channel_id: scope.channelId, limit: 12, before_id: scope.threadId }).then(checkedConversationResult).catch(() => null)
1025
+ : null,
1026
+ ])
1027
+ return { thread, surroundings }
1028
+ } })
1008
1029
  const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
1009
1030
  let names
1010
1031
  try { names = await discoverMcpTools() }
@@ -1055,10 +1076,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1055
1076
  const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
1056
1077
  const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
1057
1078
  const [tasksResult, typesResult] = await Promise.all([
1058
- callMcpReadWithRetry('list_tasks', { project_id: project.id }),
1079
+ loadProjectTickets({ projectId: project.id, read: async (name, args) => toolData(await callMcpReadWithRetry(name, args), name), listTools: () => mcpClient.listTools(), matches: resolveTaskOwner }),
1059
1080
  taskTypeSets(project.id),
1060
1081
  ])
1061
- const tasksData = toolData(tasksResult, 'list_tasks')
1082
+ const tasksData = { tasks: tasksResult }
1062
1083
  const { done: doneIds, review: reviewIds } = typesResult
1063
1084
  const tasks = await mapConcurrent(Array.isArray(tasksData.tasks) ? tasksData.tasks : [], 3, async (task) => {
1064
1085
  await resolveTaskOwner(task)
@@ -1162,7 +1183,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1162
1183
  save: saveOwnThread,
1163
1184
  cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
1164
1185
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1165
- load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1186
+ load: async (cid, root) => (await conversationReader.read({ channelId: Number(cid), threadId: Number(root) })).thread,
1166
1187
  })
1167
1188
  const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
1168
1189
  const ownTaskCommentKey = (projectId, ticketId, content) => {
@@ -1170,7 +1191,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1170
1191
  return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
1171
1192
  }
1172
1193
  const rememberPeerReply = (key, refs = {}) => {
1173
- if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied once to a peer agent; waiting for a human turn.', refs })
1194
+ if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied to a peer; bounded concrete follow-ups remain available.', refs, meta: memory.get(key)?.meta })
1174
1195
  }
1175
1196
  const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
1176
1197
  if (stopping) throw new Error('Watcher is stopping')
@@ -1190,6 +1211,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1190
1211
  const controlKey = threadControlKey(channelId, parentId)
1191
1212
  const suppressCancelled = () => {
1192
1213
  if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
1214
+ if (sourceMessageId != null && memory.has(`deleted-message:${channelId}:${sourceMessageId}`, 'deleted')) return { posted: false, reason: 'source-deleted' }
1193
1215
  if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
1194
1216
  if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
1195
1217
  pendingReplyDeliveries.delete(deliveryKey)
@@ -1208,7 +1230,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1208
1230
  }
1209
1231
  const run = (async () => {
1210
1232
  if (parentId != null) {
1211
- const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1233
+ const live = checkedConversationResult(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
1212
1234
  const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
1213
1235
  const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
1214
1236
  const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
@@ -1514,7 +1536,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1514
1536
  try {
1515
1537
  const assigned = []
1516
1538
  const [tasksData, typesData] = await Promise.all([
1517
- callMcpTool('list_tasks', { project_id: project.id }).then((result) => toolData(result, 'list_tasks')),
1539
+ loadProjectTickets({ projectId: project.id, read: async (name, args) => toolData(await callMcpReadWithRetry(name, args), name), listTools: () => mcpClient.listTools(), matches: resolveTaskOwner }).then(tasks => ({ tasks })),
1518
1540
  taskTypeSets(project.id),
1519
1541
  ])
1520
1542
  const { done: doneIds, review: reviewIds } = typesData
@@ -1673,6 +1695,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1673
1695
  ? { channelId: delivery.channelId, threadId: delivery.parentId }
1674
1696
  : activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
1675
1697
  let recalled = ''
1698
+ runnerOptions.contextRefs = memoryRefs
1699
+ if (delivery?.sourceMessage) {
1700
+ let snapshot, unavailable = !!delivery.contextUnavailable
1701
+ try { if (!unavailable) snapshot = await conversationReader.read(memoryRefs) }
1702
+ catch (error) { unavailable = true; log('Compiled conversation history unavailable: ' + (error?.message || error)) }
1703
+ ctx.push(compileConversationContext({ ...snapshot, source: delivery.sourceMessage, scope: memoryRefs, unavailable }))
1704
+ }
1676
1705
  try { recalled = await memory.context(memoryRefs) }
1677
1706
  catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
1678
1707
  const capabilityContext = kind !== 'full' && canCode
@@ -1708,7 +1737,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1708
1737
  cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
1709
1738
  cycleControl.outcome = 'running'
1710
1739
  log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
1711
- const result = await runner.runCycle(prompt, useModel, runnerOptions)
1740
+ let result = await runner.runCycle(prompt, useModel, runnerOptions)
1741
+ // Stay in the same writable session and delivery scope after a premature
1742
+ // acknowledgment. Never escalate reply-only work or bypass cancellation.
1743
+ const unfinishedTurn = value => needsWorkContinuation(value, { pendingPlan: cycleControl.planEntries?.some(entry => ['pending', 'in_progress'].includes(entry.status)) })
1744
+ if (kind === 'full' && canCode && !cycleControl.cancelled && !stopping && unfinishedTurn(result)) {
1745
+ await cycleControl.planDelivery
1746
+ if (cycleControl.cancelled || stopping) return
1747
+ emit('cycle.continued', { cycleId: cycleControl.cycleId, reason: 'acknowledgement without an outcome', status: 'continued' })
1748
+ result = await runner.runCycle(`${prompt}\n\nCONTINUE THE ACCEPTED WORK: Your previous turn ended without a result: ${String(result.outputText || '(no final result)').slice(0, 700)}\nYour coding workspace is still available. An acknowledgement is not completion. Continue the original authorized task now, using alternate read tools if needed. Do not repeat the acknowledgement. Finish with a concrete result and verification, or a specific blocker with the checks attempted. Do not invent outcomes or bypass permissions.`, useModel, runnerOptions)
1749
+ if (unfinishedTurn(result)) result = { ...result, subtype: 'incomplete', is_error: true }
1750
+ }
1712
1751
  if (result?.model) {
1713
1752
  emit('models.applied', { requestedModel: useModel, model: result.model, tier: kind === 'full' ? 'coding' : 'reply', modelSettingsRevision: cycleModelRevision })
1714
1753
  }
@@ -1812,6 +1851,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1812
1851
  // Distill an event's WS payload into a directive the model can act on without
1813
1852
  // any extra lookup.
1814
1853
  const senderName = (m) => {
1854
+ const attributed = conversationAuthor(m || {})
1855
+ if (attributed.kind === 'agent' && attributed.name !== 'Unknown') return attributed.name
1815
1856
  const s = m && (m.taskSender || m.task_sender || m.sender || m.user || m.author || m.member || m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent)
1816
1857
  if (!s) return ''
1817
1858
  return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
@@ -1953,11 +1994,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1953
1994
  if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
1954
1995
  const peer = messageSenderIsAgent(comment)
1955
1996
  const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
1956
- if (peer && memory.has(loopKey, 'replied')) {
1957
- log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
1958
- return { status: 'ignored-peer-loop' }
1959
- }
1960
- if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
1961
1997
  const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
1962
1998
  if (target.action !== 'handle') {
1963
1999
  log('task:comment ignored before model start (' + target.reason + ')')
@@ -1979,6 +2015,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1979
2015
  log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
1980
2016
  return { status: 'ignored-unassigned' }
1981
2017
  }
2018
+ if (peer) {
2019
+ const meta = admitPeerTurn(memory.get(loopKey), text, target)
2020
+ if (!meta) return { status: 'ignored-peer-loop' }
2021
+ memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
2022
+ } else memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human' })
2023
+ conversationReader.invalidate({ projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) })
1982
2024
  const who = senderName(comment)
1983
2025
  const ticketSlug = ticketDisplaySlug(ticket)
1984
2026
  memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
@@ -1991,6 +2033,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1991
2033
  sourceKey,
1992
2034
  watcherOwned: true,
1993
2035
  peerLoopKey: peer ? loopKey : '',
2036
+ sourceMessage: { ...comment, id: Number(commentId) },
1994
2037
  }
1995
2038
  const label = ticketSlug || String(ticket.title || 'the assigned ticket')
1996
2039
  const context = `The watcher received a new comment on your assigned ticket ${JSON.stringify(label)} from ${JSON.stringify(who || (peer ? 'another agent' : 'a teammate'))}: ${JSON.stringify(text)}. Authoritative ticket details: ${JSON.stringify({ slug: ticketSlug, title: ticket.title, description: ticket.description, priority: ticket.priority, status: ticket.type?.name ?? ticket.task_type?.name ?? ticket.status ?? ticket.state })}. Answer the comment directly in the context of this ticket. Do not call post_message or any task-comment tool; return only the final reply and the watcher will post it once in the source comment thread.${peer ? ' This was authored by another team agent. Address its concrete question or request without replacing it with ticket inventory or adding an open-ended reciprocal question.' : ''}`
@@ -2011,7 +2054,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2011
2054
  const messageCandidate = [outer.message, data.message, payload.message].find((item) => item && typeof item === 'object' && !Array.isArray(item))
2012
2055
  const directId = source.message_id ?? source.messageId ?? source.id
2013
2056
  const directContent = source.content ?? source.body ?? source.text ?? (typeof source.message === 'string' ? source.message : null)
2014
- const message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
2057
+ let message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
2058
+ const messageId = message.message_id ?? message.messageId ?? message.id
2059
+ // The documented mention envelope may provide the root outside message.
2060
+ // Explicit inner parent fields always win, including malformed ones.
2061
+ if (!['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo'].some((key) => message[key] != null)
2062
+ && validBackendId(source.thread_root_id) && String(source.thread_root_id) !== String(messageId)) message = { ...message, parent_id: Number(source.thread_root_id) }
2015
2063
  const channelId = source.channel_id ?? source.channelId ?? source.channel?.id ?? message.channel_id ?? message.channelId ?? message.channel?.id
2016
2064
  return { ...source, channel_id: channelId, message }
2017
2065
  }
@@ -2019,6 +2067,31 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2019
2067
  const mentionIntake = new Map()
2020
2068
  function onEvent(k, d) {
2021
2069
  if (stopping) return
2070
+ if (['channel:message:updated', 'channel:message:deleted', 'channel:message:reaction'].includes(k)) {
2071
+ const event = conversationEventData(d)
2072
+ if (validBackendId(event.channel_id)) {
2073
+ conversationReader.clearChannel(Number(event.channel_id))
2074
+ const mid = event.message?.id ?? event.message_id
2075
+ if (k === 'channel:message:deleted' && validBackendId(mid)) {
2076
+ memory.remember({ key: `deleted-message:${Number(event.channel_id)}:${Number(mid)}`, kind: 'message-deletion', state: 'deleted' })
2077
+ contextService.forget(`message:${Number(event.channel_id)}:${Number(mid)}`)
2078
+ contextService.forgetThread?.(Number(event.channel_id), Number(mid))
2079
+ cancelThread(Number(event.channel_id), Number(mid), 'Source message deleted')
2080
+ // A reply can itself be the accepted request. Cancel just its work,
2081
+ // without standing down unrelated requests in the parent thread.
2082
+ const fromDeletedSource = (delivery) => Number(delivery?.channelId) === Number(event.channel_id) && Number(delivery?.sourceMessageId) === Number(mid)
2083
+ for (const [key, delivery] of pendingReplyDeliveries) if (fromDeletedSource(delivery)) pendingReplyDeliveries.delete(key)
2084
+ for (const queue of Object.values(queues)) queue.cancel((item) => fromDeletedSource(item.delivery), (item) => {
2085
+ item.control.cancelled = true; item.control.wakeRecovery?.()
2086
+ void item.control.runner?.cancelCurrent?.('source-deleted')
2087
+ })
2088
+ persistReplay()
2089
+ } else if (k === 'channel:message:updated') {
2090
+ try { contextService.observe({ name: 'list_message_thread', args: { channel_id: Number(event.channel_id) }, result: event.message }) } catch { /* live read remains authoritative */ }
2091
+ }
2092
+ }
2093
+ return
2094
+ }
2022
2095
  if (k === 'task:comment') return trackOperation(handleTaskComment(d))
2023
2096
  const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
2024
2097
  if (!conversationEvent) return trackOperation(routeEvent(k, d))
@@ -2029,6 +2102,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2029
2102
  const messageId = message.message_id ?? message.messageId ?? message.id
2030
2103
  const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
2031
2104
  if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
2105
+ if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return
2106
+ conversationReader.clearChannel(Number(cid))
2032
2107
  const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
2033
2108
  // Keep redirects ordered while a legacy root is being verified. Other
2034
2109
  // conversations can resolve and run independently.
@@ -2048,9 +2123,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2048
2123
  // Organization fanout includes our own posts. Ignore stable self identity
2049
2124
  // before recipient checks, replay guards or thread cancellation run.
2050
2125
  if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
2051
- const preliminary = classifyConversationTarget(message, [...selfAliases])
2126
+ const preliminary = classifyConversationTarget(message, [...selfAliases], { threadOwned: parent != null && memory.has(threadControlKey(cid, parent), 'active') })
2052
2127
  const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
2053
2128
  let rootContent = ''
2129
+ let contextUnavailable = false
2054
2130
  if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
2055
2131
  const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
2056
2132
  let contextTimer
@@ -2061,20 +2137,25 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2061
2137
  // before a model starts. Delivery still re-reads the authoritative thread.
2062
2138
  rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
2063
2139
  contextTimer = setTimeout(() => {
2140
+ contextUnavailable = true
2064
2141
  log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
2065
2142
  resolve('')
2066
2143
  }, 1000)
2067
2144
  })]) : await context
2068
2145
  }
2069
2146
  catch (error) {
2147
+ contextUnavailable = true
2070
2148
  if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
2071
2149
  log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
2072
2150
  }
2073
2151
  finally { clearTimeout(contextTimer) }
2074
2152
  }
2153
+ // Deletion may arrive during identity/history I/O, before there is a
2154
+ // queue entry for cancellation to find. Recheck at the admission boundary.
2155
+ if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return { status: 'ignored-deleted' }
2075
2156
  if (!stopping) {
2076
2157
  if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
2077
- return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent })
2158
+ return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent, _contextUnavailable: contextUnavailable })
2078
2159
  }
2079
2160
  }).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
2080
2161
  .finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
@@ -2114,11 +2195,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2114
2195
  log('agent:mention replay recovered for active owned thread')
2115
2196
  }
2116
2197
  const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
2117
- if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
2118
- log('agent:mention ignored before model start (peer-loop-budget)')
2119
- return { status: 'ignored-peer-loop' }
2120
- }
2121
- if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
2122
2198
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2123
2199
  if (target.action === 'ignore') {
2124
2200
  log('agent:mention ignored before model start (' + target.reason + ')')
@@ -2139,6 +2215,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2139
2215
  }
2140
2216
  return
2141
2217
  }
2218
+ if (peerMessage && loopKey) {
2219
+ const meta = admitPeerTurn(memory.get(loopKey), text, target)
2220
+ if (!meta) { log('agent:mention ignored before model start (peer-loop-budget)'); return { status: 'ignored-peer-loop' } }
2221
+ memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
2222
+ } else if (loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
2142
2223
  if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
2143
2224
  memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
2144
2225
  try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
@@ -2147,10 +2228,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2147
2228
  // redirect can cancel its process. The watcher delivers the final answer
2148
2229
  // for every runtime after an authoritative last-moment thread check.
2149
2230
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
2150
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2231
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, sourceMessage: msg, contextUnavailable: raw._contextUnavailable, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2151
2232
  : null
2152
2233
  // Under-the-hood model control from chat (view / switch the model the agent runs).
2153
2234
  const mcmd = cid != null ? parseModelCmd(text) : null
2235
+ if (peerMessage && mcmd && !mcmd.report) { log('Peer agent cannot change local model configuration'); return }
2154
2236
  if (mcmd) {
2155
2237
  refreshModelSettings()
2156
2238
  const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
@@ -0,0 +1,35 @@
1
+ // Recover read paths, never replay a possibly successful mutation.
2
+ export async function loadProjectTickets({ projectId, read, listTools, matches = async () => true }) {
3
+ const rows = data => {
4
+ const value = Array.isArray(data) ? data : data?.tasks ?? data?.tickets ?? data?.data?.tasks ?? data?.data?.tickets;
5
+ if (!Array.isArray(value)) throw new Error('Ticket listing returned an unrecognized response');
6
+ return value.filter(row => row && (row.project_id == null || String(row.project_id) === String(projectId)));
7
+ };
8
+ let primary, failure;
9
+ try { primary = rows(await read('list_tasks', { project_id: projectId })); }
10
+ catch (error) { failure = error; }
11
+ if (primary?.length && (await Promise.all(primary.map(matches))).some(Boolean)) return primary;
12
+ let catalog;
13
+ try { catalog = await listTools(); }
14
+ catch (error) { throw failure || error; }
15
+ const alternate = catalog.find(tool => tool.name === 'get_tickets');
16
+ const schema = alternate?.inputSchema;
17
+ // Only use a published schema we can satisfy from authoritative context.
18
+ if (schema && (schema.required || []).every(key => key === 'project_id')) {
19
+ const args = schema.properties?.project_id ? { project_id: projectId } : {};
20
+ const alternateRows = rows(await read('get_tickets', args));
21
+ return [...new Map([...(primary || []), ...alternateRows].map(row => [String(row.id), row])).values()];
22
+ }
23
+ if (failure) throw failure;
24
+ return primary;
25
+ }
26
+
27
+ // A narrow guard against promise-only final turns, not a tool-count quota.
28
+ // Completed explanations, verified no-ops and genuine blockers can end normally.
29
+ export function needsWorkContinuation(result, { pendingPlan = false } = {}) {
30
+ if (!result || result.is_error || !['ok', 'success'].includes(result.subtype) || result.workRequest || result.didCode || result.didRepoMutation || result.didResultMessage) return false;
31
+ const text = String(result.outputText || '').trim();
32
+ if (!text) return pendingPlan;
33
+ if (text.length > 700 || /\b(verified|passed|fixed|completed|found|failed|permission|approval|denied|blocked|missing|unavailable)\b/i.test(text)) return false;
34
+ return /^(?:(?:okay|ok|sure|understood|acknowledged|got it)[,.!—:\s]+)?(?:I['’]ll|I will|I['’]m going to|I am going to|I['’]m on it|I['’]ll take this on)\b/i.test(text);
35
+ }