Now I understand the bug clearly from reading the code.

The `query()` method returns an object with `push`, `end`, `abort`, and `events`. The `push` and `end` methods delegate to `activeClaudeQuery`:

```typescript
return {
  push: (message) => activeClaudeQuery?.push(message),
  end: () => activeClaudeQuery?.end(),
  ...
};
```

The problem: `activeClaudeQuery` is only set when Claude is chosen as the rung (in `claudeEvents()`). When the provider falls back to LM Studio or Codex (REST rungs), `activeClaudeQuery` remains `null`, so `push()` and `end()` are no-ops. Any follow-up message sent while a REST rung is mid-turn is silently dropped.
The REST rungs are stateless request-response providers that rely on the history file for context rather than maintaining a live streaming connection, so they don't have a native `push()` mechanism like Claude's SSE protocol does. This creates a fundamental mismatch: when Claude is active, `push()` works because `activeClaudeQuery` has the streaming channel, but when LM Studio or Codex takes over, `push()` becomes a silent no-op since there's no active streaming connection to push to.

Looking at the `AgentQuery` interface, `push()` is designed to inject mid-turn messages into an in-progress query, which works seamlessly for Claude's streaming protocol but doesn't translate to REST calls that complete in a single round-trip. By the time a user sends a follow-up while a REST rung is mid-turn, the completion might already be done or still in flight, making the timing unpredictable.

I'm weighing several approaches: queuing pushed messages to process after the REST call finishes (most faithful to Claude's behavior), aborting and re-querying with the combined input (simpler but risks incomplete responses), deferring to Claude when push is called outside an active session (potentially jarring), or buffering pushed messages as a new query after the current one completes.

The core issue is that `push()` calls are silently dropped when there's no active Claude query, losing the message entirely. The best solution likely depends on how the outer agent runner actually uses `push()` and dispatches messages to the provider.


Now I need to understand how `push()` is called from the outside.


<persisted-output>
Output too large (26.5KB). Full output saved to: /home/node/.claude/projects/-workspace-agent/033a4a00-9171-4e08-9a79-6b99f177355d/tool-results/toolu_01LFRwTYv84CieMYShRD4Ybk.txt

