@evident-ai/cli 3.0.1-dev.28a3007 → 3.0.1-dev.3be0a8d

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,19 +684,6 @@ 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
-
700
687
  // src/lib/opencode/process.ts
701
688
  import { execSync, spawn } from "child_process";
702
689
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -1011,17 +998,21 @@ function completedOf(m) {
1011
998
  if (!m || typeof m !== "object") return void 0;
1012
999
  return m.info?.time?.completed;
1013
1000
  }
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;
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
+ }
1019
1010
  }
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;
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;
1025
1016
  }
1026
1017
  async function createOpenCodeSession(port, directory) {
1027
1018
  const url = new URL(`${opencodeBase(port)}/session`);
@@ -1040,9 +1031,8 @@ async function createOpenCodeSession(port, directory) {
1040
1031
  const data = await response.json();
1041
1032
  return data.id;
1042
1033
  }
1043
- async function sendPromptAsync(port, sessionId, content, options, messageId) {
1034
+ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1044
1035
  const body = {
1045
- messageID: messageId,
1046
1036
  parts: [{ type: "text", text: content }]
1047
1037
  };
1048
1038
  if (options?.agent) {
@@ -1057,42 +1047,79 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1057
1047
  };
1058
1048
  }
1059
1049
  }
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}`;
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;
1096
1123
  }
1097
1124
 
1098
1125
  // src/lib/tunnel/connection.ts
@@ -1471,12 +1498,6 @@ var RunnerConnection = class {
1471
1498
  };
1472
1499
 
1473
1500
  // 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
- }
1480
1501
  var DEFAULT_RETRY_POLICY = {
1481
1502
  maxAttempts: 6,
1482
1503
  baseDelayMs: 500,
@@ -1484,21 +1505,12 @@ var DEFAULT_RETRY_POLICY = {
1484
1505
  };
1485
1506
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1486
1507
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1487
- var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1488
1508
  var ChannelAuthError = class extends Error {
1489
1509
  constructor(message) {
1490
1510
  super(message);
1491
1511
  this.name = "ChannelAuthError";
1492
1512
  }
1493
1513
  };
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
- };
1502
1514
  function backoffDelay(attempt, policy) {
1503
1515
  const exp = policy.baseDelayMs * Math.pow(2, attempt);
1504
1516
  const capped = Math.min(policy.maxDelayMs, exp);
@@ -1519,26 +1531,15 @@ var ChannelDriver = class {
1519
1531
  sleep;
1520
1532
  pausedPollIntervalMs;
1521
1533
  pausedMaxWaitMs;
1522
- dispatchConfirmMs;
1523
- now;
1524
1534
  /** Cache of conversationId → opencode sessionId. */
1525
1535
  sessions = /* @__PURE__ */ new Map();
1526
1536
  /**
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.
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.
1531
1541
  */
1532
1542
  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();
1542
1543
  /**
1543
1544
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1544
1545
  * first session creation so drain-created sessions are rooted at the project
@@ -1561,8 +1562,6 @@ var ChannelDriver = class {
1561
1562
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1562
1563
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1563
1564
  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());
1566
1565
  }
1567
1566
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1568
1567
  get opencodeBase() {
@@ -1572,16 +1571,16 @@ var ChannelDriver = class {
1572
1571
  // Public API
1573
1572
  // -------------------------------------------------------------------------
1574
1573
  /**
1575
- * Drain all pending channel conversations once: poll → dispatchregister.
1574
+ * Drain all pending channel conversations once: poll → processcallback.
1576
1575
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1577
1576
  * Re-entrant calls while a drain is in flight are skipped (return 0).
1578
1577
  *
1579
- * @returns the number of messages NEWLY dispatched to opencode's native queue.
1578
+ * @returns the number of messages processed.
1580
1579
  */
1581
1580
  async drainPending() {
1582
1581
  if (this.draining) return 0;
1583
1582
  this.draining = true;
1584
- let dispatched = 0;
1583
+ let processed = 0;
1585
1584
  try {
1586
1585
  const conversations = await this.getPendingConversations();
1587
1586
  if (conversations.length > 0) {
@@ -1592,95 +1591,95 @@ var ChannelDriver = class {
1592
1591
  });
1593
1592
  }
1594
1593
  for (const conv of conversations) {
1595
- dispatched += await this.processConversation(conv);
1594
+ processed += await this.processConversation(conv);
1596
1595
  }
1597
1596
  } finally {
1598
1597
  this.draining = false;
1599
1598
  }
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;
1599
+ return processed;
1613
1600
  }
1614
1601
  /**
1615
- * Await all outstanding per-session watchers (WI-3).
1602
+ * Await all outstanding paused-session watchers (WI-2-CLI).
1616
1603
  *
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.
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.
1623
1609
  */
1624
1610
  async flushPausedWatchers() {
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
- }
1611
+ await Promise.all([...this.watchers.values()]);
1632
1612
  }
1633
1613
  // -------------------------------------------------------------------------
1634
- // Conversation processing (WI-3 — async dispatch)
1614
+ // Conversation processing
1635
1615
  // -------------------------------------------------------------------------
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
- */
1644
1616
  async processConversation(conv) {
1645
1617
  const sessionId = await this.ensureSession(conv);
1646
1618
  const messages = await this.getPendingMessages(conv.id);
1647
- let dispatched = 0;
1619
+ let processed = 0;
1648
1620
  for (const message of messages) {
1649
- if (this.dispatched.has(message.id)) {
1621
+ const claimed = await this.markProcessing(conv.id, message.id, sessionId);
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
+ });
1650
1629
  continue;
1651
1630
  }
1652
- const opencodeMessageId = opencodeMessageIdFor(message.id);
1653
- const options = {
1654
- agent: message.opencode_agent ?? void 0,
1655
- model: message.opencode_model ?? void 0
1656
- };
1657
1631
  try {
1658
1632
  this.log({
1659
1633
  level: "info",
1660
- message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
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`,
1661
1667
  conversation_id: conv.id,
