@evident-ai/cli 3.0.1-dev.65a41a6 → 3.0.1-dev.65c0838

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -684,6 +684,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
684
684
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
685
685
  }
686
686
 
687
+ // src/lib/opencode/opencode-version-gate.ts
688
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
689
+ function isQueueValidatedVersion(version) {
690
+ if (!version) return false;
691
+ return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version);
692
+ }
693
+ function buildOpenCodeVersionWarning(version) {
694
+ if (isQueueValidatedVersion(version)) return null;
695
+ const detected = version ? `v${version}` : "unknown";
696
+ const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
697
+ return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
698
+ }
699
+
687
700
  // src/lib/opencode/process.ts
688
701
  import { execSync, spawn } from "child_process";
689
702
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -998,21 +1011,17 @@ function completedOf(m) {
998
1011
  if (!m || typeof m !== "object") return void 0;
999
1012
  return m.info?.time?.completed;
1000
1013
  }
1001
- async function getSessionMessages(port, sessionId) {
1002
- try {
1003
- const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
1004
- if (!res.ok) return null;
1005
- const body = await res.json();
1006
- return Array.isArray(body) ? body : null;
1007
- } catch {
1008
- return null;
1009
- }
1014
+ function idOf(m) {
1015
+ if (!m || typeof m !== "object") return void 0;
1016
+ if (typeof m.id === "string") return m.id;
1017
+ const infoId = m.info?.id;
1018
+ return typeof infoId === "string" ? infoId : void 0;
1010
1019
  }
1011
- function isTurnComplete(messages) {
1012
- if (!messages || messages.length === 0) return false;
1013
- const last = messages[messages.length - 1];
1014
- if (roleOf(last) !== "assistant") return false;
1015
- return completedOf(last) != null;
1020
+ function parentIdOf(m) {
1021
+ if (!m || typeof m !== "object") return void 0;
1022
+ if (typeof m.parentID === "string") return m.parentID;
1023
+ const infoParent = m.info?.parentID;
1024
+ return typeof infoParent === "string" ? infoParent : void 0;
1016
1025
  }
1017
1026
  async function createOpenCodeSession(port, directory) {
1018
1027
  const url = new URL(`${opencodeBase(port)}/session`);
@@ -1031,8 +1040,9 @@ async function createOpenCodeSession(port, directory) {
1031
1040
  const data = await response.json();
1032
1041
  return data.id;
1033
1042
  }
1034
- async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1043
+ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1035
1044
  const body = {
1045
+ messageID: messageId,
1036
1046
  parts: [{ type: "text", text: content }]
1037
1047
  };
1038
1048
  if (options?.agent) {
@@ -1047,79 +1057,42 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1047
1057
  };
1048
1058
  }
1049
1059
  }
1050
- let pollDone = false;
1051
- const reportedQuestions = /* @__PURE__ */ new Set();
1052
- const reportedPermissions = /* @__PURE__ */ new Set();
1053
- const pollInteractive = async () => {
1054
- while (!pollDone) {
1055
- await new Promise((resolve) => setTimeout(resolve, 1e3));
1056
- if (pollDone) break;
1057
- if (hooks?.onQuestion) {
1058
- try {
1059
- const res = await fetch(`${opencodeBase(port)}/question`);
1060
- if (res.ok) {
1061
- const questions = await res.json();
1062
- for (const q of questions) {
1063
- if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
1064
- reportedQuestions.add(q.id);
1065
- await hooks.onQuestion(q);
1066
- }
1067
- }
1068
- }
1069
- } catch {
1070
- }
1071
- }
1072
- if (hooks?.onPermission) {
1073
- try {
1074
- const res = await fetch(`${opencodeBase(port)}/permission`);
1075
- if (res.ok) {
1076
- const permissions = await res.json();
1077
- for (const p of permissions) {
1078
- if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
1079
- reportedPermissions.add(p.id);
1080
- await hooks.onPermission(p);
1081
- }
1082
- }
1083
- }
1084
- } catch {
1085
- }
1086
- }
1087
- }
1088
- };
1089
- const sendMessage = async () => {
1090
- const controller = new AbortController();
1091
- const timer = setTimeout(() => controller.abort(), maxWaitMs);
1092
- try {
1093
- const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
1094
- method: "POST",
1095
- headers: { "Content-Type": "application/json" },
1096
- body: JSON.stringify(body),
1097
- signal: controller.signal
1098
- });
1099
- if (!res.ok) {
1100
- const text = await res.text().catch(() => "");
1101
- throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1102
- }
1103
- const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
1104
- () => null
1105
- );
1106
- const session = sessionRes?.ok ? await sessionRes.json() : null;
1107
- const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
1108
- const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
1109
- const awaitingInteraction = reportedInteraction && !turnComplete;
1110
- return { title: session?.title, awaitingInteraction };
1111
- } catch (err) {
1112
- if (err instanceof Error && err.name === "AbortError") {
1113
- throw new Error("Message processing timed out");
1114
- }
1115
- throw err;
1116
- } finally {
1117
- clearTimeout(timer);
1118
- pollDone = true;
1119
- }
1120
- };
1121
- const [result] = await Promise.all([sendMessage(), pollInteractive()]);
1122
- return result;
1060
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
1061
+ method: "POST",
1062
+ headers: { "Content-Type": "application/json" },
1063
+ body: JSON.stringify(body)
1064
+ });
1065
+ if (res.status < 200 || res.status >= 300) {
1066
+ const text = await res.text().catch(() => "");
1067
+ throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1068
+ }
1069
+ }
1070
+ function findAssistantReplyAfter(messages, userMessageId) {
1071
+ if (!messages || messages.length === 0) return null;
1072
+ const byParent = messages.find(
1073
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1074
+ );
1075
+ if (byParent) return byParent;
1076
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1077
+ if (userIndex === -1) return null;
1078
+ for (let i = userIndex + 1; i < messages.length; i++) {
1079
+ if (roleOf(messages[i]) === "assistant") return messages[i];
1080
+ }
1081
+ return null;
1082
+ }
1083
+ function messageRunState(messages, userMessageId) {
1084
+ if (!messages || messages.length === 0) return "unknown";
1085
+ const hasUser = messages.some((m) => idOf(m) === userMessageId);
1086
+ const reply = findAssistantReplyAfter(messages, userMessageId);
1087
+ if (!hasUser) {
1088
+ if (!reply) return "unknown";
1089
+ }
1090
+ if (!reply) return "queued";
1091
+ return completedOf(reply) != null ? "done" : "running";
1092
+ }
1093
+ function opencodeMessageIdFor(queuedMessageId) {
1094
+ const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
1095
+ return `msg_${sanitized}`;
1123
1096
  }