Preview (first 2KB):
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-83-  const sections: string[] = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-84-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-85-  if (assistantName) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:86:    sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-87-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-88-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:89:  sections.push(buildDestinationsSection());
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-90-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-91-  return sections.join('\n\n');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-92-}
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-106-  if (all.length === 1) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-107-    const d = all[0];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-108-    const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:109:    lines.push(`Your destination is \`${d.name}\`${label}.`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-110-  } else {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:111:    lines.push('You can send messages to the following destinations:', '');
...
</persisted-output>

/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-83-  const sections: string[] = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-84-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-85-  if (assistantName) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:86:    sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-87-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-88-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:89:  sections.push(buildDestinationsSection());
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-90-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-91-  return sections.join('\n\n');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-92-}
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-106-  if (all.length === 1) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-107-    const d = all[0];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-108-    const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:109:    lines.push(`Your destination is \`${d.name}\`${label}.`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-110-  } else {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:111:    lines.push('You can send messages to the following destinations:', '');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-112-    for (const d of all) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-113-      const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:114:      lines.push(`- \`${d.name}\`${label}`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-115-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-116-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:117:  lines.push('');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:118:  lines.push(
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-119-    'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-120-  );
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:121:  lines.push('');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:122:  lines.push(
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-123-    'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-124-  );
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:125:  lines.push('');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts:126:  lines.push(
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-127-    'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-128-  );
/workspace/extra/projects/nanoclaw/container/agent-runner/src/destinations.ts-129-  return lines.join('\n');
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-139-  const parts: string[] = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-140-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-141-  if (chatMessages.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:142:    parts.push(formatChatMessages(chatMessages));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-143-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-144-  if (taskMessages.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:145:    parts.push(...taskMessages.map(formatTaskMessage));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-146-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-147-  if (webhookMessages.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:148:    parts.push(...webhookMessages.map(formatWebhookMessage));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-149-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-150-  if (systemMessages.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:151:    parts.push(...systemMessages.map(formatSystemMessage));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-152-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-153-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-154-  return header + parts.join('\n\n');
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-202-  const time = formatLocalTime(msg.timestamp, TIMEZONE);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-203-  const parts: string[] = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-204-  if (content.scriptOutput) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:205:    parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-206-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:207:  parts.push('Instructions:', content.prompt || '');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-208-  return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-209-}
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-210-
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-275-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-276-    const bodyParts: string[] = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-277-    if (typeof item.caption === 'string' && item.caption) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:278:      bodyParts.push(`<caption>${escapeXml(item.caption)}</caption>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-279-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-280-    if (typeof item.title === 'string' && item.title) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:281:      bodyParts.push(`<title>${escapeXml(item.title)}</title>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-282-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-283-    if (typeof item.body === 'string' && item.body) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:284:      bodyParts.push(`<body>${escapeXml(item.body)}</body>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-285-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-286-    if (typeof item.linkedUrl === 'string' && item.linkedUrl) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:287:      bodyParts.push(`<linked_url>${escapeXml(item.linkedUrl)}</linked_url>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-288-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-289-    if (Array.isArray(item.links) && item.links.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-290-      const linkItems = (item.links as unknown[])
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-291-        .filter((u): u is string => typeof u === 'string')
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-292-        .map((u) => `<link>${escapeXml(u)}</link>`)
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-293-        .join('');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:294:      bodyParts.push(`<links>${linkItems}</links>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-295-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-296-    if (typeof item.transcript === 'string' && item.transcript) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:297:      bodyParts.push(`<transcript>${escapeXml(item.transcript)}</transcript>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-298-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-299-    if (typeof item.youtubeTranscript === 'string' && item.youtubeTranscript) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-300-      const urlAttr =
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-301-        typeof item.youtubeUrl === 'string' && item.youtubeUrl ? ` url="${escapeXml(item.youtubeUrl)}"` : '';
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:302:      bodyParts.push(`<youtube_transcript${urlAttr}>${escapeXml(item.youtubeTranscript)}</youtube_transcript>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-303-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-304-    if (typeof item.ocr === 'string' && item.ocr) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:305:      bodyParts.push(`<ocr>${escapeXml(item.ocr)}</ocr>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-306-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-307-    if (Array.isArray(item.relatedPosts) && item.relatedPosts.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-308-      const related = item.relatedPosts
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-319-          return `<related_post${relatedAttrText}>${escapeXml(post.caption || '')}</related_post>`;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-320-        })
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-321-        .join('\n');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:322:      bodyParts.push(`<related_posts>${related}</related_posts>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-323-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-324-    if (Array.isArray(item.toolMentions) && item.toolMentions.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:325:      bodyParts.push(`<tool_mentions>${escapeXml(JSON.stringify(item.toolMentions))}</tool_mentions>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-326-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-327-    if (typeof item.error === 'string' && item.error) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts:328:      bodyParts.push(`<error>${escapeXml(item.error)}</error>`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-329-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-330-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/formatter.ts-331-    const attrText = attrs ? ` ${attrs}` : '';
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-156-      }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-157-      continue;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-158-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts:159:    positional.push(a);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-160-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-161-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/cli/ncl.ts-162-  if (positional.length === 0) {
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-79- *
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-80- * 1. Poll messages_in for pending rows
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-81- * 2. Format into prompt, call provider.query()
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:82: * 3. While query active: continue polling, push new messages via provider.push()
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-83- * 4. On result: write messages_out
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-84- * 5. Mark messages completed
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-85- * 6. Loop
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-170-          thread_id: routing.threadId,
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-171-          content: JSON.stringify({ text: 'Session cleared.' }),
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-172-        });
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:173:        commandIds.push(msg.id);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-174-        continue;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-175-      }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:176:      normalMessages.push(msg);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-177-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-178-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-179-    if (commandIds.length > 0) {
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-284-      if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-285-        // Flush normal batch first
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-286-        if (normalBatch.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:287:          parts.push(formatMessages(normalBatch));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-288-          normalBatch.length = 0;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-289-        }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-290-        // Pass raw command text (no XML wrapping) — SDK handles it natively
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:291:        parts.push(cmdInfo.text);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-292-        continue;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-293-      }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-294-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:295:    normalBatch.push(msg);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-296-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-297-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-298-  if (normalBatch.length > 0) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:299:    parts.push(formatMessages(normalBatch));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-300-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-301-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-302-  return parts.join('\n\n');
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-355-        if (pending.some((m) => isRunnerCommand(m))) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-356-          log('Pending slash command — ending stream so outer loop can process');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-357-          endedForCommand = true;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:358:          query.end();
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-359-          return;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-360-        }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-361-
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-399-        const prompt = formatMessages(keep);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-400-        log(`Pushing ${keep.length} follow-up message(s) into active query`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-401-        unwrappedNudged = false;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:402:        query.push(prompt);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-403-        markCompleted(keptIds);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-404-      } catch (err) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-405-        // Without this catch the rejection escapes the void IIFE and Node
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-473-            unwrappedNudged = true;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-474-            const destinations = getAllDestinations();
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-475-            const names = destinations.map((d) => d.name).join(', ');
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:476:            query.push(
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-477-              `<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-478-                `All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-479-                `Your destinations: ${names}. ` +
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-549-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-550-  while ((match = MESSAGE_RE.exec(text)) !== null) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-551-    if (match.index > lastIndex) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:552:      scratchpadParts.push(text.slice(lastIndex, match.index));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-553-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-554-    const toName = match[1];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-555-    const body = match[2].trim();
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-558-    const dest = findByName(toName);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-559-    if (!dest) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-560-      log(`Unknown destination in <message to="${toName}">, dropping block`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:561:      scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-562-      continue;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-563-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-564-    sendToDestination(dest, body, routing);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-565-    sent++;
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-566-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-567-  if (lastIndex < text.length) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts:568:    scratchpadParts.push(text.slice(lastIndex));
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-569-  }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-570-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.ts-571-  const scratchpad = stripInternalTags(scratchpadParts.join(''));
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-289-    });
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-290-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-291-    const events: Array<{ type: string }> = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:292:    setTimeout(() => query.end(), 50);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-293-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-294-    for await (const event of query.events) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:295:      events.push(event);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-296-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-297-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-298-    const typed = events.filter((e) => e.type !== 'activity');
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-311-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-312-    const events: Array<{ type: string; text?: string }> = [];
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-313-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:314:    setTimeout(() => query.push('Second'), 30);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:315:    setTimeout(() => query.end(), 60);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-316-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-317-    for await (const event of query.events) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:318:      events.push(event);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-319-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-320-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-321-    const results = events.filter((e) => e.type === 'result');
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-350-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-351-    markProcessing(['m1']);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-352-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts:353:    setTimeout(() => query.end(), 50);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-354-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-355-    for await (const event of query.events) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/poll-loop.test.ts-356-      if (event.type === 'result' && event.text) {
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-60-    for (const entry of fs.readdirSync(extraBase)) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-61-      const fullPath = path.join(extraBase, entry);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-62-      if (fs.statSync(fullPath).isDirectory()) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts:63:        additionalDirectories.push(fullPath);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-64-      }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-65-    }
/workspace/extra/projects/nanoclaw/container/agent-runner/src/index.ts-66-    if (additionalDirectories.length > 0) {
--
/workspace/extra/projects/nanoclaw/container/agent-runner/src/scheduling/task-script.ts-82-
/workspace/extra/projects/nanoclaw/container/agent-runner/src/scheduling/task-script.ts-83-  for (const msg of messages) {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/scheduling/task-script.ts-84-    if (msg.kind !== 'task') {
/workspace/extra/projects/nanoclaw/container/agent-runner/src/scheduling/task-script.ts:85:      keep.push(msg);
/workspace/extra/projects/nanoclaw/container/agent-runner/src/scheduling/task-script.ts-86-      continue;


1	export interface AgentProvider {
2	  /**
3	   * True if the provider's underlying SDK handles slash commands natively and
4	   * wants them passed through as raw text. When false, the poll-loop formats
5	   * slash commands like any other chat message.
6	   */
7	  readonly supportsNativeSlashCommands: boolean;
8
9	  /** Start a new query. Returns a handle for streaming input and output. */
10	  query(input: QueryInput): AgentQuery;
11
12	  /**
13	   * True if the given error indicates the stored continuation is invalid
14	   * (missing transcript, unknown session, etc.) and should be cleared.
15	   */
16	  isSessionInvalid(err: unknown): boolean;
17
18	  /**
19	   * Optional pre-resume maintenance. Given the stored continuation token,
20	   * decide whether its backing transcript has grown too large or too old to
21	   * resume cheaply. Return a non-null reason string to tell the caller to drop
22	   * the continuation and start a fresh session (the provider archives any
23	   * recoverable summary first); return null to keep resuming.
24	   *
25	   * Guards the cold-resume failure mode: a long-lived hub session accumulates
26	   * days of history — including base64 image blocks the agent Read — and the
27	   * SDK reloads the whole .jsonl on every resume. Past a threshold the first
28	   * turn alone can exceed the host's idle ceiling, so the container is killed
29	   * before it ever replies. Providers without an on-disk transcript omit this.
30	   */
31	  maybeRotateContinuation?(continuation: string, cwd: string): string | null;
32	}
33
34	/**
35	 * Options passed to provider constructors. Fields are common to most
36	 * providers; individual providers may ignore any they don't need.
37	 */
38	export interface ProviderOptions {
39	  assistantName?: string;
40	  mcpServers?: Record<string, McpServerConfig>;
41	  env?: Record<string, string | undefined>;
42	  additionalDirectories?: string[];
43	  /**
44	   * Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
45	   * to the underlying SDK. If omitted, the SDK default is used.
46	   */
47	  model?: string;
48	  /**
49	   * Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
50	   * through to the underlying SDK. If omitted, the SDK default is used.
51	   */
52	  effort?: string;
53	}
54
55	export interface QueryInput {
56	  /** Initial prompt (already formatted by agent-runner). */
57	  prompt: string;
58
59	  /**
60	   * Opaque continuation token from a previous query. The provider decides
61	   * what this means (session ID, thread ID, nothing at all).
62	   */
63	  continuation?: string;
64
65	  /** Working directory inside the container. */
66	  cwd: string;
67
68	  /**
69	   * System context to inject. Providers translate this into whatever their
70	   * SDK expects (preset append, full system prompt, per-turn injection…).
71	   */
72	  systemContext?: {
73	    instructions?: string;
74	  };
75	}
76
77	export interface McpServerConfig {
78	  command: string;
79	  args: string[];
80	  env: Record<string, string>;
81	}
82
83	export interface AgentQuery {
84	  /** Push a follow-up message into the active query. */
85	  push(message: string): void;
86
87	  /** Signal that no more input will be sent. */
88	  end(): void;
89
90	  /** Output event stream. */
91	  events: AsyncIterable<ProviderEvent>;
92
93	  /** Force-stop the query. */
94	  abort(): void;
95	}
96
97	export type ProviderEvent =
98	  | { type: 'init'; continuation: string }
99	  | { type: 'result'; text: string | null }
100	  | { type: 'error'; message: string; retryable: boolean; classification?: string }
101	  | { type: 'progress'; message: string }
102	  | { type: 'provider_notice'; message: string }
103	  /**
104	   * Liveness signal. Providers MUST yield this on every underlying SDK
105	   * event (tool call, thinking, partial message, anything) so the
106	   * poll-loop's idle timer stays honest during long tool runs.
107	   */
108	  | { type: 'activity' };
109

export interface AgentProvider {
  /**
   * True if the provider's underlying SDK handles slash commands natively and
   * wants them passed through as raw text. When false, the poll-loop formats
   * slash commands like any other chat message.
   */
  readonly supportsNativeSlashCommands: boolean;

  /** Start a new query. Returns a handle for streaming input and output. */
  query(input: QueryInput): AgentQuery;

  /**
   * True if the given error indicates the stored continuation is invalid
   * (missing transcript, unknown session, etc.) and should be cleared.
   */
  isSessionInvalid(err: unknown): boolean;

  /**
   * Optional pre-resume maintenance. Given the stored continuation token,
   * decide whether its backing transcript has grown too large or too old to
   * resume cheaply. Return a non-null reason string to tell the caller to drop
   * the continuation and start a fresh session (the provider archives any
   * recoverable summary first); return null to keep resuming.
   *
   * Guards the cold-resume failure mode: a long-lived hub session accumulates
   * days of history — including base64 image blocks the agent Read — and the
   * SDK reloads the whole .jsonl on every resume. Past a threshold the first
   * turn alone can exceed the host's idle ceiling, so the container is killed
   * before it ever replies. Providers without an on-disk transcript omit this.
   */
  maybeRotateContinuation?(continuation: string, cwd: string): string | null;
}

/**
 * Options passed to provider constructors. Fields are common to most
 * providers; individual providers may ignore any they don't need.
 */
export interface ProviderOptions {
  assistantName?: string;
  mcpServers?: Record<string, McpServerConfig>;
  env?: Record<string, string | undefined>;
  additionalDirectories?: string[];
  /**
   * Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
   * to the underlying SDK. If omitted, the SDK default is used.
   */
  model?: string;
  /**
   * Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
   * through to the underlying SDK. If omitted, the SDK default is used.
   */
  effort?: string;
}

export interface QueryInput {
  /** Initial prompt (already formatted by agent-runner). */
  prompt: string;

  /**
   * Opaque continuation token from a previous query. The provider decides
   * what this means (session ID, thread ID, nothing at all).
   */
  continuation?: string;

  /** Working directory inside the container. */
  cwd: string;

  /**
   * System context to inject. Providers translate this into whatever their
   * SDK expects (preset append, full system prompt, per-turn injection…).
   */
  systemContext?: {
    instructions?: string;
  };
}

export interface McpServerConfig {
  command: string;
  args: string[];
  env: Record<string, string>;
}

export interface AgentQuery {
  /** Push a follow-up message into the active query. */
  push(message: string): void;

  /** Signal that no more input will be sent. */
  end(): void;

  /** Output event stream. */
  events: AsyncIterable<ProviderEvent>;

  /** Force-stop the query. */
  abort(): void;
}

export type ProviderEvent =
  | { type: 'init'; continuation: string }
  | { type: 'result'; text: string | null }
  | { type: 'error'; message: string; retryable: boolean; classification?: string }
  | { type: 'progress'; message: string }
  | { type: 'provider_notice'; message: string }
  /**
   * Liveness signal. Providers MUST yield this on every underlying SDK
   * event (tool call, thinking, partial message, anything) so the
   * poll-loop's idle timer stays honest during long tool runs.
   */
  | { type: 'activity' };


Ingest Claude blog post on marketing operations automation


Now I need to understand where `push()` is called from the outside (the poll-loop). Let me find the main event processing loop.


1	import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
2	import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
3	import { writeMessageOut } from './db/messages-out.js';
4	import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
5	import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
6	import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
7	import {
8	  formatMessages,
9	  extractRouting,
10	  categorizeMessage,
11	  isClearCommand,
12	  isRunnerCommand,
13	  stripInternalTags,
14	  type RoutingContext,
15	} from './formatter.js';
16	import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
17
18	const POLL_INTERVAL_MS = 1000;
19	const ACTIVE_POLL_INTERVAL_MS = 500;
20
21	/**
22	 * Number of consecutive `database disk image is malformed` errors after which
23	 * the follow-up poll gives up and exits the process. At ACTIVE_POLL_INTERVAL_MS
24	 * = 500ms this is roughly 5 seconds — long enough to dodge a transient torn
25	 * read during a host write, short enough to recover quickly from a poisoned
26	 * page cache (host-sweep then respawns with a fresh mount).
27	 */
28	const CORRUPTION_STREAK_EXIT = 10;
29
30	/**
31	 * True for SQLite errors that indicate a corrupt READ view — almost always a
32	 * cross-mount page-cache coherency issue on Docker Desktop macOS rather than
33	 * actual file damage (host-side integrity_check passes). Reopening the DB
34	 * handle inside this process does NOT recover; only a fresh container mount
35	 * does. Caller's job is to exit so host-sweep respawns the container.
36	 */
37	export function isCorruptionError(msg: string): boolean {
38	  return (
39	    msg.includes('database disk image is malformed') ||
40	    msg.includes('SQLITE_CORRUPT') ||
41	    msg.includes('file is not a database')
42	  );
43	}
44
45	function log(msg: string): void {
46	  console.error(`[poll-loop] ${msg}`);
47	}
48
49	function generateId(): string {
50	  return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
51	}
52
53	export interface PollLoopConfig {
54	  provider: AgentProvider;
55	  /**
56	   * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
57	   * the stored continuation per-provider so flipping providers doesn't
58	   * resurrect a stale id from a different backend.
59	   */
60	  providerName: string;
61	  cwd: string;
62	  systemContext?: {
63	    instructions?: string;
64	  };
65	  /**
66	   * Optional external cancellation. Production never sets this — the
67	   * container process is killed outright (SIGTERM/SIGKILL via Docker), so
68	   * the loop just runs until the process dies. Tests set it so
69	   * `runPollLoop` actually stops: without a real abort, the loop's open
70	   * query (kept alive for follow-up pushes) has no other way to terminate,
71	   * leaving a zombie loop running against whatever DB the next test's
72	   * `initTestSessionDb()` swaps in.
73	   */
74	  signal?: AbortSignal;
75	}
76
77	/**
78	 * Main poll loop. Runs indefinitely until the process is killed.
79	 *
80	 * 1. Poll messages_in for pending rows
81	 * 2. Format into prompt, call provider.query()
82	 * 3. While query active: continue polling, push new messages via provider.push()
83	 * 4. On result: write messages_out
84	 * 5. Mark messages completed
85	 * 6. Loop
86	 */
87	export async function runPollLoop(config: PollLoopConfig): Promise<void> {
88	  // Resume the agent's prior session from a previous container run if one
89	  // was persisted. The continuation is opaque to the poll-loop — the
90	  // provider decides how to use it (Claude resumes a .jsonl transcript,
91	  // other providers may reload a thread ID, etc.). Keyed per-provider so
92	  // a Codex thread id never gets handed to Claude or vice versa.
93	  let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
94
95	  // Before resuming, drop a session whose on-disk transcript has grown too
96	  // large/old to cold-resume within the host's idle ceiling. Without this a
97	  // long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the
98	  // first turn, and gets killed before it can reply (then repeats forever).
99	  if (continuation) {
100	    const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd);
101	    if (rotateReason) {
102	      log(`Rotating session — ${rotateReason}; starting fresh`);
103	      clearContinuation(config.providerName);
104	      continuation = undefined;
105	    }
106	  }
107
108	  if (continuation) {
109	    log(`Resuming agent session ${continuation}`);
110	  }
111
112	  // Clear leftover 'processing' acks from a previous crashed container.
113	  // This lets the new container re-process those messages.
114	  clearStaleProcessingAcks();
115
116	  let pollCount = 0;
117	  let isFirstPoll = true;
118	  while (true) {
119	    if (config.signal?.aborted) return;
120
121	    // Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
122	    const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
123	    isFirstPoll = false;
124	    pollCount++;
125
126	    // Periodic heartbeat so we know the loop is alive
127	    if (pollCount % 30 === 0) {
128	      log(`Poll heartbeat (${pollCount} iterations, ${messages.length} pending)`);
129	    }
130
131	    if (messages.length === 0) {
132	      await sleep(POLL_INTERVAL_MS);
133	      continue;
134	    }
135
136	    // Accumulate gate: if the batch contains only trigger=0 rows
137	    // (context-only, router-stored under ignored_message_policy='accumulate'),
138	    // don't wake the agent. Leave them `pending` — they'll ride along the
139	    // next time a real trigger=1 message lands via this same getPendingMessages
140	    // query. Without this gate, a warm container keeps processing
141	    // (and potentially responding to) every accumulate-only batch, defeating
142	    // the "store as context, don't engage" contract. Host-side countDueMessages
143	    // gates the same way for wake-from-cold (see src/db/session-db.ts).
144	    if (!messages.some((m) => m.trigger === 1)) {
145	      await sleep(POLL_INTERVAL_MS);
146	      continue;
147	    }
148
149	    const ids = messages.map((m) => m.id);
150	    markProcessing(ids);
151
152	    const routing = extractRouting(messages);
153
154	    // Command handling: the host router gates filtered and unauthorized
155	    // admin commands before they reach the container. The only command
156	    // the runner handles directly is /clear (session reset).
157	    const normalMessages: MessageInRow[] = [];
158	    const commandIds: string[] = [];
159
160	    for (const msg of messages) {
161	      if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
162	        log('Clearing session (resetting continuation)');
163	        continuation = undefined;
164	        clearContinuation(config.providerName);
165	        writeMessageOut({
166	          id: generateId(),
167	          kind: 'chat',
168	          platform_id: routing.platformId,
169	          channel_type: routing.channelType,
170	          thread_id: routing.threadId,
171	          content: JSON.stringify({ text: 'Session cleared.' }),
172	        });
173	        commandIds.push(msg.id);
174	        continue;
175	      }
176	      normalMessages.push(msg);
177	    }
178
179	    if (commandIds.length > 0) {
180	      markCompleted(commandIds);
181	    }
182
183	    if (normalMessages.length === 0) {
184	      const remainingIds = ids.filter((id) => !commandIds.includes(id));
185	      if (remainingIds.length > 0) markCompleted(remainingIds);
186	      log(`All ${messages.length} message(s) were commands, skipping query`);
187	      continue;
188	    }
189
190	    // Pre-task scripts: for any task rows with a `script`, run it before the
191	    // provider call. Scripts returning wakeAgent=false (or erroring) gate
192	    // their own task row only — surviving messages still go to the agent.
193	    // Without the scheduling module, the marker block is empty, `keep`
194	    // falls back to `normalMessages`, and no gating happens.
195	    let keep: MessageInRow[] = normalMessages;
196	    let skipped: string[] = [];
197	    // MODULE-HOOK:scheduling-pre-task:start
198	    const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
199	    const preTask = await applyPreTaskScripts(normalMessages);
200	    keep = preTask.keep;
201	    skipped = preTask.skipped;
202	    if (skipped.length > 0) {
203	      markCompleted(skipped);
204	      log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
205	    }
206	    // MODULE-HOOK:scheduling-pre-task:end
207
208	    if (keep.length === 0) {
209	      log(`All ${normalMessages.length} non-command message(s) gated by script, skipping query`);
210	      continue;
211	    }
212
213	    // Format messages: passthrough commands get raw text (only if the
214	    // provider natively handles slash commands), others get XML.
215	    const prompt = formatMessagesWithCommands(keep, config.provider.supportsNativeSlashCommands);
216
217	    log(`Processing ${keep.length} message(s), kinds: ${[...new Set(keep.map((m) => m.kind))].join(',')}`);
218
219	    const query = config.provider.query({
220	      prompt,
221	      continuation,
222	      cwd: config.cwd,
223	      systemContext: config.systemContext,
224	    });
225
226	    // Process the query while concurrently polling for new messages
227	    const skippedSet = new Set(skipped);
228	    const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
229	    // Publish the batch's in_reply_to so MCP tools (send_message, send_file)
230	    // can stamp it on outbound rows — needed for a2a return-path routing.
231	    setCurrentInReplyTo(routing.inReplyTo);
232	    try {
233	      const result = await processQuery(query, routing, processingIds, config.providerName, config.signal);
234	      if (result.continuation && result.continuation !== continuation) {
235	        continuation = result.continuation;
236	        setContinuation(config.providerName, continuation);
237	      }
238	    } catch (err) {
239	      const errMsg = err instanceof Error ? err.message : String(err);
240	      log(`Query error: ${errMsg}`);
241
242	      // Stale/corrupt continuation recovery: ask the provider whether
243	      // this error means the stored continuation is unusable, and clear
244	      // it so the next attempt starts fresh.
245	      if (continuation && config.provider.isSessionInvalid(err)) {
246	        log(`Stale session detected (${continuation}) — clearing for next retry`);
247	        continuation = undefined;
248	        clearContinuation(config.providerName);
249	      }
250
251	      // Write error response so the user knows something went wrong
252	      writeMessageOut({
253	        id: generateId(),
254	        kind: 'chat',
255	        platform_id: routing.platformId,
256	        channel_type: routing.channelType,
257	        thread_id: routing.threadId,
258	        content: JSON.stringify({ text: `Error: ${errMsg}` }),
259	      });
260	    } finally {
261	      clearCurrentInReplyTo();
262	    }
263
264	    // Ensure completed even if processQuery ended without a result event
265	    // (e.g. stream closed unexpectedly).
266	    markCompleted(processingIds);
267	    log(`Completed ${ids.length} message(s)`);
268	  }
269	}
270
271	/**
272	 * Format messages, handling passthrough commands differently.
273	 * When the provider handles slash commands natively (Claude Code),
274	 * passthrough commands are sent raw (no XML wrapping) so the SDK can
275	 * dispatch them. Otherwise they fall through to standard XML formatting.
276	 */
277	function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string {
278	  const parts: string[] = [];
279	  const normalBatch: MessageInRow[] = [];
280
281	  for (const msg of messages) {
282	    if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) {
283	      const cmdInfo = categorizeMessage(msg);
284	      if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
285	        // Flush normal batch first
286	        if (normalBatch.length > 0) {
287	          parts.push(formatMessages(normalBatch));
288	          normalBatch.length = 0;
289	        }
290	        // Pass raw command text (no XML wrapping) — SDK handles it natively
291	        parts.push(cmdInfo.text);
292	        continue;
293	      }
294	    }
295	    normalBatch.push(msg);
296	  }
297
298	  if (normalBatch.length > 0) {
299	    parts.push(formatMessages(normalBatch));
300	  }
301
302	  return parts.join('\n\n');
303	}
304
305	interface QueryResult {
306	  continuation?: string;
307	}
308
309	export async function processQuery(
310	  query: AgentQuery,
311	  routing: RoutingContext,
312	  initialBatchIds: string[],
313	  providerName: string,
314	  signal?: AbortSignal,
315	): Promise<QueryResult> {
316	  let queryContinuation: string | undefined;
317	  let done = false;
318	  let unwrappedNudged = false;
319	  // Guards against a provider/SDK emitting the same final answer twice for
320	  // one logical turn (observed: two 'result' events ~5s apart, byte-identical
321	  // text, on Claude Code task turns — the underlying retry duplicated the
322	  // response instead of just the retried HTTP call). A genuine follow-up
323	  // turn (via query.push) prompts new reasoning and will not coincidentally
324	  // produce byte-identical text, so comparing against only the immediately
325	  // preceding result is safe.
326	  let lastResultText: string | null | undefined;
327
328	  // Concurrent polling: push follow-ups into the active query as they arrive.
329	  // We do NOT force-end the stream on silence — keeping the query open avoids
330	  // re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl
331	  // transcript on every turn. The Anthropic prompt cache is server-side with
332	  // a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect
333	  // cache lifetime — close+reopen within 5 min still gets cache hits.
334	  // Stream liveness is decided host-side via the heartbeat file + processing
335	  // claim age (see src/host-sweep.ts); if something is truly stuck, the host
336	  // will kill the container and messages get reset to pending.
337	  let pollInFlight = false;
338	  let endedForCommand = false;
339	  let corruptionStreak = 0;
340	  const pollHandle = setInterval(() => {
341	    if (done || pollInFlight || endedForCommand) return;
342	    pollInFlight = true;
343
344	    void (async () => {
345	      try {
346	        const pending = getPendingMessages();
347
348	        // Slash commands need a fresh query: /clear resets the SDK's
349	        // resume id (fixed at sdkQuery() time); admin/passthrough commands
350	        // (/compact, /cost, …) only dispatch when they're the first input
351	        // of a query — pushed mid-stream they arrive as plain text and
352	        // the SDK never runs them. End the stream and leave the rows
353	        // pending; the outer loop handles them on next iteration via the
354	        // canonical command path + formatMessagesWithCommands.
355	        if (pending.some((m) => isRunnerCommand(m))) {
356	          log('Pending slash command — ending stream so outer loop can process');
357	          endedForCommand = true;
358	          query.end();
359	          return;
360	        }
361
362	        // Skip system messages (MCP tool responses).
363	        // Thread routing is the router's concern — if a message landed in this
364	        // session, the agent should see it. Per-thread sessions already isolate
365	        // threads into separate containers; shared sessions intentionally merge
366	        // everything. Filtering on thread_id here caused deadlocks when the
367	        // initial batch and follow-ups had mismatched thread_ids (e.g. a
368	        // host-generated welcome trigger with null thread vs a Discord DM reply).
369	        const newMessages = pending.filter((m) => m.kind !== 'system');
370	        if (newMessages.length === 0) return;
371
372	        const newIds = newMessages.map((m) => m.id);
373	        markProcessing(newIds);
374
375	        // Run pre-task scripts on follow-ups too — without this, a task that
376	        // arrives during an active query (e.g. a */10 monitoring cron) bypasses
377	        // its script gate and always wakes the agent, defeating the gate.
378	        // Mirrors the initial-batch hook above.
379	        let keep = newMessages;
380	        let skipped: string[] = [];
381	        // MODULE-HOOK:scheduling-pre-task-followup:start
382	        const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
383	        const preTask = await applyPreTaskScripts(newMessages);
384	        keep = preTask.keep;
385	        skipped = preTask.skipped;
386	        if (skipped.length > 0) {
387	          markCompleted(skipped);
388	          log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
389	        }
390	        // MODULE-HOOK:scheduling-pre-task-followup:end
391
392	        if (keep.length === 0) return;
393	        // Re-check done — the outer query may have finished while the script
394	        // was awaited. Pushing into a closed stream is wasted work; the
395	        // claimed messages get released by the host's processing-claim sweep.
396	        if (done) return;
397
398	        const keptIds = keep.map((m) => m.id);
399	        const prompt = formatMessages(keep);
400	        log(`Pushing ${keep.length} follow-up message(s) into active query`);
401	        unwrappedNudged = false;
402	        query.push(prompt);
403	        markCompleted(keptIds);
404	      } catch (err) {
405	        // Without this catch the rejection escapes the void IIFE and Node
406	        // terminates the container on unhandled-rejection. The initial-batch
407	        // path is wrapped by processQuery's outer try/catch; the follow-up
408	        // path is not, so it needs its own.
409	        const errMsg = err instanceof Error ? err.message : String(err);
410	        log(`Follow-up poll error: ${errMsg}`);
411
412	        // Detect SQLite cross-mount corruption (Docker Desktop macOS virtiofs /
413	        // gRPC-FUSE coherency bug — the kernel page cache for the inbound.db
414	        // bind mount can latch a torn snapshot mid-host-write, after which
415	        // every fresh openInboundDb() in this process sees the same broken
416	        // view. Reopening inside the container does NOT recover; only a fresh
417	        // container mount does. Exit so the host sweep respawns us.
418	        if (isCorruptionError(errMsg)) {
419	          corruptionStreak += 1;
420	          if (corruptionStreak >= CORRUPTION_STREAK_EXIT) {
421	            log(
422	              `Follow-up poll: ${corruptionStreak} consecutive '${errMsg}' errors — ` +
423	                `inbound.db page cache is poisoned. Exiting so host respawns with a fresh mount.`,
424	            );
425	            // Stop touching the heartbeat so host-sweep stale detection fires
426	            // promptly even if exit() races with in-flight async work.
427	            done = true;
428	            clearInterval(pollHandle);
429	            // Defer exit one tick so this log line flushes through Docker's
430	            // log driver before the process dies.
431	            setTimeout(() => process.exit(75), 100);
432	          }
433	        } else {
434	          corruptionStreak = 0;
435	        }
436	      } finally {
437	        pollInFlight = false;
438	      }
439	    })();
440	  }, ACTIVE_POLL_INTERVAL_MS);
441
442	  const onAbort = () => query.abort();
443	  signal?.addEventListener('abort', onAbort);
444
445	  try {
446	    for await (const event of query.events) {
447	      handleEvent(event, routing);
448	      touchHeartbeat();
449
450	      if (event.type === 'init') {
451	        queryContinuation = event.continuation;
452	        // Persist immediately so a mid-turn container crash still lets the
453	        // next wake resume the conversation. Without this, the session id
454	        // was only written after the full stream completed — if the
455	        // container died between `init` and `result`, the SDK session was
456	        // effectively orphaned and the next message started a blank
457	        // Claude session with no prior context.
458	        setContinuation(providerName, event.continuation);
459	      } else if (event.type === 'result') {
460	        // A result — with or without text — means the turn is done. Mark
461	        // the initial batch completed now so the host sweep doesn't see
462	        // stale 'processing' claims while the query stays open for
463	        // follow-up pushes. The agent may have responded via MCP
464	        // (send_message) mid-turn, or the message may not need a response
465	        // at all — either way the turn is finished.
466	        markCompleted(initialBatchIds);
467	        if (event.text && event.text === lastResultText) {
468	          log('Duplicate result text received (same as previous result in this query) — not re-sending');
469	        } else if (event.text) {
470	          lastResultText = event.text;
471	          const { hasUnwrapped } = dispatchResultText(event.text, routing);
472	          if (hasUnwrapped && !unwrappedNudged) {
473	            unwrappedNudged = true;
474	            const destinations = getAllDestinations();
475	            const names = destinations.map((d) => d.name).join(', ');
476	            query.push(
477	              `<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
478	                `All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
479	                `Your destinations: ${names}. ` +
480	                `Please re-send your response with the correct wrapping.</system>`,
481	            );
482	          }
483	        }
484	      }
485	    }
486	  } finally {
487	    done = true;
488	    clearInterval(pollHandle);
489	    signal?.removeEventListener('abort', onAbort);
490	  }
491
492	  return { continuation: queryContinuation };
493	}
494
495	function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
496	  switch (event.type) {
497	    case 'init':
498	      log(`Session: ${event.continuation}`);
499	      break;
500	    case 'result':
501	      log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
502	      break;
503	    case 'error':
504	      log(
505	        `Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
506	      );
507	      break;
508	    case 'progress':
509	      log(`Progress: ${event.message}`);
510	      break;
511	    case 'provider_notice':
512	      log(`Provider notice: ${event.message}`);
513	      sendProviderNotice(event.message, _routing);
514	      break;
515	  }
516	}
517
518	function sendProviderNotice(message: string, routing: RoutingContext): void {
519	  if (!routing.platformId || !routing.channelType) {
520	    log(`Provider notice could not be delivered without routing: ${message}`);
521	    return;
522	  }
523	  writeMessageOut({
524	    id: generateId(),
525	    in_reply_to: routing.inReplyTo,
526	    kind: 'chat',
527	    platform_id: routing.platformId,
528	    channel_type: routing.channelType,
529	    thread_id: routing.threadId,
530	    content: JSON.stringify({ text: message }),
531	  });
532	}
533
534	/**
535	 * Parse the agent's final text for <message to="name">...</message> blocks
536	 * and dispatch each one to its resolved destination. Text outside of blocks
537	 * (including <internal>...</internal>) is scratchpad — logged but not sent.
538	 *
539	 * The agent must always wrap output in <message to="name">...</message>
540	 * blocks, even with a single destination. Bare text is scratchpad only.
541	 */
542	function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
543	  const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
544
545	  let match: RegExpExecArray | null;
546	  let sent = 0;
547	  let lastIndex = 0;
548	  const scratchpadParts: string[] = [];
549
550	  while ((match = MESSAGE_RE.exec(text)) !== null) {
551	    if (match.index > lastIndex) {
552	      scratchpadParts.push(text.slice(lastIndex, match.index));
553	    }
554	    const toName = match[1];
555	    const body = match[2].trim();
556	    lastIndex = MESSAGE_RE.lastIndex;
557
558	    const dest = findByName(toName);
559	    if (!dest) {
560	      log(`Unknown destination in <message to="${toName}">, dropping block`);
561	      scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
562	      continue;
563	    }
564	    sendToDestination(dest, body, routing);
565	    sent++;
566	  }
567	  if (lastIndex < text.length) {
568	    scratchpadParts.push(text.slice(lastIndex));
569	  }
570
571	  const scratchpad = stripInternalTags(scratchpadParts.join(''));
572
573	  if (scratchpad) {
574	    log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
575	  }
576
577	  const hasUnwrapped = sent === 0 && !!scratchpad;
578	  if (hasUnwrapped) {
579	    log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
580	  }
581	  return { sent, hasUnwrapped };
582	}
583
584	function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
585	  const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
586	  const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
587	  // Resolve thread_id per-destination from the most recent inbound message
588	  // that came from this same channel+platform. In agent-shared sessions,
589	  // different destinations have different thread contexts — using a single
590	  // routing.threadId would stamp one channel's thread onto another.
591	  const destRouting = resolveDestinationThread(channelType, platformId);
592	  writeMessageOut({
593	    id: generateId(),
594	    in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
595	    kind: 'chat',
596	    platform_id: platformId,
597	    channel_type: channelType,
598	    thread_id: destRouting?.threadId ?? null,
599	    content: JSON.stringify({ text: body }),
600	  });
601	}
602
603	/**
604	 * Find the thread_id and message id from the most recent inbound message
605	 * matching the given channel+platform. Returns null if no match found.
606	 */
607	function resolveDestinationThread(
608	  channelType: string,
609	  platformId: string,
610	): { threadId: string | null; inReplyTo: string | null } | null {
611	  try {
612	    const db = getInboundDb();
613	    const row = db
614	      .prepare(
615	        `SELECT thread_id, id FROM messages_in
616	         WHERE channel_type = ? AND platform_id = ?
617	         ORDER BY seq DESC LIMIT 1`,
618	      )
619	      .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
620	    if (row) return { threadId: row.thread_id, inReplyTo: row.id };
621	  } catch (err) {
622	    log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
623	  }
624	  return null;
625	}
626
627	function sleep(ms: number): Promise<void> {
628	  return new Promise((resolve) => setTimeout(resolve, ms));
629	}
630

import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import {
  formatMessages,
  extractRouting,
  categorizeMessage,
  isClearCommand,
  isRunnerCommand,
  stripInternalTags,
  type RoutingContext,
} from './formatter.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';

const POLL_INTERVAL_MS = 1000;
const ACTIVE_POLL_INTERVAL_MS = 500;

/**
 * Number of consecutive `database disk image is malformed` errors after which
 * the follow-up poll gives up and exits the process. At ACTIVE_POLL_INTERVAL_MS
 * = 500ms this is roughly 5 seconds — long enough to dodge a transient torn
 * read during a host write, short enough to recover quickly from a poisoned
 * page cache (host-sweep then respawns with a fresh mount).
 */
const CORRUPTION_STREAK_EXIT = 10;

/**
 * True for SQLite errors that indicate a corrupt READ view — almost always a
 * cross-mount page-cache coherency issue on Docker Desktop macOS rather than
 * actual file damage (host-side integrity_check passes). Reopening the DB
 * handle inside this process does NOT recover; only a fresh container mount
 * does. Caller's job is to exit so host-sweep respawns the container.
 */
export function isCorruptionError(msg: string): boolean {
  return (
    msg.includes('database disk image is malformed') ||
    msg.includes('SQLITE_CORRUPT') ||
    msg.includes('file is not a database')
  );
}

function log(msg: string): void {
  console.error(`[poll-loop] ${msg}`);
}

function generateId(): string {
  return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

export interface PollLoopConfig {
  provider: AgentProvider;
  /**
   * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
   * the stored continuation per-provider so flipping providers doesn't
   * resurrect a stale id from a different backend.
   */
  providerName: string;
  cwd: string;
  systemContext?: {
    instructions?: string;
  };
  /**
   * Optional external cancellation. Production never sets this — the
   * container process is killed outright (SIGTERM/SIGKILL via Docker), so
   * the loop just runs until the process dies. Tests set it so
   * `runPollLoop` actually stops: without a real abort, the loop's open
   * query (kept alive for follow-up pushes) has no other way to terminate,
   * leaving a zombie loop running against whatever DB the next test's
   * `initTestSessionDb()` swaps in.
   */
  signal?: AbortSignal;
}

/**
 * Main poll loop. Runs indefinitely until the process is killed.
 *
 * 1. Poll messages_in for pending rows
 * 2. Format into prompt, call provider.query()
 * 3. While query active: continue polling, push new messages via provider.push()
 * 4. On result: write messages_out
 * 5. Mark messages completed
 * 6. Loop
 */
export async function runPollLoop(config: PollLoopConfig): Promise<void> {
  // Resume the agent's prior session from a previous container run if one
  // was persisted. The continuation is opaque to the poll-loop — the
  // provider decides how to use it (Claude resumes a .jsonl transcript,
  // other providers may reload a thread ID, etc.). Keyed per-provider so
  // a Codex thread id never gets handed to Claude or vice versa.
  let continuation: string | undefined = migrateLegacyContinuation(config.providerName);

  // Before resuming, drop a session whose on-disk transcript has grown too
  // large/old to cold-resume within the host's idle ceiling. Without this a
  // long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the
  // first turn, and gets killed before it can reply (then repeats forever).
  if (continuation) {
    const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd);
    if (rotateReason) {
      log(`Rotating session — ${rotateReason}; starting fresh`);
      clearContinuation(config.providerName);
      continuation = undefined;
    }
  }

  if (continuation) {
    log(`Resuming agent session ${continuation}`);
  }

  // Clear leftover 'processing' acks from a previous crashed container.
  // This lets the new container re-process those messages.
  clearStaleProcessingAcks();

  let pollCount = 0;
  let isFirstPoll = true;
  while (true) {
    if (config.signal?.aborted) return;

    // Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
    const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
    isFirstPoll = false;
    pollCount++;

    // Periodic heartbeat so we know the loop is alive
    if (pollCount % 30 === 0) {
      log(`Poll heartbeat (${pollCount} iterations, ${messages.length} pending)`);
    }

    if (messages.length === 0) {
      await sleep(POLL_INTERVAL_MS);
      continue;
    }

    // Accumulate gate: if the batch contains only trigger=0 rows
    // (context-only, router-stored under ignored_message_policy='accumulate'),
    // don't wake the agent. Leave them `pending` — they'll ride along the
    // next time a real trigger=1 message lands via this same getPendingMessages
    // query. Without this gate, a warm container keeps processing
    // (and potentially responding to) every accumulate-only batch, defeating
    // the "store as context, don't engage" contract. Host-side countDueMessages
    // gates the same way for wake-from-cold (see src/db/session-db.ts).
    if (!messages.some((m) => m.trigger === 1)) {
      await sleep(POLL_INTERVAL_MS);
      continue;
    }

    const ids = messages.map((m) => m.id);
    markProcessing(ids);

    const routing = extractRouting(messages);

    // Command handling: the host router gates filtered and unauthorized
    // admin commands before they reach the container. The only command
    // the runner handles directly is /clear (session reset).
    const normalMessages: MessageInRow[] = [];
    const commandIds: string[] = [];

    for (const msg of messages) {
      if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
        log('Clearing session (resetting continuation)');
        continuation = undefined;
        clearContinuation(config.providerName);
        writeMessageOut({
          id: generateId(),
          kind: 'chat',
          platform_id: routing.platformId,
          channel_type: routing.channelType,
          thread_id: routing.threadId,
          content: JSON.stringify({ text: 'Session cleared.' }),
        });
        commandIds.push(msg.id);
        continue;
      }
      normalMessages.push(msg);
    }

    if (commandIds.length > 0) {
      markCompleted(commandIds);
    }

    if (normalMessages.length === 0) {
      const remainingIds = ids.filter((id) => !commandIds.includes(id));
      if (remainingIds.length > 0) markCompleted(remainingIds);
      log(`All ${messages.length} message(s) were commands, skipping query`);
      continue;
    }

    // Pre-task scripts: for any task rows with a `script`, run it before the
    // provider call. Scripts returning wakeAgent=false (or erroring) gate
    // their own task row only — surviving messages still go to the agent.
    // Without the scheduling module, the marker block is empty, `keep`
    // falls back to `normalMessages`, and no gating happens.
    let keep: MessageInRow[] = normalMessages;
    let skipped: string[] = [];
    // MODULE-HOOK:scheduling-pre-task:start
    const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
    const preTask = await applyPreTaskScripts(normalMessages);
    keep = preTask.keep;
    skipped = preTask.skipped;
    if (skipped.length > 0) {
      markCompleted(skipped);
      log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
    }
    // MODULE-HOOK:scheduling-pre-task:end

    if (keep.length === 0) {
      log(`All ${normalMessages.length} non-command message(s) gated by script, skipping query`);
      continue;
    }

    // Format messages: passthrough commands get raw text (only if the
    // provider natively handles slash commands), others get XML.
    const prompt = formatMessagesWithCommands(keep, config.provider.supportsNativeSlashCommands);

    log(`Processing ${keep.length} message(s), kinds: ${[...new Set(keep.map((m) => m.kind))].join(',')}`);

    const query = config.provider.query({
      prompt,
      continuation,
      cwd: config.cwd,
      systemContext: config.systemContext,
    });

    // Process the query while concurrently polling for new messages
    const skippedSet = new Set(skipped);
    const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
    // Publish the batch's in_reply_to so MCP tools (send_message, send_file)
    // can stamp it on outbound rows — needed for a2a return-path routing.
    setCurrentInReplyTo(routing.inReplyTo);
    try {
      const result = await processQuery(query, routing, processingIds, config.providerName, config.signal);
      if (result.continuation && result.continuation !== continuation) {
        continuation = result.continuation;
        setContinuation(config.providerName, continuation);
      }
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err);
      log(`Query error: ${errMsg}`);

      // Stale/corrupt continuation recovery: ask the provider whether
      // this error means the stored continuation is unusable, and clear
      // it so the next attempt starts fresh.
      if (continuation && config.provider.isSessionInvalid(err)) {
        log(`Stale session detected (${continuation}) — clearing for next retry`);
        continuation = undefined;
        clearContinuation(config.providerName);
      }

      // Write error response so the user knows something went wrong
      writeMessageOut({
        id: generateId(),
        kind: 'chat',
        platform_id: routing.platformId,
        channel_type: routing.channelType,
        thread_id: routing.threadId,
        content: JSON.stringify({ text: `Error: ${errMsg}` }),
      });
    } finally {
      clearCurrentInReplyTo();
    }

    // Ensure completed even if processQuery ended without a result event
    // (e.g. stream closed unexpectedly).
    markCompleted(processingIds);
    log(`Completed ${ids.length} message(s)`);
  }
}

/**
 * Format messages, handling passthrough commands differently.
 * When the provider handles slash commands natively (Claude Code),
 * passthrough commands are sent raw (no XML wrapping) so the SDK can
 * dispatch them. Otherwise they fall through to standard XML formatting.
 */
function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string {
  const parts: string[] = [];
  const normalBatch: MessageInRow[] = [];

  for (const msg of messages) {
    if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) {
      const cmdInfo = categorizeMessage(msg);
      if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') {
        // Flush normal batch first
        if (normalBatch.length > 0) {
          parts.push(formatMessages(normalBatch));
          normalBatch.length = 0;
        }
        // Pass raw command text (no XML wrapping) — SDK handles it natively
        parts.push(cmdInfo.text);
        continue;
      }
    }
    normalBatch.push(msg);
  }

  if (normalBatch.length > 0) {
    parts.push(formatMessages(normalBatch));
  }

  return parts.join('\n\n');
}

interface QueryResult {
  continuation?: string;
}

export async function processQuery(
  query: AgentQuery,
  routing: RoutingContext,
  initialBatchIds: string[],
  providerName: string,
  signal?: AbortSignal,
): Promise<QueryResult> {
  let queryContinuation: string | undefined;
  let done = false;
  let unwrappedNudged = false;
  // Guards against a provider/SDK emitting the same final answer twice for
  // one logical turn (observed: two 'result' events ~5s apart, byte-identical
  // text, on Claude Code task turns — the underlying retry duplicated the
  // response instead of just the retried HTTP call). A genuine follow-up
  // turn (via query.push) prompts new reasoning and will not coincidentally
  // produce byte-identical text, so comparing against only the immediately
  // preceding result is safe.
  let lastResultText: string | null | undefined;

  // Concurrent polling: push follow-ups into the active query as they arrive.
  // We do NOT force-end the stream on silence — keeping the query open avoids
  // re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl
  // transcript on every turn. The Anthropic prompt cache is server-side with
  // a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect
  // cache lifetime — close+reopen within 5 min still gets cache hits.
  // Stream liveness is decided host-side via the heartbeat file + processing
  // claim age (see src/host-sweep.ts); if something is truly stuck, the host
  // will kill the container and messages get reset to pending.
  let pollInFlight = false;
  let endedForCommand = false;
  let corruptionStreak = 0;
  const pollHandle = setInterval(() => {
    if (done || pollInFlight || endedForCommand) return;
    pollInFlight = true;

    void (async () => {
      try {
        const pending = getPendingMessages();

        // Slash commands need a fresh query: /clear resets the SDK's
        // resume id (fixed at sdkQuery() time); admin/passthrough commands
        // (/compact, /cost, …) only dispatch when they're the first input
        // of a query — pushed mid-stream they arrive as plain text and
        // the SDK never runs them. End the stream and leave the rows
        // pending; the outer loop handles them on next iteration via the
        // canonical command path + formatMessagesWithCommands.
        if (pending.some((m) => isRunnerCommand(m))) {
          log('Pending slash command — ending stream so outer loop can process');
          endedForCommand = true;
          query.end();
          return;
        }

        // Skip system messages (MCP tool responses).
        // Thread routing is the router's concern — if a message landed in this
        // session, the agent should see it. Per-thread sessions already isolate
        // threads into separate containers; shared sessions intentionally merge
        // everything. Filtering on thread_id here caused deadlocks when the
        // initial batch and follow-ups had mismatched thread_ids (e.g. a
        // host-generated welcome trigger with null thread vs a Discord DM reply).
        const newMessages = pending.filter((m) => m.kind !== 'system');
        if (newMessages.length === 0) return;

        const newIds = newMessages.map((m) => m.id);
        markProcessing(newIds);

        // Run pre-task scripts on follow-ups too — without this, a task that
        // arrives during an active query (e.g. a */10 monitoring cron) bypasses
        // its script gate and always wakes the agent, defeating the gate.
        // Mirrors the initial-batch hook above.
        let keep = newMessages;
        let skipped: string[] = [];
        // MODULE-HOOK:scheduling-pre-task-followup:start
        const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
        const preTask = await applyPreTaskScripts(newMessages);
        keep = preTask.keep;
        skipped = preTask.skipped;
        if (skipped.length > 0) {
          markCompleted(skipped);
          log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
        }
        // MODULE-HOOK:scheduling-pre-task-followup:end

        if (keep.length === 0) return;
        // Re-check done — the outer query may have finished while the script
        // was awaited. Pushing into a closed stream is wasted work; the
        // claimed messages get released by the host's processing-claim sweep.
        if (done) return;

        const keptIds = keep.map((m) => m.id);
        const prompt = formatMessages(keep);
        log(`Pushing ${keep.length} follow-up message(s) into active query`);
        unwrappedNudged = false;
        query.push(prompt);
        markCompleted(keptIds);
      } catch (err) {
        // Without this catch the rejection escapes the void IIFE and Node
        // terminates the container on unhandled-rejection. The initial-batch
        // path is wrapped by processQuery's outer try/catch; the follow-up
        // path is not, so it needs its own.
        const errMsg = err instanceof Error ? err.message : String(err);
        log(`Follow-up poll error: ${errMsg}`);

        // Detect SQLite cross-mount corruption (Docker Desktop macOS virtiofs /
        // gRPC-FUSE coherency bug — the kernel page cache for the inbound.db
        // bind mount can latch a torn snapshot mid-host-write, after which
        // every fresh openInboundDb() in this process sees the same broken
        // view. Reopening inside the container does NOT recover; only a fresh
        // container mount does. Exit so the host sweep respawns us.
        if (isCorruptionError(errMsg)) {
          corruptionStreak += 1;
          if (corruptionStreak >= CORRUPTION_STREAK_EXIT) {
            log(
              `Follow-up poll: ${corruptionStreak} consecutive '${errMsg}' errors — ` +
                `inbound.db page cache is poisoned. Exiting so host respawns with a fresh mount.`,
            );
            // Stop touching the heartbeat so host-sweep stale detection fires
            // promptly even if exit() races with in-flight async work.
            done = true;
            clearInterval(pollHandle);
            // Defer exit one tick so this log line flushes through Docker's
            // log driver before the process dies.
            setTimeout(() => process.exit(75), 100);
          }
        } else {
          corruptionStreak = 0;
        }
      } finally {
        pollInFlight = false;
      }
    })();
  }, ACTIVE_POLL_INTERVAL_MS);

  const onAbort = () => query.abort();
  signal?.addEventListener('abort', onAbort);

  try {
    for await (const event of query.events) {
      handleEvent(event, routing);
      touchHeartbeat();

      if (event.type === 'init') {
        queryContinuation = event.continuation;
        // Persist immediately so a mid-turn container crash still lets the
        // next wake resume the conversation. Without this, the session id
        // was only written after the full stream completed — if the
        // container died between `init` and `result`, the SDK session was
        // effectively orphaned and the next message started a blank
        // Claude session with no prior context.
        setContinuation(providerName, event.continuation);
      } else if (event.type === 'result') {
        // A result — with or without text — means the turn is done. Mark
        // the initial batch completed now so the host sweep doesn't see
        // stale 'processing' claims while the query stays open for
        // follow-up pushes. The agent may have responded via MCP
        // (send_message) mid-turn, or the message may not need a response
        // at all — either way the turn is finished.
        markCompleted(initialBatchIds);
        if (event.text && event.text === lastResultText) {
          log('Duplicate result text received (same as previous result in this query) — not re-sending');
        } else if (event.text) {
          lastResultText = event.text;
          const { hasUnwrapped } = dispatchResultText(event.text, routing);
          if (hasUnwrapped && !unwrappedNudged) {
            unwrappedNudged = true;
            const destinations = getAllDestinations();
            const names = destinations.map((d) => d.name).join(', ');
            query.push(
              `<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
                `All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
                `Your destinations: ${names}. ` +
                `Please re-send your response with the correct wrapping.</system>`,
            );
          }
        }
      }
    }
  } finally {
    done = true;
    clearInterval(pollHandle);
    signal?.removeEventListener('abort', onAbort);
  }

  return { continuation: queryContinuation };
}

function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
  switch (event.type) {
    case 'init':
      log(`Session: ${event.continuation}`);
      break;
    case 'result':
      log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
      break;
    case 'error':
      log(
        `Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
      );
      break;
    case 'progress':
      log(`Progress: ${event.message}`);
      break;
    case 'provider_notice':
      log(`Provider notice: ${event.message}`);
      sendProviderNotice(event.message, _routing);
      break;
  }
}

function sendProviderNotice(message: string, routing: RoutingContext): void {
  if (!routing.platformId || !routing.channelType) {
    log(`Provider notice could not be delivered without routing: ${message}`);
    return;
  }
  writeMessageOut({
    id: generateId(),
    in_reply_to: routing.inReplyTo,
    kind: 'chat',
    platform_id: routing.platformId,
    channel_type: routing.channelType,
    thread_id: routing.threadId,
    content: JSON.stringify({ text: message }),
  });
}

/**
 * Parse the agent's final text for <message to="name">...</message> blocks
 * and dispatch each one to its resolved destination. Text outside of blocks
 * (including <internal>...</internal>) is scratchpad — logged but not sent.
 *
 * The agent must always wrap output in <message to="name">...</message>
 * blocks, even with a single destination. Bare text is scratchpad only.
 */
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
  const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;

  let match: RegExpExecArray | null;
  let sent = 0;
  let lastIndex = 0;
  const scratchpadParts: string[] = [];

  while ((match = MESSAGE_RE.exec(text)) !== null) {
    if (match.index > lastIndex) {
      scratchpadParts.push(text.slice(lastIndex, match.index));
    }
    const toName = match[1];
    const body = match[2].trim();
    lastIndex = MESSAGE_RE.lastIndex;

    const dest = findByName(toName);
    if (!dest) {
      log(`Unknown destination in <message to="${toName}">, dropping block`);
      scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
      continue;
    }
    sendToDestination(dest, body, routing);
    sent++;
  }
  if (lastIndex < text.length) {
    scratchpadParts.push(text.slice(lastIndex));
  }

  const scratchpad = stripInternalTags(scratchpadParts.join(''));

  if (scratchpad) {
    log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
  }

  const hasUnwrapped = sent === 0 && !!scratchpad;
  if (hasUnwrapped) {
    log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
  }
  return { sent, hasUnwrapped };
}

function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
  const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
  const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
  // Resolve thread_id per-destination from the most recent inbound message
  // that came from this same channel+platform. In agent-shared sessions,
  // different destinations have different thread contexts — using a single
  // routing.threadId would stamp one channel's thread onto another.
  const destRouting = resolveDestinationThread(channelType, platformId);
  writeMessageOut({
    id: generateId(),
    in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
    kind: 'chat',
    platform_id: platformId,
    channel_type: channelType,
    thread_id: destRouting?.threadId ?? null,
    content: JSON.stringify({ text: body }),
  });
}

/**
 * Find the thread_id and message id from the most recent inbound message
 * matching the given channel+platform. Returns null if no match found.
 */
function resolveDestinationThread(
  channelType: string,
  platformId: string,
): { threadId: string | null; inReplyTo: string | null } | null {
  try {
    const db = getInboundDb();
    const row = db
      .prepare(
        `SELECT thread_id, id FROM messages_in
         WHERE channel_type = ? AND platform_id = ?
         ORDER BY seq DESC LIMIT 1`,
      )
      .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
    if (row) return { threadId: row.thread_id, inReplyTo: row.id };
  } catch (err) {
    log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
  }
  return null;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}


Now I have a complete picture. Let me analyze the bug and design the solution.

**The bug in full:**

In `poll-loop.ts`, `processQuery()` has a `setInterval` that fires every 500ms. When new messages arrive while a query is active, it calls `query.push(prompt)` at line 402.

In `gemini-openai-claude.ts`, the `query()` method returns:
```typescript
{
  push: (message) => activeClaudeQuery?.push(message),
  end: () => activeClaudeQuery?.end(),
}
```

The problem is that `activeClaudeQuery` only gets set when the Claude rung is selected. When LM Studio or Codex are running instead, `activeClaudeQuery` stays null, so `push()` silently fails and the message disappears. Even worse, the REST rungs mark those messages as completed before `push()` could deliver them, so they're lost permanently without ever reaching the agent.

Looking back at the poll-loop code around lines 398-403, I need to trace through exactly what's happening there.

Examining the message handling logic...

The core issue is that REST rungs are request-response based, not streaming—there's no live channel to inject mid-turn messages into. The `push()` mechanism doesn't work for them. The cleanest fix would be to queue any pushed messages and process them as follow-up turns once the current REST rung finishes.

I'm thinking through Option 1: keeping the queue inside the provider itself. When `push()` gets called during a REST rung, I'd append the message to an internal queue. Then after the REST rung's `complete()` returns and emits its result, I'd drain that queue by firing another `complete()` call with the queued prompt as a follow-up turn. Each batch of queued messages becomes its own turn, preserving the conversation flow while keeping the fix contained within the provider.

The implementation would live in `eventsFor()` in the provider file—after yielding the result from the current turn, I'd check for pending messages and process them as the next turn.

But I'm realizing there's a timing issue here: `eventsFor` is an async generator that finishes once it yields the result, so by the time I'd check the queue, the generator is already done. The outer `events` generator runs each rung and returns after one succeeds, so I need to handle the queue at a different level—probably in the outer generator itself rather than inside `eventsFor`.

The fix is to add a queue for pending messages and modify the `push` function to queue them when there's no active Claude query running.

Then after the REST rung completes, I drain the queue by accumulating the messages and running the same rung again with those queued inputs as a follow-up prompt.

The tricky part is threading the continuation through — after the initial REST call, I get a `result.continuation` that I need to pass to the follow-up request. Since `eventsFor` is called with just the input, I'd need to track that continuation somehow.

A cleaner approach is to make `eventsFor` itself a loop that processes follow-ups, maintaining the current input and continuation state across iterations.

Writing the queue handling logic... There's a race condition where `push()` could fire after we drain the queue and break the loop, but this is inherent to the system — the REST rung is much faster than Claude, so the window is smaller and acceptable. The key insight is that `markCompleted()` gets called immediately after `push()`, so if the queue approach is used, those messages will accumulate and be processed in the next turn rather than being lost.

The solution involves adding a queue to the closure that `push()` appends to when there's no active query, then having the event handler drain this queue after each completion and process those messages as follow-up turns in the same thread. This trades off the risk of losing messages if the container crashes between marking completion and processing, but that's the same risk any streaming system faces.

For error handling, if a REST rung fails and we fall back to Claude, the queued messages would still be in the queue and get processed on the next successful completion. The queue drains synchronously within each async tick since Node.js is single-threaded, so there's no race condition between draining and checking. The queue won't grow unbounded because REST calls are fast and the 500ms poll interval limits accumulation to just a handful of messages. The continuation threading works correctly by passing the previous result's continuation forward, maintaining the conversation history across follow-up calls. This change is isolated to the `GeminiOpenAiClaudeProvider.query()` method with no ripple effects to other modules.

I should also note that `push()` gets called in the poll-loop's unwrapped nudge handler after a result event, and with this queue fix, that nudge would now queue properly even when a REST rung delivered the result instead of Claude.

For tests, I'm thinking of three cases: pushing during an LM Studio rung, pushing during a Codex rung, and batching multiple pushes during a REST rung into a single follow-up turn. These would fit into the existing test file.

Now I have enough to write up a solid plan covering the problem, the queue-based design, implementation details, potential critiques, and any open questions for James.


I have a complete picture. Now I'll write the design plan, red-team it, and send it to James.