1662
1668
  message_id: message.id
1663
1669
  });
1664
- await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1665
1670
  } catch (err) {
1666
1671
  if (err instanceof ChannelAuthError) throw err;
1667
- this.dispatched.delete(message.id);
1668
1672
  await this.markFailed(conv.id, message.id).catch(() => {
1669
1673
  });
1670
1674
  this.log({
1671
1675
  level: "error",
1672
- message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1676
+ message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1673
1677
  conversation_id: conv.id,
1674
1678
  message_id: message.id
1675
1679
  });
1676
- continue;
1677
1680
  }
1678
- this.dispatched.add(message.id);
1679
- this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1680
- dispatched += 1;
1681
1681
  }
1682
- this.ensureWatcherRunning(sessionId);
1683
- return dispatched;
1682
+ return processed;
1684
1683
  }
1685
1684
  async ensureSession(conv) {
1686
1685
  const cached = this.sessions.get(conv.id);
@@ -1712,349 +1711,111 @@ var ChannelDriver = class {
1712
1711
  }
1713
1712
  return this.opencodeDirectory;
1714
1713
  }
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
+ }
1715
1741
  // -------------------------------------------------------------------------
1716
- // Per-session watcher (WI-3)
1742
+ // Paused-session watcher (WI-2-CLI)
1717
1743
  // -------------------------------------------------------------------------
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
- }
1742
1744
  /**
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.
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.
1748
1751
  */
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
- }
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);
1762
1756
  });
1763
- watcher.loop = loop;
1757
+ this.watchers.set(message.id, watcher);
1764
1758
  }
1765
1759
  /**
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.
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.
1777
1778
  */
1778
- async runWatcherLoop(sessionId, watcher) {
1779
+ async watchPausedSession(conv, message, sessionId) {
1780
+ const deadline = Date.now() + this.pausedMaxWaitMs;
1779
1781
  try {
1780
- while (watcher.inFlight.size > 0) {
1782
+ while (Date.now() < deadline) {
1781
1783
  await this.sleep(this.pausedPollIntervalMs);
1782
- let messages = null;
1784
+ let completed = false;
1783
1785
  try {
1784
1786
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1785
1787
  if (res.ok) {
1786
1788
  const body = await res.json();
1787
- messages = Array.isArray(body) ? body : null;
1789
+ const messages = Array.isArray(body) ? body : null;
1790
+ completed = isTurnComplete(messages);
1788
1791
  }
1789
1792
  } catch {
1790
1793
  continue;
1791
1794
  }
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;
1831
- this.log({
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)}`,
1834
- conversation_id: conv.id,
1835
- message_id: inFlight.evidentMessageId
1836
- });
1837
- return;
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) {
1795
+ if (!completed) continue;
1851
1796
  this.log({
1852
1797
  level: "info",
1853
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1798
+ message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
1854
1799
  conversation_id: conv.id,
1855
- message_id: inFlight.evidentMessageId
1800
+ message_id: message.id
1856
1801
  });
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);
1802
+ await this.markDone(conv.id, message.id, sessionId);
1803
+ return;
1897
1804
  }
1898
- }
1899
- if (this.now() >= inFlight.deadline) {
1900
1805
  this.log({
1901
1806
  level: "info",
1902
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
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`,
1903
1808
  conversation_id: conv.id,
1904
- message_id: inFlight.evidentMessageId
1809
+ message_id: message.id
1905
1810
  });
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
- );
1933
1811
  } catch (err) {
1934
1812
  this.log({
1935
1813
  level: "error",
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;
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
2041
1817
  });
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
- }
2052
1818
  }
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];
2058
1819
  }