1124
1097
 
1125
1098
  // src/lib/tunnel/connection.ts
@@ -1498,6 +1471,12 @@ var RunnerConnection = class {
1498
1471
  };
1499
1472
 
1500
1473
  // src/lib/channels/driver.ts
1474
+ function messageIdOf(m) {
1475
+ if (!m || typeof m !== "object") return void 0;
1476
+ if (typeof m.id === "string") return m.id;
1477
+ const infoId = m.info?.id;
1478
+ return typeof infoId === "string" ? infoId : void 0;
1479
+ }
1501
1480
  var DEFAULT_RETRY_POLICY = {
1502
1481
  maxAttempts: 6,
1503
1482
  baseDelayMs: 500,
@@ -1505,12 +1484,21 @@ var DEFAULT_RETRY_POLICY = {
1505
1484
  };
1506
1485
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1507
1486
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1487
+ var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1508
1488
  var ChannelAuthError = class extends Error {
1509
1489
  constructor(message) {
1510
1490
  super(message);
1511
1491
  this.name = "ChannelAuthError";
1512
1492
  }
1513
1493
  };
1494
+ var ChannelTerminalError = class extends Error {
1495
+ status;
1496
+ constructor(message, status) {
1497
+ super(message);
1498
+ this.name = "ChannelTerminalError";
1499
+ this.status = status;
1500
+ }
1501
+ };
1514
1502
  function backoffDelay(attempt, policy) {
1515
1503
  const exp = policy.baseDelayMs * Math.pow(2, attempt);
1516
1504
  const capped = Math.min(policy.maxDelayMs, exp);
@@ -1531,15 +1519,26 @@ var ChannelDriver = class {
1531
1519
  sleep;
1532
1520
  pausedPollIntervalMs;
1533
1521
  pausedMaxWaitMs;
1522
+ dispatchConfirmMs;
1523
+ now;
1534
1524
  /** Cache of conversationId → opencode sessionId. */
1535
1525
  sessions = /* @__PURE__ */ new Map();
1536
1526
  /**
1537
- * Outstanding paused-session watchers, keyed by `message.id` (WI-2-CLI).
1538
- * Single-flight per message: while a watcher is live for a message we never
1539
- * start a second one. The message stays `processing` for the watcher's lifetime
1540
- * so the `?status=pending` drain cannot double-claim it.
1527
+ * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1528
+ * session: one polling loop services all of that session's in-flight messages.
1529
+ * A session entry exists while it has any in-flight (dispatched-but-not-done)
1530
+ * message; it is removed once its in-flight set empties.
1541
1531
  */
1542
1532
  watchers = /* @__PURE__ */ new Map();
1533
+ /**
1534
+ * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
1535
+ * dispatched and are still in-flight. A message in this set is never
1536
+ * re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
1537
+ * Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
1538
+ * idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
1539
+ * a steady-state-poll re-dispatch will not double-run the message.
1540
+ */
1541
+ dispatched = /* @__PURE__ */ new Set();
1543
1542
  /**
1544
1543
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1545
1544
  * first session creation so drain-created sessions are rooted at the project
@@ -1562,6 +1561,8 @@ var ChannelDriver = class {
1562
1561
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1563
1562
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1564
1563
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1564
+ this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1565
+ this.now = config2.now ?? (() => Date.now());
1565
1566
  }
1566
1567
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1567
1568
  get opencodeBase() {
@@ -1571,16 +1572,16 @@ var ChannelDriver = class {
1571
1572
  // Public API
1572
1573
  // -------------------------------------------------------------------------
1573
1574
  /**
1574
- * Drain all pending channel conversations once: poll → processcallback.
1575
+ * Drain all pending channel conversations once: poll → dispatchregister.
1575
1576
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1576
1577
  * Re-entrant calls while a drain is in flight are skipped (return 0).
1577
1578
  *
1578
- * @returns the number of messages processed.
1579
+ * @returns the number of messages NEWLY dispatched to opencode's native queue.
1579
1580
  */
1580
1581
  async drainPending() {
1581
1582
  if (this.draining) return 0;
1582
1583
  this.draining = true;
1583
- let processed = 0;
1584
+ let dispatched = 0;
1584
1585
  try {
1585
1586
  const conversations = await this.getPendingConversations();
1586
1587
  if (conversations.length > 0) {
@@ -1591,95 +1592,95 @@ var ChannelDriver = class {
1591
1592
  });
1592
1593
  }
1593
1594
  for (const conv of conversations) {
1594
- processed += await this.processConversation(conv);
1595
+ dispatched += await this.processConversation(conv);
1595
1596
  }
1596
1597
  } finally {
1597
1598
  this.draining = false;
1598
1599
  }
1599
- return processed;
1600
+ return dispatched;
1601
+ }
1602
+ /**
1603
+ * True while any per-session watcher has a non-empty in-flight dispatched set
1604
+ * (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
1605
+ * the process while a dispatched message is still queued/running — which would
1606
+ * kill the turn and orphan its reply.
1607
+ */
1608
+ hasInFlightWatchers() {
1609
+ for (const watcher of this.watchers.values()) {
1610
+ if (watcher.inFlight.size > 0) return true;
1611
+ }
1612
+ return false;
1600
1613
  }
1601
1614
  /**
1602
- * Await all outstanding paused-session watchers (WI-2-CLI).
1615
+ * Await all outstanding per-session watchers (WI-3).
1603
1616
  *
1604
- * In production the watchers are deliberately started-not-awaited so the drain
1605
- * loop never blocks on them and process exit is not held up (the cron recovers
1606
- * any abandoned ones). This helper exists primarily for deterministic tests
1607
- * that need to observe the watcher's effect (the `done` PATCH or its giving up)
1608
- * after a non-blocking `drainPending`. Watchers never reject, so this resolves.
1617
+ * In production the watcher loops are deliberately started-not-awaited so the
1618
+ * drain loop never blocks on them and process exit is not held up (the cron
1619
+ * recovers any abandoned ones). This helper exists primarily for deterministic
1620
+ * tests that need to observe a watcher's effect (the `processing`/`done` PATCH
1621
+ * or its giving up) after a non-blocking `drainPending`. Watcher loops never
1622
+ * reject, so this resolves.
1609
1623
  */
1610
1624
  async flushPausedWatchers() {
1611
- await Promise.all([...this.watchers.values()]);
1625
+ while (true) {
1626
+ const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
1627
+ if (loops.length === 0) return;
1628
+ await Promise.all(loops);
1629
+ const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
1630
+ if (!stillLive) return;
1631
+ }
1612
1632
  }
1613
1633
  // -------------------------------------------------------------------------
1614
- // Conversation processing
1634
+ // Conversation processing (WI-3 — async dispatch)
1615
1635
  // -------------------------------------------------------------------------
1636
+ /**
1637
+ * Dispatch each pending message for a conversation to opencode's native queue
1638
+ * via `prompt_async` (Task 3.2) and register it with the conversation's
1639
+ * per-session watcher. Does NOT block on the turn and does NOT call
1640
+ * `markProcessing` here — that fires from the watcher on running-start.
1641
+ *
1642
+ * @returns the count of messages NEWLY dispatched (not already in-flight).
1643
+ */
1616
1644
  async processConversation(conv) {
1617
1645
  const sessionId = await this.ensureSession(conv);
1618
1646
  const messages = await this.getPendingMessages(conv.id);
1619
- let processed = 0;
1647
+ let dispatched = 0;
1620
1648
  for (const message of messages) {
1621
- const claimed = await this.markProcessing(conv.id, message.id);
1622
- if (!claimed) {
1623
- this.log({
1624
- level: "info",
1625
- message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
1626
- conversation_id: conv.id,
1627
- message_id: message.id
1628
- });
1649
+ if (this.dispatched.has(message.id)) {
1629
1650
  continue;
1630
1651
  }
1652
+ const opencodeMessageId = opencodeMessageIdFor(message.id);
1653
+ const options = {
1654
+ agent: message.opencode_agent ?? void 0,
1655
+ model: message.opencode_model ?? void 0
1656
+ };
1631
1657
  try {
1632
1658
  this.log({
1633
1659
  level: "info",
1634
- message: `Sending queued message ${message.id.slice(0, 8)} to OpenCode (session ${sessionId.slice(0, 8)})`,
1635
- conversation_id: conv.id,
1636
- message_id: message.id
1637
- });
1638
- const result = await sendMessageToOpenCode(
1639
- this.port,
1640
- sessionId,
1641
- message.content,
1642
- {
1643
- agent: message.opencode_agent ?? void 0,
1644
- model: message.opencode_model ?? void 0
1645
- },
1646
- {
1647
- onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1648
- onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1649
- }
1650
- );
1651
- if (result.awaitingInteraction) {
1652
- this.log({
1653
- level: "info",
1654
- message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
1655
- conversation_id: conv.id,
1656
- message_id: message.id
1657
- });
1658
- this.startPausedWatcher(conv, message, sessionId);
1659
- continue;
1660
- }
1661
- await this.confirmCompletion(sessionId);
1662
- await this.markDone(conv.id, message.id, sessionId);
1663
- processed += 1;
1664
- this.log({
1665
- level: "info",
1666
- message: `Message ${message.id.slice(0, 8)} processed`,
1660
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
1667
1661
  conversation_id: conv.id,
1668
1662
  message_id: message.id
1669
1663
  });
1664
+ await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1670
1665
  } catch (err) {
1671
1666
  if (err instanceof ChannelAuthError) throw err;
1667
+ this.dispatched.delete(message.id);
1672
1668
  await this.markFailed(conv.id, message.id).catch(() => {
1673
1669
  });
1674
1670
  this.log({
1675
1671
  level: "error",
1676
- message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1672
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1677
1673
  conversation_id: conv.id,
1678
1674
  message_id: message.id
1679
1675
  });
1676
+ continue;
1680
1677
  }
1678
+ this.dispatched.add(message.id);
1679
+ this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1680
+ dispatched += 1;
1681
1681
  }
1682
- return processed;
1682
+ this.ensureWatcherRunning(sessionId);
1683
+ return dispatched;
1683
1684
  }
1684
1685
  async ensureSession(conv) {
1685
1686
  const cached = this.sessions.get(conv.id);
@@ -1711,111 +1712,349 @@ var ChannelDriver = class {
1711
1712
  }
1712
1713
  return this.opencodeDirectory;
1713
1714
  }
1714
- /**
1715
- * Local reconcile: re-query `GET /session/:id/message` and check whether the
1716
- * last assistant message is message-level complete (`isTurnComplete`).
1717
- *
1718
- * This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
1719
- * blocking POST already returned, and `markDone` causes the server to re-fetch
1720
- * the messages itself (via `extractTextFromMessages`) when delivering the
1721
- * reply — so this round-trip never gates delivery. We keep it only to surface a
1722
- * truthful diagnostic when opencode hasn't yet recorded a completed assistant
1723
- * turn at reconcile time, then proceed to `markDone` regardless. Uses the
1724
- * injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
1725
- */
1726
- async confirmCompletion(sessionId) {
1727
- try {
1728
- const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1729
- if (!res.ok) return;
1730
- const body = await res.json();
1731
- const messages = Array.isArray(body) ? body : null;
1732
- if (!isTurnComplete(messages)) {
1733
- this.log({
1734
- level: "info",
1735
- message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
1736
- });
1737
- }
1738
- } catch {
1739
- }
1740
- }
1741
1715
  // -------------------------------------------------------------------------
1742
- // Paused-session watcher (WI-2-CLI)
1716
+ // Per-session watcher (WI-3)
1743
1717
  // -------------------------------------------------------------------------
1718
+ /** Register a freshly-dispatched message with its session's watcher state. */
1719
+ registerInFlight(conv, sessionId, message, opencodeMessageId) {
1720
+ let watcher = this.watchers.get(sessionId);
1721
+ if (!watcher) {
1722
+ watcher = {
1723
+ conv,
1724
+ inFlight: /* @__PURE__ */ new Map(),
1725
+ loop: null,
1726
+ reportedQuestions: /* @__PURE__ */ new Set(),
1727
+ reportedPermissions: /* @__PURE__ */ new Set()
1728
+ };
1729
+ this.watchers.set(sessionId, watcher);
1730
+ }
1731
+ const now = this.now();
1732
+ watcher.inFlight.set(message.id, {
1733
+ evidentMessageId: message.id,
1734
+ opencodeMessageId,
1735
+ message,
1736
+ dispatchedAt: now,
1737
+ deadline: now + this.pausedMaxWaitMs,
1738
+ started: false,
1739
+ done: false
1740
+ });
1741
+ }
1744
1742
  /**
1745
- * Start (but do NOT await) a watcher that resumes a paused turn to completion.
1746
- *
1747
- * Single-flight per message: if a watcher is already live for this message we
1748
- * skip. The returned watcher promise is tracked in `this.watchers` and removed
1749
- * when it settles; it never rejects (the body is fully guarded), so a failed
1750
- * poll/markDone can never crash the run loop — the cron stays as the safety net.
1743
+ * Start (but do NOT await) the per-session watcher loop if it has in-flight
1744
+ * work and is not already running. Single-flight per session. The loop is
1745
+ * tracked on the watcher and cleared when it settles; it never rejects (fully
1746
+ * guarded), so a failed poll/callback can never crash the run loop — the cron
1747
+ * stays as the safety net.
1751
1748
  */
1752
- startPausedWatcher(conv, message, sessionId) {
1753
- if (this.watchers.has(message.id)) return;
1754
- const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
1755
- this.watchers.delete(message.id);
1749
+ ensureWatcherRunning(sessionId) {
1750
+ const watcher = this.watchers.get(sessionId);
1751
+ if (!watcher) return;
1752
+ if (watcher.loop) return;
1753
+ if (watcher.inFlight.size === 0) {
1754
+ this.watchers.delete(sessionId);
1755
+ return;
1756
+ }
1757
+ const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
1758
+ watcher.loop = null;
1759
+ if (watcher.inFlight.size === 0) {
1760
+ this.watchers.delete(sessionId);
1761
+ }
1756
1762
  });
1757
- this.watchers.set(message.id, watcher);
1763
+ watcher.loop = loop;
1758
1764
  }
1759
1765
  /**
1760
- * Poll `GET /session/:id/message` until the SAME paused turn is message-level
1761
- * complete the last message is an ASSISTANT message whose
1762
- * `info.time.completed` is set (the user answered the question/permission in
1763
- * opencode web and opencode finished the turn) then complete it via the
1764
- * EXISTING `markDone` PATCH — NO re-send of the original message. The server
1765
- * re-fetches the assistant messages on completion, so the watcher does not
1766
- * pass any reply text.
1767
- *
1768
- * Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
1769
- * tests' injection) and reuses only the pure `isTurnComplete` predicate from
1770
- * `session.ts` we do NOT call `getSessionMessages` (which uses the global
1771
- * `fetch`) here.
1772
- *
1773
- * Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
1774
- * reset): on timeout we STOP and leave the message `processing` so the cron
1775
- * remains the last-resort safety net. The whole body is wrapped so any
1776
- * poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
1777
- * out of the run loop.
1766
+ * The per-session polling loop (WI-3). Once per tick it:
1767
+ * 1. polls `GET /session/:id/message` once and, per in-flight message,
1768
+ * computes `messageRunState` and fires markProcessing (queued→running) /
1769
+ * markDone (done) exactly once per transition;
1770
+ * 2. applies the idle-path re-dispatch guard (a dispatched message that never
1771
+ * APPEARS re-dispatch D1 obligation 2);
1772
+ * 3. polls `/question` + `/permission` (scoped to the session) and surfaces
1773
+ * NEW ones via `reportInteraction`, carrying the PAUSED message's own
1774
+ * `source_message_id`;
1775
+ * 4. drops messages that completed or timed out from the in-flight set.
1776
+ * Exits when the in-flight set empties. Never throws.
1778
1777
  */
1779
- async watchPausedSession(conv, message, sessionId) {
1780
- const deadline = Date.now() + this.pausedMaxWaitMs;
1778
+ async runWatcherLoop(sessionId, watcher) {
1781
1779
  try {
1782
- while (Date.now() < deadline) {
1780
+ while (watcher.inFlight.size > 0) {
1783
1781
  await this.sleep(this.pausedPollIntervalMs);
1784
- let completed = false;
1782
+ let messages = null;
1785
1783
  try {
1786
1784
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1787
1785
  if (res.ok) {
1788
1786
  const body = await res.json();
1789
- const messages = Array.isArray(body) ? body : null;
1790
- completed = isTurnComplete(messages);
1787
+ messages = Array.isArray(body) ? body : null;
1791
1788
  }
1792
1789
  } catch {
1793
1790
  continue;
1794
1791
  }
1795
- if (!completed) continue;
1792
+ for (const inFlight of [...watcher.inFlight.values()]) {
1793
+ await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
1794
+ }
1795
+ await this.pollInteractions(sessionId, watcher, messages);
1796
+ }
1797
+ } catch (err) {
1798
+ if (err instanceof ChannelAuthError) {
1799
+ this.log({
1800
+ level: "error",
1801
+ message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
1802
+ conversation_id: watcher.conv.id
1803
+ });
1804
+ for (const evidentMessageId of [...watcher.inFlight.keys()]) {
1805
+ this.removeInFlight(watcher, evidentMessageId);
1806
+ }
1807
+ return;
1808
+ }
1809
+ this.log({
1810
+ level: "error",
1811
+ message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1812
+ conversation_id: watcher.conv.id
1813
+ });
1814
+ }
1815
+ }
1816
+ /**
1817
+ * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1818
+ * Fires markProcessing on queued→running and markDone on done (each once),
1819
+ * applies the idle-path re-dispatch guard, and removes the message from the
1820
+ * in-flight set on completion or timeout.
1821
+ */
1822
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1823
+ const conv = watcher.conv;
1824
+ const state = messageRunState(messages, inFlight.opencodeMessageId);
1825
+ if ((state === "running" || state === "done") && !inFlight.started) {
1826
+ let claimed;
1827
+ try {
1828
+ claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
1829
+ } catch (err) {
1830
+ if (err instanceof ChannelAuthError) throw err;
1796
1831
  this.log({
1797
- level: "info",
1798
- message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
1832
+ level: "error",
1833
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1799
1834
  conversation_id: conv.id,
1800
- message_id: message.id
1835
+ message_id: inFlight.evidentMessageId
1801
1836
  });
1802
- await this.markDone(conv.id, message.id, sessionId);
1803
1837
  return;
1804
1838
  }
1839
+ inFlight.started = true;
1840
+ if (!claimed) {
1841
+ this.log({
1842
+ level: "info",
1843
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
1844
+ conversation_id: conv.id,
1845
+ message_id: inFlight.evidentMessageId
1846
+ });
1847
+ }
1848
+ }
1849
+ if (state === "done") {
1850
+ if (!inFlight.done) {
1851
+ this.log({
1852
+ level: "info",
1853
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1854
+ conversation_id: conv.id,
1855
+ message_id: inFlight.evidentMessageId
1856
+ });
1857
+ try {
1858
+ await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
1859
+ } catch (err) {
1860
+ if (err instanceof ChannelAuthError) throw err;
1861
+ if (err instanceof ChannelTerminalError) {
1862
+ this.log({
1863
+ level: "error",
1864
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
1865
+ conversation_id: conv.id,
1866
+ message_id: inFlight.evidentMessageId
1867
+ });
1868
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1869
+ return;
1870
+ }
1871
+ if (this.now() >= inFlight.deadline) {
1872
+ this.log({
1873
+ level: "error",
1874
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
1875
+ conversation_id: conv.id,
1876
+ message_id: inFlight.evidentMessageId
1877
+ });
1878
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1879
+ return;
1880
+ }
1881
+ this.log({
1882
+ level: "error",
1883
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1884
+ conversation_id: conv.id,
1885
+ message_id: inFlight.evidentMessageId
1886
+ });
1887
+ return;
1888
+ }
1889
+ inFlight.done = true;
1890
+ }
1891
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1892
+ return;
1893
+ }
1894
+ if (state === "unknown") {
1895
+ if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
1896
+ await this.redispatchInFlight(sessionId, inFlight);
1897
+ }
1898
+ }
1899
+ if (this.now() >= inFlight.deadline) {
1805
1900
  this.log({
1806
1901
  level: "info",
1807
- message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
1902
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
1808
1903
  conversation_id: conv.id,
1809
- message_id: message.id
1904
+ message_id: inFlight.evidentMessageId
1810
1905
  });
1906
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1907
+ }
1908
+ }
1909
+ /**
1910
+ * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
1911
+ * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
1912
+ * fact 9) — one user message + one reply even if the original DID land. Resets
1913
+ * the dispatch timestamp so the guard doesn't immediately fire again.
1914
+ */
1915
+ async redispatchInFlight(sessionId, inFlight) {
1916
+ const options = {
1917
+ agent: inFlight.message.opencode_agent ?? void 0,
1918
+ model: inFlight.message.opencode_model ?? void 0
1919
+ };
1920
+ this.log({
1921
+ level: "info",
1922
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
1923
+ message_id: inFlight.evidentMessageId
1924
+ });
1925
+ try {
1926
+ await sendPromptAsync(
1927
+ this.port,
1928
+ sessionId,
1929
+ inFlight.message.content,
1930
+ options,
1931
+ inFlight.opencodeMessageId
1932
+ );
1811
1933
  } catch (err) {
1812
1934
  this.log({
1813
1935
  level: "error",
1814
- message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1815
- conversation_id: conv.id,
1816
- message_id: message.id
1936
+ message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1937
+ message_id: inFlight.evidentMessageId
1938
+ });
1939
+ }
1940
+ inFlight.dispatchedAt = this.now();
1941
+ }
1942
+ /**
1943
+ * Remove a message from the in-flight set AND the authoritative dispatched
1944
+ * set. Once the in-flight set empties, the watcher loop's `while` guard exits
1945
+ * and its `.finally` removes the session entry from `this.watchers`.
1946
+ */
1947
+ removeInFlight(watcher, evidentMessageId) {
1948
+ watcher.inFlight.delete(evidentMessageId);
1949
+ this.dispatched.delete(evidentMessageId);
1950
+ }
1951
+ /**
1952
+ * Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
1953
+ * via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
1954
+ * `source_message_id` so the server @mentions the correct person under
1955
+ * concurrency. Dedups by interaction id across ticks (reused per-session sets).
1956
+ *
1957
+ * The interaction is attributed to the in-flight message it paused on. opencode
1958
+ * stamps a `messageID` on a permission (and `tool.messageID` on a question) =
1959
+ * the assistant message id, whose `parentID` is the user message id — but the
1960
+ * simplest robust attribution here is: the single in-flight message that is
1961
+ * RUNNING (not done) is the one that paused. With one running message that is
1962
+ * unambiguous; with several we prefer an explicit messageID match, else the
1963
+ * oldest running message.
1964
+ */
1965
+ async pollInteractions(sessionId, watcher, messages) {
1966
+ let questions = [];
1967
+ try {
1968
+ const res = await this.fetchImpl(`${this.opencodeBase}/question`);
1969
+ if (res.ok) {
1970
+ const body = await res.json();
1971
+ questions = Array.isArray(body) ? body : [];
1972
+ }
1973
+ } catch {
1974
+ }
1975
+ for (const q of questions) {
1976
+ if (q.sessionID !== sessionId) continue;
1977
+ if (watcher.reportedQuestions.has(q.id)) continue;
1978
+ const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
1979
+ const reported = await this.reportInteraction(
1980
+ watcher.conv.id,
1981
+ "question",
1982
+ q,
1983
+ paused?.message.source_message_id ?? void 0
1984
+ );
1985
+ if (reported) watcher.reportedQuestions.add(q.id);
1986
+ }
1987
+ let permissions = [];
1988
+ try {
1989
+ const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
1990
+ if (res.ok) {
1991
+ const body = await res.json();
1992
+ permissions = Array.isArray(body) ? body : [];
1993
+ }
1994
+ } catch {
1995
+ }
1996
+ for (const p of permissions) {
1997
+ if (p.sessionID !== sessionId) continue;
1998
+ if (watcher.reportedPermissions.has(p.id)) continue;
1999
+ const paused = this.attributeInteraction(watcher, p.messageID, messages);
2000
+ const reported = await this.reportInteraction(
2001
+ watcher.conv.id,
2002
+ "permission",
2003
+ p,
2004
+ paused?.message.source_message_id ?? void 0
2005
+ );
2006
+ if (reported) watcher.reportedPermissions.add(p.id);
2007
+ }
2008
+ }
2009
+ /**
2010
+ * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2011
+ *
2012
+ * The interaction carries `interactionMessageId` — the ASSISTANT message id
2013
+ * that raised it (a question's `tool.messageID` / a permission's `messageID`).
2014
+ * That assistant message is the reply to ONE of our minted user messages
2015
+ * (correlated by `parentID`, GATE-B). So when we have the tick's message
2016
+ * snapshot, we resolve each running in-flight message's correlated assistant
2017
+ * reply (`findAssistantReplyAfter`) and match its id against
2018
+ * `interactionMessageId` — giving an EXACT attribution even with several
2019
+ * messages in flight concurrently in one session.
2020
+ *
2021
+ * We fall back to the oldest running message ONLY when no exact match is
2022
+ * possible (the id is absent, the snapshot is missing, or the reply has not yet
2023
+ * been correlated). With a single running message either path is exact. Never
2024
+ * throws.
2025
+ *
2026
+ * Attribution must NOT depend on our own `started` PATCH flag: opencode can
2027
+ * START a turn AND raise a question/permission BEFORE our next tick fires
2028
+ * `markProcessing` (which sets `started`). Relying on `started` would leave the
2029
+ * running set empty in that window and let the server fall back to "newest
2030
+ * processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
2031
+ * person whose active turn actually paused. So we derive "running" from the
2032
+ * tick's `messages` snapshot via `messageRunState` instead.
2033
+ */
2034
+ attributeInteraction(watcher, interactionMessageId, messages) {
2035
+ const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
2036
+ if (inFlight.length === 0) return void 0;
2037
+ if (interactionMessageId && messages) {
2038
+ const exact = inFlight.find((m) => {
2039
+ const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
2040
+ return reply != null && messageIdOf(reply) === interactionMessageId;
1817
2041
  });
2042
+ if (exact) return exact;
2043
+ }
2044
+ const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
2045
+ if (messages) {
2046
+ const runningPerSnapshot = inFlight.filter(
2047
+ (m) => messageRunState(messages, m.opencodeMessageId) === "running"
2048
+ );
2049
+ if (runningPerSnapshot.length > 0) {
2050
+ return runningPerSnapshot.sort(byOldest)[0];
2051
+ }
1818
2052
  }
2053
+ const startedRunning = inFlight.filter((m) => m.started);
2054
+ if (startedRunning.length > 0) {
2055
+ return startedRunning.sort(byOldest)[0];
2056
+ }
2057
+ return inFlight.sort(byOldest)[0];
1819
2058
  }
1820
2059
  // -------------------------------------------------------------------------
1821
2060
  // Evident API calls (combinedAuth thread routes)
@@ -1849,36 +2088,86 @@ var ChannelDriver = class {
1849
2088
  }
1850
2089
  return await res.json();
1851
2090
  }
1852
- async markProcessing(conversationId, messageId) {
2091
+ /**
2092
+ * EXISTING combinedAuth route — now fired by the watcher on queued→running
2093
+ * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
2094
+ * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
2095
+ * deep-linked "View in Evident" notice).
2096
+ *
2097
+ * Return/throw contract (consumed by the watcher's swap-to-running guard):
2098
+ * - returns `true` → the server transitioned the row to processing;
2099
+ * - returns `false` → the server gave a DEFINITIVE "already-processing"
2100
+ * answer (a non-retryable, non-auth status — e.g. a
2101
+ * conflict because a duplicate already transitioned it),
2102
+ * so the caller treats it as already-started and does NOT
2103
+ * retry;
2104
+ * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
2105
+ * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
2106
+ * network-level error from `fetch`) — i.e. NO definitive server response —
2107
+ * so the caller leaves the message un-started and retries the swap on the
2108
+ * next tick.
2109
+ * A single attempt (no internal retry): the watcher's per-tick loop is the
2110
+ * retry vehicle for the swap-to-running.
2111
+ */
2112
+ async markProcessing(conversationId, messageId, sessionId) {
1853
2113
  const res = await this.fetchImpl(
1854
2114
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1855
2115
  {
1856
2116
  method: "PATCH",
1857
2117
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1858
- body: JSON.stringify({ status: "processing" })
2118
+ body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
1859
2119
  }
1860
2120
  );
1861
2121
  this.assertAuth(res, "marking message as processing");
1862
- return res.ok;
2122
+ if (res.ok) return true;
2123
+ if (isRetryableStatus(res.status)) {
2124
+ throw new Error(`marking message as processing: HTTP ${res.status}`);
2125
+ }
2126
+ return false;
1863
2127
  }
1864
2128
  /**
1865
- * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1866
- * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
2129
+ * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2130
+ * .../messages/:id {status:'done', opencode_session_id}`. The server's
1867
2131
  * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1868
- * for an already-`done` message a no-op (no double Slack post).
2132
+ * for an already-`done` message a no-op (no double Slack post). Fired by the
2133
+ * watcher on per-message completion (Task 3.4) — no `confirmCompletion`
2134
+ * round-trip (we already observed completion via the message list).
2135
+ *
2136
+ * SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
2137
+ * services its in-flight messages SEQUENTIALLY within a tick
2138
+ * (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
2139
+ * backoff here would BLOCK sibling messages in the SAME session/tick: while
2140
+ * message A's done PATCH burned its internal retries, message B could not be
2141
+ * swapped to running even though opencode had already started it. Instead this
2142
+ * does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
2143
+ * handler already relies on, leaning on the per-tick retry across ticks
2144
+ * (bounded by `inFlight.deadline`) rather than an in-call retry:
2145
+ * - resolves (`void`) → the server transitioned the row to done
2146
+ * (or idempotently confirmed already-done);
2147
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
2148
+ * cleanup, Finding 1);
2149
+ * - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
2150
+ * succeed → straight to the cron, Finding 4);
2151
+ * - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
2152
+ * (no definitive server response → the
2153
+ * watcher retries next tick within the
2154
+ * deadline, Finding 4).
1869
2155
  */
1870
2156
  async markDone(conversationId, messageId, sessionId) {
1871
- await this.callWithRetry(
1872
- "marking message as done",
1873
- () => this.fetchImpl(
1874
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1875
- {
1876
- method: "PATCH",
1877
- headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1878
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1879
- }
1880
- )
2157
+ const res = await this.fetchImpl(
2158
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2159
+ {
2160
+ method: "PATCH",
2161
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2162
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
2163
+ }
1881
2164
  );
2165
+ this.assertAuth(res, "marking message as done");
2166
+ if (res.ok) return;
2167
+ if (isRetryableStatus(res.status)) {
2168
+ throw new Error(`marking message as done: HTTP ${res.status}`);
2169
+ }
2170
+ throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
1882
2171
  }
1883
2172
  async markFailed(conversationId, messageId) {
1884
2173
  await this.callWithRetry(
@@ -1906,10 +2195,17 @@ var ChannelDriver = class {
1906
2195
  }
1907
2196
  /**
1908
2197
  * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1909
- * `POST .../interactive-event {type, data}`. The server persists the
1910
- * interaction and posts a link to the proxied opencode-web conversation.
2198
+ * `POST .../interactive-event {type, data, source_message_id?}`. The server
2199
+ * persists the interaction and posts a link to the proxied opencode-web
2200
+ * conversation, @mentioning the user who triggered THIS message's turn.
2201
+ *
2202
+ * WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
2203
+ * ts (`message.source_message_id`). The server resolves the @mention from that
2204
+ * message's user FIRST (falling back to the old "newest processing" precedence
2205
+ * only when absent), so the correct person is mentioned under concurrency. It
2206
+ * is OPTIONAL for back-compat with older clients / legacy rows.
1911
2207
  */
1912
- async reportInteraction(conversationId, type, data) {
2208
+ async reportInteraction(conversationId, type, data, sourceMessageId) {
1913
2209
  try {
1914
2210
  await this.callWithRetry(
1915
2211
  "reporting interactive event",
@@ -1918,7 +2214,9 @@ var ChannelDriver = class {
1918
2214
  {
1919
2215
  method: "POST",
1920
2216
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1921
- body: JSON.stringify({ type, data })
2217
+ body: JSON.stringify(
2218
+ sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2219
+ )
1922
2220
  }
1923
2221
  )
1924
2222
  );
@@ -1927,6 +2225,7 @@ var ChannelDriver = class {
1927
2225
  message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1928
2226
  conversation_id: conversationId
1929
2227
  });
2228
+ return true;
1930
2229
  } catch (err) {
1931
2230
  if (err instanceof ChannelAuthError) throw err;
1932
2231
  this.log({
@@ -1934,6 +2233,7 @@ var ChannelDriver = class {
1934
2233
  message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1935
2234
  conversation_id: conversationId
1936
2235
  });
2236
+ return false;
1937
2237
  }
1938
2238
  }
1939
2239
  // -------------------------------------------------------------------------
@@ -1972,8 +2272,9 @@ var ChannelDriver = class {
1972
2272
  await this.sleep(backoffDelay(attempt, this.retry));
1973
2273
  continue;
1974
2274
  }
2275
+ break;
1975
2276
  }
1976
- throw new Error(`${context}: HTTP ${res.status}`);
2277
+ throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
1977
2278
  }
1978
2279
  throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1979
2280
  }
@@ -2318,9 +2619,9 @@ async function driveChannels(state, driver) {
2318
2619
  try {
2319
2620
  const processed = await driver.drainPending();
2320
2621
  state.messageCount += processed;
2321
- if (processed > 0) {
2622
+ if (processed > 0 || driver.hasInFlightWatchers()) {
2322
2623
  idlePolls = 0;
2323
- if (state.interactive) displayStatus(state);
2624
+ if (processed > 0 && state.interactive) displayStatus(state);
2324
2625
  } else if (state.idleTimeout !== null) {
2325
2626
  idlePolls++;
2326
2627
  if (idlePolls === 1) {
@@ -2511,6 +2812,13 @@ async function run(options) {
2511
2812
  state.opencodeConnected = oc.process !== null || oc.version !== null;
2512
2813
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2513
2814
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2815
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
2816
+ if (versionWarning) {
2817
+ log(state, versionWarning, false);
2818
+ if (state.interactive && !state.json) {
2819
+ logActivity(state, { type: "info", message: versionWarning });
2820
+ }
2821
+ }
2514
2822
  } catch (error2) {
2515
2823
  ocSpinner?.fail(error2.message);
2516
2824
  throw error2;