2059
1820
  // -------------------------------------------------------------------------
2060
1821
  // Evident API calls (combinedAuth thread routes)
@@ -2088,86 +1849,39 @@ var ChannelDriver = class {
2088
1849
  }
2089
1850
  return await res.json();
2090
1851
  }
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
1852
  async markProcessing(conversationId, messageId, sessionId) {
2113
1853
  const res = await this.fetchImpl(
2114
1854
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2115
1855
  {
2116
1856
  method: "PATCH",
2117
1857
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1858
+ // Include the opencode session id so the server can deep-link the
1859
+ // "View in Evident" notice straight to the conversation (the session
1860
+ // already exists by now — ensureSession runs before claiming).
2118
1861
  body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
2119
1862
  }
2120
1863
  );
2121
1864
  this.assertAuth(res, "marking message as processing");
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;
1865
+ return res.ok;
2127
1866
  }
2128
1867
  /**
2129
- * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2130
- * .../messages/:id {status:'done', opencode_session_id}`. The server's
1868
+ * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1869
+ * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
2131
1870
  * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
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).
1871
+ * for an already-`done` message a no-op (no double Slack post).
2155
1872
  */
2156
1873
  async markDone(conversationId, messageId, sessionId) {
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
- }
1874
+ await this.callWithRetry(
1875
+ "marking message as done",
1876
+ () => this.fetchImpl(
1877
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1878
+ {
1879
+ method: "PATCH",
1880
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1881
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1882
+ }
1883
+ )
2164
1884
  );
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);
2171
1885
  }
2172
1886
  async markFailed(conversationId, messageId) {
2173
1887
  await this.callWithRetry(
@@ -2195,17 +1909,10 @@ var ChannelDriver = class {
2195
1909
  }
2196
1910
  /**
2197
1911
  * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
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.
1912
+ * `POST .../interactive-event {type, data}`. The server persists the
1913
+ * interaction and posts a link to the proxied opencode-web conversation.
2207
1914
  */
2208
- async reportInteraction(conversationId, type, data, sourceMessageId) {
1915
+ async reportInteraction(conversationId, type, data) {
2209
1916
  try {
2210
1917
  await this.callWithRetry(
2211
1918
  "reporting interactive event",
@@ -2214,9 +1921,7 @@ var ChannelDriver = class {
2214
1921
  {
2215
1922
  method: "POST",
2216
1923
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2217
- body: JSON.stringify(
2218
- sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2219
- )
1924
+ body: JSON.stringify({ type, data })
2220
1925
  }
2221
1926
  )
2222
1927
  );
@@ -2225,7 +1930,6 @@ var ChannelDriver = class {
2225
1930
  message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
2226
1931
  conversation_id: conversationId
2227
1932
  });
2228
- return true;
2229
1933
  } catch (err) {
2230
1934
  if (err instanceof ChannelAuthError) throw err;
2231
1935
  this.log({
@@ -2233,7 +1937,6 @@ var ChannelDriver = class {
2233
1937
  message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
2234
1938
  conversation_id: conversationId
2235
1939
  });
2236
- return false;
2237
1940
  }
2238
1941
  }
2239
1942
  // -------------------------------------------------------------------------
@@ -2272,9 +1975,8 @@ var ChannelDriver = class {
2272
1975
  await this.sleep(backoffDelay(attempt, this.retry));
2273
1976
  continue;
2274
1977
  }
2275
- break;
2276
1978
  }
2277
- throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
1979
+ throw new Error(`${context}: HTTP ${res.status}`);
2278
1980
  }
2279
1981
  throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
2280
1982
  }
@@ -2619,9 +2321,9 @@ async function driveChannels(state, driver) {
2619
2321
  try {
2620
2322
  const processed = await driver.drainPending();
2621
2323
  state.messageCount += processed;
2622
- if (processed > 0 || driver.hasInFlightWatchers()) {
2324
+ if (processed > 0) {
2623
2325
  idlePolls = 0;
2624
- if (processed > 0 && state.interactive) displayStatus(state);
2326
+ if (state.interactive) displayStatus(state);
2625
2327
  } else if (state.idleTimeout !== null) {
2626
2328
  idlePolls++;
2627
2329
  if (idlePolls === 1) {
@@ -2812,13 +2514,6 @@ async function run(options) {
2812
2514
  state.opencodeConnected = oc.process !== null || oc.version !== null;
2813
2515
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2814
2516
  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
- }
2822
2517
  } catch (error2) {
2823
2518
  ocSpinner?.fail(error2.message);
2824
2519
  throw error2;