@evident-ai/cli 3.0.1-dev.116734c → 3.0.1-dev.23d3337

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
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { createRequire } from "module";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/commands/login.ts
@@ -470,6 +471,12 @@ import chalk6 from "chalk";
470
471
  import ora3 from "ora";
471
472
  import { select as select3 } from "@inquirer/prompts";
472
473
 
474
+ // ../../packages/types/src/opencode/index.ts
475
+ function opencodeMessageIdFor(queuedMessageId) {
476
+ const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
477
+ return `msg_${sanitized}`;
478
+ }
479
+
473
480
  // ../../packages/types/src/telemetry/index.ts
474
481
  var TelemetryEventTypes = {
475
482
  // Agent activity events (shown in web UI activity log)
@@ -484,6 +491,29 @@ var TelemetryEventTypes = {
484
491
  var MAX_FRAME_BYTES = 256 * 1024;
485
492
  var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
486
493
 
494
+ // ../../packages/types/src/logging/index.ts
495
+ var CORRELATION_ID_HEADER = "x-evident-correlation-id";
496
+ function log(level, event, fields) {
497
+ const method = level === "debug" ? "log" : level;
498
+ try {
499
+ console[method]("[evident]", JSON.stringify({ level, event, ...fields }));
500
+ } catch (err) {
501
+ console.error(
502
+ "[evident] log_serialize_failed",
503
+ event,
504
+ err instanceof Error ? err.message : String(err)
505
+ );
506
+ }
507
+ }
508
+ function stripQuery(url) {
509
+ try {
510
+ return new URL(url).pathname;
511
+ } catch {
512
+ const q = url.indexOf("?");
513
+ return q === -1 ? url : url.slice(0, q);
514
+ }
515
+ }
516
+
487
517
  // src/lib/telemetry.ts
488
518
  var CLI_VERSION = process.env.npm_package_version || "unknown";
489
519
  var eventBuffer = [];
@@ -684,6 +714,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
684
714
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
685
715
  }
686
716
 
717
+ // src/lib/opencode/opencode-version-gate.ts
718
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
719
+ function isQueueValidatedVersion(version2) {
720
+ if (!version2) return false;
721
+ return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
722
+ }
723
+ function buildOpenCodeVersionWarning(version2) {
724
+ if (isQueueValidatedVersion(version2)) return null;
725
+ const detected = version2 ? `v${version2}` : "unknown";
726
+ const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
727
+ 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.`;
728
+ }
729
+
687
730
  // src/lib/opencode/process.ts
688
731
  import { execSync, spawn } from "child_process";
689
732
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -998,21 +1041,27 @@ function completedOf(m) {
998
1041
  if (!m || typeof m !== "object") return void 0;
999
1042
  return m.info?.time?.completed;
1000
1043
  }
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
- }
1044
+ function idOf(m) {
1045
+ if (!m || typeof m !== "object") return void 0;
1046
+ if (typeof m.id === "string") return m.id;
1047
+ const infoId = m.info?.id;
1048
+ return typeof infoId === "string" ? infoId : void 0;
1010
1049
  }
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;
1050
+ function parentIdOf(m) {
1051
+ if (!m || typeof m !== "object") return void 0;
1052
+ if (typeof m.parentID === "string") return m.parentID;
1053
+ const infoParent = m.info?.parentID;
1054
+ return typeof infoParent === "string" ? infoParent : void 0;
1055
+ }
1056
+ function finishOf(m) {
1057
+ if (!m || typeof m !== "object") return void 0;
1058
+ if (typeof m.finish === "string") return m.finish;
1059
+ const infoFinish = m.info?.finish;
1060
+ return typeof infoFinish === "string" ? infoFinish : void 0;
1061
+ }
1062
+ function isAssistantInFlight(m) {
1063
+ if (completedOf(m) == null) return true;
1064
+ return finishOf(m) === "tool-calls";
1016
1065
  }
1017
1066
  async function createOpenCodeSession(port, directory) {
1018
1067
  const url = new URL(`${opencodeBase(port)}/session`);
@@ -1031,8 +1080,9 @@ async function createOpenCodeSession(port, directory) {
1031
1080
  const data = await response.json();
1032
1081
  return data.id;
1033
1082
  }
1034
- async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1083
+ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1035
1084
  const body = {
1085
+ messageID: messageId,
1036
1086
  parts: [{ type: "text", text: content }]
1037
1087
  };
1038
1088
  if (options?.agent) {
@@ -1047,79 +1097,63 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1047
1097
  };
1048
1098
  }
1049
1099
  }
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;
1100
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
1101
+ method: "POST",
1102
+ headers: { "Content-Type": "application/json" },
1103
+ body: JSON.stringify(body)
1104
+ });
1105
+ if (res.status < 200 || res.status >= 300) {
1106
+ const text = await res.text().catch(() => "");
1107
+ throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1108
+ }
1109
+ }
1110
+ function findAssistantReplyAfter(messages, userMessageId) {
1111
+ if (!messages || messages.length === 0) return null;
1112
+ const byParent = messages.find(
1113
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1114
+ );
1115
+ if (byParent) return byParent;
1116
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1117
+ if (userIndex === -1) return null;
1118
+ for (let i = userIndex + 1; i < messages.length; i++) {
1119
+ if (roleOf(messages[i]) === "assistant") return messages[i];
1120
+ }
1121
+ return null;
1122
+ }
1123
+ function findLastAssistantReplyFor(messages, userMessageId) {
1124
+ if (!messages || messages.length === 0) return null;
1125
+ for (let i = messages.length - 1; i >= 0; i--) {
1126
+ const m = messages[i];
1127
+ if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1128
+ }
1129
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1130
+ if (userIndex === -1) return null;
1131
+ let last = null;
1132
+ for (let i = userIndex + 1; i < messages.length; i++) {
1133
+ const role = roleOf(messages[i]);
1134
+ if (role === "user") break;
1135
+ if (role === "assistant") last = messages[i];
1136
+ }
1137
+ return last;
1138
+ }
1139
+ function messageRunState(messages, userMessageId) {
1140
+ if (!messages || messages.length === 0) return "unknown";
1141
+ const hasUser = messages.some((m) => idOf(m) === userMessageId);
1142
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1143
+ if (!hasUser) {
1144
+ if (!reply) return "unknown";
1145
+ }
1146
+ if (!reply) return "queued";
1147
+ return isAssistantInFlight(reply) ? "running" : "done";
1148
+ }
1149
+ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1150
+ if (!messages || messages.length === 0) return false;
1151
+ return messages.some(
1152
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1153
+ );
1154
+ }
1155
+ function opencodeMessageIdFor2(queuedMessageId) {
1156
+ return opencodeMessageIdFor(queuedMessageId);
1123
1157
  }
1124
1158
 
1125
1159
  // src/lib/tunnel/connection.ts
@@ -1190,12 +1224,20 @@ var StreamForwarder = class {
1190
1224
  }
1191
1225
  async handleOpen(frame) {
1192
1226
  const { sid, method, path, headers, has_body } = frame;
1227
+ const correlationId = headers?.[CORRELATION_ID_HEADER];
1228
+ const startedAt = Date.now();
1193
1229
  if (path === TUNNEL_DRAIN_PING_PATH) {
1194
1230
  this.callbacks.onDrainPing?.();
1195
1231
  this.send({ type: "head", sid, status: 204, headers: {} });
1196
1232
  this.send({ type: "res_end", sid });
1197
1233
  return;
1198
1234
  }
1235
+ log("info", "agent_request", {
1236
+ correlation_id: correlationId,
1237
+ sid,
1238
+ method,
1239
+ path: stripQuery(path)
1240
+ });
1199
1241
  const ac = new AbortController();
1200
1242
  let bodyPromise;
1201
1243
  let pushBody;
@@ -1242,6 +1284,12 @@ var StreamForwarder = class {
1242
1284
  if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1243
1285
  });
1244
1286
  this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1287
+ log("info", "agent_response", {
1288
+ correlation_id: correlationId,
1289
+ sid,
1290
+ status: upstream.status,
1291
+ duration_ms: Date.now() - startedAt
1292
+ });
1245
1293
  this.callbacks.onHead?.(sid, upstream.status);
1246
1294
  try {
1247
1295
  if (upstream.body) {
@@ -1498,6 +1546,12 @@ var RunnerConnection = class {
1498
1546
  };
1499
1547
 
1500
1548
  // src/lib/channels/driver.ts
1549
+ function messageIdOf(m) {
1550
+ if (!m || typeof m !== "object") return void 0;
1551
+ if (typeof m.id === "string") return m.id;
1552
+ const infoId = m.info?.id;
1553
+ return typeof infoId === "string" ? infoId : void 0;
1554
+ }
1501
1555
  var DEFAULT_RETRY_POLICY = {
1502
1556
  maxAttempts: 6,
1503
1557
  baseDelayMs: 500,
@@ -1505,12 +1559,22 @@ var DEFAULT_RETRY_POLICY = {
1505
1559
  };
1506
1560
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1507
1561
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1562
+ var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1563
+ var DEFAULT_STUCK_QUEUED_MS = 6e4;
1508
1564
  var ChannelAuthError = class extends Error {
1509
1565
  constructor(message) {
1510
1566
  super(message);
1511
1567
  this.name = "ChannelAuthError";
1512
1568
  }
1513
1569
  };
1570
+ var ChannelTerminalError = class extends Error {
1571
+ status;
1572
+ constructor(message, status) {
1573
+ super(message);
1574
+ this.name = "ChannelTerminalError";
1575
+ this.status = status;
1576
+ }
1577
+ };
1514
1578
  function backoffDelay(attempt, policy) {
1515
1579
  const exp = policy.baseDelayMs * Math.pow(2, attempt);
1516
1580
  const capped = Math.min(policy.maxDelayMs, exp);
@@ -1531,15 +1595,27 @@ var ChannelDriver = class {
1531
1595
  sleep;
1532
1596
  pausedPollIntervalMs;
1533
1597
  pausedMaxWaitMs;
1598
+ dispatchConfirmMs;
1599
+ stuckQueuedMs;
1600
+ now;
1534
1601
  /** Cache of conversationId → opencode sessionId. */
1535
1602
  sessions = /* @__PURE__ */ new Map();
1536
1603
  /**
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.
1604
+ * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1605
+ * session: one polling loop services all of that session's in-flight messages.
1606
+ * A session entry exists while it has any in-flight (dispatched-but-not-done)
1607
+ * message; it is removed once its in-flight set empties.
1541
1608
  */
1542
1609
  watchers = /* @__PURE__ */ new Map();
1610
+ /**
1611
+ * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
1612
+ * dispatched and are still in-flight. A message in this set is never
1613
+ * re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
1614
+ * Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
1615
+ * idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
1616
+ * a steady-state-poll re-dispatch will not double-run the message.
1617
+ */
1618
+ dispatched = /* @__PURE__ */ new Set();
1543
1619
  /**
1544
1620
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1545
1621
  * first session creation so drain-created sessions are rooted at the project
@@ -1562,6 +1638,9 @@ var ChannelDriver = class {
1562
1638
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1563
1639
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1564
1640
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1641
+ this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1642
+ this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1643
+ this.now = config2.now ?? (() => Date.now());
1565
1644
  }
1566
1645
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1567
1646
  get opencodeBase() {
@@ -1571,16 +1650,16 @@ var ChannelDriver = class {
1571
1650
  // Public API
1572
1651
  // -------------------------------------------------------------------------
1573
1652
  /**
1574
- * Drain all pending channel conversations once: poll → processcallback.
1653
+ * Drain all pending channel conversations once: poll → dispatchregister.
1575
1654
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1576
1655
  * Re-entrant calls while a drain is in flight are skipped (return 0).
1577
1656
  *
1578
- * @returns the number of messages processed.
1657
+ * @returns the number of messages NEWLY dispatched to opencode's native queue.
1579
1658
  */
1580
1659
  async drainPending() {
1581
1660
  if (this.draining) return 0;
1582
1661
  this.draining = true;
1583
- let processed = 0;
1662
+ let dispatched = 0;
1584
1663
  try {
1585
1664
  const conversations = await this.getPendingConversations();
1586
1665
  if (conversations.length > 0) {
@@ -1591,95 +1670,105 @@ var ChannelDriver = class {
1591
1670
  });
1592
1671
  }
1593
1672
  for (const conv of conversations) {
1594
- processed += await this.processConversation(conv);
1673
+ dispatched += await this.processConversation(conv);
1595
1674
  }
1596
1675
  } finally {
1597
1676
  this.draining = false;
1598
1677
  }
1599
- return processed;
1678
+ return dispatched;
1679
+ }
1680
+ /**
1681
+ * True while any per-session watcher has a non-empty in-flight dispatched set
1682
+ * (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
1683
+ * the process while a dispatched message is still queued/running — which would
1684
+ * kill the turn and orphan its reply.
1685
+ */
1686
+ hasInFlightWatchers() {
1687
+ for (const watcher of this.watchers.values()) {
1688
+ if (watcher.inFlight.size > 0) return true;
1689
+ }
1690
+ return false;
1600
1691
  }
1601
1692
  /**
1602
- * Await all outstanding paused-session watchers (WI-2-CLI).
1693
+ * Await all outstanding per-session watchers (WI-3).
1603
1694
  *
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.
1695
+ * In production the watcher loops are deliberately started-not-awaited so the
1696
+ * drain loop never blocks on them and process exit is not held up (the cron
1697
+ * recovers any abandoned ones). This helper exists primarily for deterministic
1698
+ * tests that need to observe a watcher's effect (the `processing`/`done` PATCH
1699
+ * or its giving up) after a non-blocking `drainPending`. Watcher loops never
1700
+ * reject, so this resolves.
1609
1701
  */
1610
1702
  async flushPausedWatchers() {
1611
- await Promise.all([...this.watchers.values()]);
1703
+ while (true) {
1704
+ const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
1705
+ if (loops.length === 0) return;
1706
+ await Promise.all(loops);
1707
+ const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
1708
+ if (!stillLive) return;
1709
+ }
1612
1710
  }
1613
1711
  // -------------------------------------------------------------------------
1614
- // Conversation processing
1712
+ // Conversation processing (WI-3 — async dispatch)
1615
1713
  // -------------------------------------------------------------------------
1714
+ /**
1715
+ * Dispatch each pending message for a conversation to opencode's native queue
1716
+ * via `prompt_async` (Task 3.2) and register it with the conversation's
1717
+ * per-session watcher. Does NOT block on the turn and does NOT call
1718
+ * `markProcessing` here — that fires from the watcher on running-start.
1719
+ *
1720
+ * @returns the count of messages NEWLY dispatched (not already in-flight).
1721
+ */
1616
1722
  async processConversation(conv) {
1617
1723
  const sessionId = await this.ensureSession(conv);
1618
1724
  const messages = await this.getPendingMessages(conv.id);
1619
- let processed = 0;
1725
+ let dispatched = 0;
1726
+ let skippedAlreadyDispatched = 0;
1620
1727
  for (const message of messages) {
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
- });
1728
+ if (this.dispatched.has(message.id)) {
1729
+ skippedAlreadyDispatched += 1;
1629
1730
  continue;
1630
1731
  }
1732
+ const opencodeMessageId = opencodeMessageIdFor2(message.id);
1733
+ const options = {
1734
+ agent: message.opencode_agent ?? void 0,
1735
+ model: message.opencode_model ?? void 0
1736
+ };
1631
1737
  try {
1632
1738
  this.log({
1633
1739
  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`,
1740
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
1667
1741
  conversation_id: conv.id,
1668
1742
  message_id: message.id
1669
1743
  });
1744
+ await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1670
1745
  } catch (err) {
1671
1746
  if (err instanceof ChannelAuthError) throw err;
1747
+ this.dispatched.delete(message.id);
1672
1748
  await this.markFailed(conv.id, message.id).catch(() => {
1673
1749
  });
1674
1750
  this.log({
1675
1751
  level: "error",
1676
- message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1752
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1677
1753
  conversation_id: conv.id,
1678
1754
  message_id: message.id
1679
1755
  });
1756
+ continue;
1680
1757
  }
1758
+ this.dispatched.add(message.id);
1759
+ this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1760
+ dispatched += 1;
1761
+ void this.postSignal(conv.id, message.id, "dispatched");
1681
1762
  }
1682
- return processed;
1763
+ if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
1764
+ this.log({
1765
+ level: "error",
1766
+ message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
1767
+ conversation_id: conv.id
1768
+ });
1769
+ }
1770
+ this.ensureWatcherRunning(sessionId);
1771
+ return dispatched;
1683
1772
  }
1684
1773
  async ensureSession(conv) {
1685
1774
  const cached = this.sessions.get(conv.id);
@@ -1711,111 +1800,356 @@ var ChannelDriver = class {
1711
1800
  }
1712
1801
  return this.opencodeDirectory;
1713
1802
  }
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
1803
  // -------------------------------------------------------------------------
1742
- // Paused-session watcher (WI-2-CLI)
1804
+ // Per-session watcher (WI-3)
1743
1805
  // -------------------------------------------------------------------------
1806
+ /** Register a freshly-dispatched message with its session's watcher state. */
1807
+ registerInFlight(conv, sessionId, message, opencodeMessageId) {
1808
+ let watcher = this.watchers.get(sessionId);
1809
+ if (!watcher) {
1810
+ watcher = {
1811
+ conv,
1812
+ inFlight: /* @__PURE__ */ new Map(),
1813
+ loop: null,
1814
+ reportedQuestions: /* @__PURE__ */ new Set(),
1815
+ reportedPermissions: /* @__PURE__ */ new Set()
1816
+ };
1817
+ this.watchers.set(sessionId, watcher);
1818
+ }
1819
+ const now = this.now();
1820
+ watcher.inFlight.set(message.id, {
1821
+ evidentMessageId: message.id,
1822
+ opencodeMessageId,
1823
+ message,
1824
+ dispatchedAt: now,
1825
+ deadline: now + this.pausedMaxWaitMs,
1826
+ started: false,
1827
+ done: false,
1828
+ stuckReported: false
1829
+ });
1830
+ }
1744
1831
  /**
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.
1832
+ * Start (but do NOT await) the per-session watcher loop if it has in-flight
1833
+ * work and is not already running. Single-flight per session. The loop is
1834
+ * tracked on the watcher and cleared when it settles; it never rejects (fully
1835
+ * guarded), so a failed poll/callback can never crash the run loop — the cron
1836
+ * stays as the safety net.
1751
1837
  */
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);
1838
+ ensureWatcherRunning(sessionId) {
1839
+ const watcher = this.watchers.get(sessionId);
1840
+ if (!watcher) return;
1841
+ if (watcher.loop) return;
1842
+ if (watcher.inFlight.size === 0) {
1843
+ this.watchers.delete(sessionId);
1844
+ return;
1845
+ }
1846
+ const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
1847
+ watcher.loop = null;
1848
+ if (watcher.inFlight.size === 0) {
1849
+ this.watchers.delete(sessionId);
1850
+ }
1756
1851
  });
1757
- this.watchers.set(message.id, watcher);
1852
+ watcher.loop = loop;
1758
1853
  }
1759
1854
  /**
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.
1855
+ * The per-session polling loop (WI-3). Once per tick it:
1856
+ * 1. polls `GET /session/:id/message` once and, per in-flight message,
1857
+ * computes `messageRunState` and fires markProcessing (queued→running) /
1858
+ * markDone (done) exactly once per transition;
1859
+ * 2. applies the idle-path re-dispatch guard (a dispatched message that never
1860
+ * APPEARS re-dispatch D1 obligation 2);
1861
+ * 3. polls `/question` + `/permission` (scoped to the session) and surfaces
1862
+ * NEW ones via `reportInteraction`, carrying the PAUSED message's own
1863
+ * `source_message_id`;
1864
+ * 4. drops messages that completed or timed out from the in-flight set.
1865
+ * Exits when the in-flight set empties. Never throws.
1778
1866
  */
1779
- async watchPausedSession(conv, message, sessionId) {
1780
- const deadline = Date.now() + this.pausedMaxWaitMs;
1867
+ async runWatcherLoop(sessionId, watcher) {
1781
1868
  try {
1782
- while (Date.now() < deadline) {
1869
+ while (watcher.inFlight.size > 0) {
1783
1870
  await this.sleep(this.pausedPollIntervalMs);
1784
- let completed = false;
1871
+ let messages = null;
1785
1872
  try {
1786
1873
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1787
1874
  if (res.ok) {
1788
1875
  const body = await res.json();
1789
- const messages = Array.isArray(body) ? body : null;
1790
- completed = isTurnComplete(messages);
1876
+ messages = Array.isArray(body) ? body : null;
1791
1877
  }
1792
1878
  } catch {
1793
1879
  continue;
1794
1880
  }
1795
- if (!completed) continue;
1881
+ for (const inFlight of [...watcher.inFlight.values()]) {
1882
+ await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
1883
+ }
1884
+ await this.pollInteractions(sessionId, watcher, messages);
1885
+ }
1886
+ } catch (err) {
1887
+ if (err instanceof ChannelAuthError) {
1796
1888
  this.log({
1797
- level: "info",
1798
- message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
1889
+ level: "error",
1890
+ 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}`,
1891
+ conversation_id: watcher.conv.id
1892
+ });
1893
+ for (const evidentMessageId of [...watcher.inFlight.keys()]) {
1894
+ this.removeInFlight(watcher, evidentMessageId);
1895
+ }
1896
+ return;
1897
+ }
1898
+ this.log({
1899
+ level: "error",
1900
+ message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1901
+ conversation_id: watcher.conv.id
1902
+ });
1903
+ }
1904
+ }
1905
+ /**
1906
+ * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1907
+ * Fires markProcessing on queued→running and markDone on done (each once),
1908
+ * applies the idle-path re-dispatch guard, and removes the message from the
1909
+ * in-flight set on completion or timeout.
1910
+ */
1911
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1912
+ const conv = watcher.conv;
1913
+ const state = messageRunState(messages, inFlight.opencodeMessageId);
1914
+ if ((state === "running" || state === "done") && !inFlight.started) {
1915
+ let claimed;
1916
+ try {
1917
+ claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
1918
+ } catch (err) {
1919
+ if (err instanceof ChannelAuthError) throw err;
1920
+ this.log({
1921
+ level: "error",
1922
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1799
1923
  conversation_id: conv.id,
1800
- message_id: message.id
1924
+ message_id: inFlight.evidentMessageId
1801
1925
  });
1802
- await this.markDone(conv.id, message.id, sessionId);
1803
1926
  return;
1804
1927
  }
1928
+ inFlight.started = true;
1929
+ if (!claimed) {
1930
+ this.log({
1931
+ level: "info",
1932
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
1933
+ conversation_id: conv.id,
1934
+ message_id: inFlight.evidentMessageId
1935
+ });
1936
+ }
1937
+ }
1938
+ if (state === "done") {
1939
+ if (!inFlight.done) {
1940
+ this.log({
1941
+ level: "info",
1942
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1943
+ conversation_id: conv.id,
1944
+ message_id: inFlight.evidentMessageId
1945
+ });
1946
+ try {
1947
+ await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
1948
+ } catch (err) {
1949
+ if (err instanceof ChannelAuthError) throw err;
1950
+ if (err instanceof ChannelTerminalError) {
1951
+ this.log({
1952
+ level: "error",
1953
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
1954
+ conversation_id: conv.id,
1955
+ message_id: inFlight.evidentMessageId
1956
+ });
1957
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1958
+ return;
1959
+ }
1960
+ if (this.now() >= inFlight.deadline) {
1961
+ this.log({
1962
+ level: "error",
1963
+ 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)}`,
1964
+ conversation_id: conv.id,
1965
+ message_id: inFlight.evidentMessageId
1966
+ });
1967
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1968
+ return;
1969
+ }
1970
+ this.log({
1971
+ level: "error",
1972
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1973
+ conversation_id: conv.id,
1974
+ message_id: inFlight.evidentMessageId
1975
+ });
1976
+ return;
1977
+ }
1978
+ inFlight.done = true;
1979
+ }
1980
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1981
+ return;
1982
+ }
1983
+ if (state === "unknown") {
1984
+ if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
1985
+ await this.redispatchInFlight(sessionId, inFlight);
1986
+ }
1987
+ }
1988
+ if (state === "queued" && !inFlight.started && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
1989
+ inFlight.stuckReported = true;
1990
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
1991
+ stuck_for_ms: this.now() - inFlight.dispatchedAt
1992
+ });
1993
+ }
1994
+ if (this.now() >= inFlight.deadline) {
1805
1995
  this.log({
1806
1996
  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`,
1997
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
1808
1998
  conversation_id: conv.id,
1809
- message_id: message.id
1999
+ message_id: inFlight.evidentMessageId
1810
2000
  });
2001
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
2006
+ * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
2007
+ * fact 9) — one user message + one reply even if the original DID land. Resets
2008
+ * the dispatch timestamp so the guard doesn't immediately fire again.
2009
+ */
2010
+ async redispatchInFlight(sessionId, inFlight) {
2011
+ const options = {
2012
+ agent: inFlight.message.opencode_agent ?? void 0,
2013
+ model: inFlight.message.opencode_model ?? void 0
2014
+ };
2015
+ this.log({
2016
+ level: "info",
2017
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
2018
+ message_id: inFlight.evidentMessageId
2019
+ });
2020
+ try {
2021
+ await sendPromptAsync(
2022
+ this.port,
2023
+ sessionId,
2024
+ inFlight.message.content,
2025
+ options,
2026
+ inFlight.opencodeMessageId
2027
+ );
1811
2028
  } catch (err) {
1812
2029
  this.log({
1813
2030
  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
2031
+ message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
2032
+ message_id: inFlight.evidentMessageId
2033
+ });
2034
+ }
2035
+ inFlight.dispatchedAt = this.now();
2036
+ }
2037
+ /**
2038
+ * Remove a message from the in-flight set AND the authoritative dispatched
2039
+ * set. Once the in-flight set empties, the watcher loop's `while` guard exits
2040
+ * and its `.finally` removes the session entry from `this.watchers`.
2041
+ */
2042
+ removeInFlight(watcher, evidentMessageId) {
2043
+ watcher.inFlight.delete(evidentMessageId);
2044
+ this.dispatched.delete(evidentMessageId);
2045
+ }
2046
+ /**
2047
+ * Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
2048
+ * via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
2049
+ * `source_message_id` so the server @mentions the correct person under
2050
+ * concurrency. Dedups by interaction id across ticks (reused per-session sets).
2051
+ *
2052
+ * The interaction is attributed to the in-flight message it paused on. opencode
2053
+ * stamps a `messageID` on a permission (and `tool.messageID` on a question) =
2054
+ * the assistant message id, whose `parentID` is the user message id — but the
2055
+ * simplest robust attribution here is: the single in-flight message that is
2056
+ * RUNNING (not done) is the one that paused. With one running message that is
2057
+ * unambiguous; with several we prefer an explicit messageID match, else the
2058
+ * oldest running message.
2059
+ */
2060
+ async pollInteractions(sessionId, watcher, messages) {
2061
+ let questions = [];
2062
+ try {
2063
+ const res = await this.fetchImpl(`${this.opencodeBase}/question`);
2064
+ if (res.ok) {
2065
+ const body = await res.json();
2066
+ questions = Array.isArray(body) ? body : [];
2067
+ }
2068
+ } catch {
2069
+ }
2070
+ for (const q of questions) {
2071
+ if (q.sessionID !== sessionId) continue;
2072
+ if (watcher.reportedQuestions.has(q.id)) continue;
2073
+ const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
2074
+ const reported = await this.reportInteraction(
2075
+ watcher.conv.id,
2076
+ "question",
2077
+ q,
2078
+ paused?.message.source_message_id ?? void 0
2079
+ );
2080
+ if (reported) watcher.reportedQuestions.add(q.id);
2081
+ }
2082
+ let permissions = [];
2083
+ try {
2084
+ const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2085
+ if (res.ok) {
2086
+ const body = await res.json();
2087
+ permissions = Array.isArray(body) ? body : [];
2088
+ }
2089
+ } catch {
2090
+ }
2091
+ for (const p of permissions) {
2092
+ if (p.sessionID !== sessionId) continue;
2093
+ if (watcher.reportedPermissions.has(p.id)) continue;
2094
+ const paused = this.attributeInteraction(watcher, p.messageID, messages);
2095
+ const reported = await this.reportInteraction(
2096
+ watcher.conv.id,
2097
+ "permission",
2098
+ p,
2099
+ paused?.message.source_message_id ?? void 0
2100
+ );
2101
+ if (reported) watcher.reportedPermissions.add(p.id);
2102
+ }
2103
+ }
2104
+ /**
2105
+ * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2106
+ *
2107
+ * The interaction carries `interactionMessageId` — the ASSISTANT message id
2108
+ * that raised it (a question's `tool.messageID` / a permission's `messageID`).
2109
+ * That assistant message is the reply to ONE of our minted user messages
2110
+ * (correlated by `parentID`, GATE-B). So when we have the tick's message
2111
+ * snapshot, we resolve each running in-flight message's correlated assistant
2112
+ * reply (`findAssistantReplyAfter`) and match its id against
2113
+ * `interactionMessageId` — giving an EXACT attribution even with several
2114
+ * messages in flight concurrently in one session.
2115
+ *
2116
+ * We fall back to the oldest running message ONLY when no exact match is
2117
+ * possible (the id is absent, the snapshot is missing, or the reply has not yet
2118
+ * been correlated). With a single running message either path is exact. Never
2119
+ * throws.
2120
+ *
2121
+ * Attribution must NOT depend on our own `started` PATCH flag: opencode can
2122
+ * START a turn AND raise a question/permission BEFORE our next tick fires
2123
+ * `markProcessing` (which sets `started`). Relying on `started` would leave the
2124
+ * running set empty in that window and let the server fall back to "newest
2125
+ * processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
2126
+ * person whose active turn actually paused. So we derive "running" from the
2127
+ * tick's `messages` snapshot via `messageRunState` instead.
2128
+ */
2129
+ attributeInteraction(watcher, interactionMessageId, messages) {
2130
+ const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
2131
+ if (inFlight.length === 0) return void 0;
2132
+ if (interactionMessageId && messages) {
2133
+ const exact = inFlight.find((m) => {
2134
+ const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
2135
+ return reply != null && messageIdOf(reply) === interactionMessageId;
1817
2136
  });
2137
+ if (exact) return exact;
2138
+ }
2139
+ const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
2140
+ if (messages) {
2141
+ const runningPerSnapshot = inFlight.filter(
2142
+ (m) => messageRunState(messages, m.opencodeMessageId) === "running"
2143
+ );
2144
+ if (runningPerSnapshot.length > 0) {
2145
+ return runningPerSnapshot.sort(byOldest)[0];
2146
+ }
2147
+ }
2148
+ const startedRunning = inFlight.filter((m) => m.started);
2149
+ if (startedRunning.length > 0) {
2150
+ return startedRunning.sort(byOldest)[0];
1818
2151
  }
2152
+ return inFlight.sort(byOldest)[0];
1819
2153
  }
1820
2154
  // -------------------------------------------------------------------------
1821
2155
  // Evident API calls (combinedAuth thread routes)
@@ -1849,39 +2183,86 @@ var ChannelDriver = class {
1849
2183
  }
1850
2184
  return await res.json();
1851
2185
  }
2186
+ /**
2187
+ * EXISTING combinedAuth route — now fired by the watcher on queued→running
2188
+ * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
2189
+ * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
2190
+ * deep-linked "View in Evident" notice).
2191
+ *
2192
+ * Return/throw contract (consumed by the watcher's swap-to-running guard):
2193
+ * - returns `true` → the server transitioned the row to processing;
2194
+ * - returns `false` → the server gave a DEFINITIVE "already-processing"
2195
+ * answer (a non-retryable, non-auth status — e.g. a
2196
+ * conflict because a duplicate already transitioned it),
2197
+ * so the caller treats it as already-started and does NOT
2198
+ * retry;
2199
+ * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
2200
+ * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
2201
+ * network-level error from `fetch`) — i.e. NO definitive server response —
2202
+ * so the caller leaves the message un-started and retries the swap on the
2203
+ * next tick.
2204
+ * A single attempt (no internal retry): the watcher's per-tick loop is the
2205
+ * retry vehicle for the swap-to-running.
2206
+ */
1852
2207
  async markProcessing(conversationId, messageId, sessionId) {
1853
2208
  const res = await this.fetchImpl(
1854
2209
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1855
2210
  {
1856
2211
  method: "PATCH",
1857
2212
  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).
1861
2213
  body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
1862
2214
  }
1863
2215
  );
1864
2216
  this.assertAuth(res, "marking message as processing");
1865
- return res.ok;
2217
+ if (res.ok) return true;
2218
+ if (isRetryableStatus(res.status)) {
2219
+ throw new Error(`marking message as processing: HTTP ${res.status}`);
2220
+ }
2221
+ return false;
1866
2222
  }
1867
2223
  /**
1868
- * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1869
- * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
2224
+ * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2225
+ * .../messages/:id {status:'done', opencode_session_id}`. The server's
1870
2226
  * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1871
- * for an already-`done` message a no-op (no double Slack post).
2227
+ * for an already-`done` message a no-op (no double Slack post). Fired by the
2228
+ * watcher on per-message completion (Task 3.4) — no `confirmCompletion`
2229
+ * round-trip (we already observed completion via the message list).
2230
+ *
2231
+ * SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
2232
+ * services its in-flight messages SEQUENTIALLY within a tick
2233
+ * (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
2234
+ * backoff here would BLOCK sibling messages in the SAME session/tick: while
2235
+ * message A's done PATCH burned its internal retries, message B could not be
2236
+ * swapped to running even though opencode had already started it. Instead this
2237
+ * does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
2238
+ * handler already relies on, leaning on the per-tick retry across ticks
2239
+ * (bounded by `inFlight.deadline`) rather than an in-call retry:
2240
+ * - resolves (`void`) → the server transitioned the row to done
2241
+ * (or idempotently confirmed already-done);
2242
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
2243
+ * cleanup, Finding 1);
2244
+ * - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
2245
+ * succeed → straight to the cron, Finding 4);
2246
+ * - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
2247
+ * (no definitive server response → the
2248
+ * watcher retries next tick within the
2249
+ * deadline, Finding 4).
1872
2250
  */
1873
2251
  async markDone(conversationId, messageId, sessionId) {
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
- )
2252
+ const res = await this.fetchImpl(
2253
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2254
+ {
2255
+ method: "PATCH",
2256
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2257
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
2258
+ }
1884
2259
  );
2260
+ this.assertAuth(res, "marking message as done");
2261
+ if (res.ok) return;
2262
+ if (isRetryableStatus(res.status)) {
2263
+ throw new Error(`marking message as done: HTTP ${res.status}`);
2264
+ }
2265
+ throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
1885
2266
  }
1886
2267
  async markFailed(conversationId, messageId) {
1887
2268
  await this.callWithRetry(
@@ -1896,6 +2277,42 @@ var ChannelDriver = class {
1896
2277
  )
1897
2278
  );
1898
2279
  }
2280
+ /**
2281
+ * Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
2282
+ * (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
2283
+ * — the server records it via `log()` (no DB write, no notification). This is
2284
+ * fire-and-forget: it MUST NEVER throw into the drain or the watcher tick, and
2285
+ * MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
2286
+ * watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
2287
+ * context (no silent catch, per development-workflow).
2288
+ */
2289
+ async postSignal(conversationId, messageId, signal, extra) {
2290
+ try {
2291
+ const res = await this.fetchImpl(
2292
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
2293
+ {
2294
+ method: "POST",
2295
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2296
+ body: JSON.stringify({ signal, ...extra })
2297
+ }
2298
+ );
2299
+ if (!res.ok) {
2300
+ this.log({
2301
+ level: "error",
2302
+ message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
2303
+ conversation_id: conversationId,
2304
+ message_id: messageId
2305
+ });
2306
+ }
2307
+ } catch (err) {
2308
+ this.log({
2309
+ level: "error",
2310
+ message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
2311
+ conversation_id: conversationId,
2312
+ message_id: messageId
2313
+ });
2314
+ }
2315
+ }
1899
2316
  async persistSession(conversationId, sessionId) {
1900
2317
  const res = await this.fetchImpl(
1901
2318
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
@@ -1909,10 +2326,17 @@ var ChannelDriver = class {
1909
2326
  }
1910
2327
  /**
1911
2328
  * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1912
- * `POST .../interactive-event {type, data}`. The server persists the
1913
- * interaction and posts a link to the proxied opencode-web conversation.
2329
+ * `POST .../interactive-event {type, data, source_message_id?}`. The server
2330
+ * persists the interaction and posts a link to the proxied opencode-web
2331
+ * conversation, @mentioning the user who triggered THIS message's turn.
2332
+ *
2333
+ * WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
2334
+ * ts (`message.source_message_id`). The server resolves the @mention from that
2335
+ * message's user FIRST (falling back to the old "newest processing" precedence
2336
+ * only when absent), so the correct person is mentioned under concurrency. It
2337
+ * is OPTIONAL for back-compat with older clients / legacy rows.
1914
2338
  */
1915
- async reportInteraction(conversationId, type, data) {
2339
+ async reportInteraction(conversationId, type, data, sourceMessageId) {
1916
2340
  try {
1917
2341
  await this.callWithRetry(
1918
2342
  "reporting interactive event",
@@ -1921,7 +2345,9 @@ var ChannelDriver = class {
1921
2345
  {
1922
2346
  method: "POST",
1923
2347
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1924
- body: JSON.stringify({ type, data })
2348
+ body: JSON.stringify(
2349
+ sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2350
+ )
1925
2351
  }
1926
2352
  )
1927
2353
  );
@@ -1930,6 +2356,7 @@ var ChannelDriver = class {
1930
2356
  message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1931
2357
  conversation_id: conversationId
1932
2358
  });
2359
+ return true;
1933
2360
  } catch (err) {
1934
2361
  if (err instanceof ChannelAuthError) throw err;
1935
2362
  this.log({
@@ -1937,6 +2364,7 @@ var ChannelDriver = class {
1937
2364
  message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1938
2365
  conversation_id: conversationId
1939
2366
  });
2367
+ return false;
1940
2368
  }
1941
2369
  }
1942
2370
  // -------------------------------------------------------------------------
@@ -1975,8 +2403,9 @@ var ChannelDriver = class {
1975
2403
  await this.sleep(backoffDelay(attempt, this.retry));
1976
2404
  continue;
1977
2405
  }
2406
+ break;
1978
2407
  }
1979
- throw new Error(`${context}: HTTP ${res.status}`);
2408
+ throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
1980
2409
  }
1981
2410
  throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1982
2411
  }
@@ -2199,7 +2628,7 @@ async function getAgentInfo(agentId, authHeader) {
2199
2628
  // src/commands/run.ts
2200
2629
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
2201
2630
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
2202
- function log(state, message, isError = false) {
2631
+ function log2(state, message, isError = false) {
2203
2632
  if (state.json) {
2204
2633
  console.log(
2205
2634
  JSON.stringify({
@@ -2224,9 +2653,9 @@ function logActivity(state, entry) {
2224
2653
  }
2225
2654
  if (!state.interactive) {
2226
2655
  if (entry.type === "error") {
2227
- log(state, entry.error ?? "Unknown error", true);
2656
+ log2(state, entry.error ?? "Unknown error", true);
2228
2657
  } else if (entry.type === "info" && entry.message) {
2229
- log(state, entry.message);
2658
+ log2(state, entry.message);
2230
2659
  }
2231
2660
  }
2232
2661
  }
@@ -2312,6 +2741,7 @@ async function handleAuthError(state, error2) {
2312
2741
  }
2313
2742
  async function driveChannels(state, driver) {
2314
2743
  let idlePolls = 0;
2744
+ let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
2315
2745
  while (state.running) {
2316
2746
  if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2317
2747
  logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
@@ -2321,9 +2751,11 @@ async function driveChannels(state, driver) {
2321
2751
  try {
2322
2752
  const processed = await driver.drainPending();
2323
2753
  state.messageCount += processed;
2324
- if (processed > 0) {
2754
+ const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
2755
+ lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
2756
+ if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity) {
2325
2757
  idlePolls = 0;
2326
- if (state.interactive) displayStatus(state);
2758
+ if (processed > 0 && state.interactive) displayStatus(state);
2327
2759
  } else if (state.idleTimeout !== null) {
2328
2760
  idlePolls++;
2329
2761
  if (idlePolls === 1) {
@@ -2373,7 +2805,7 @@ async function cleanup(state) {
2373
2805
  logActivity(state, { type: "info", message: "Stopped OpenCode process" });
2374
2806
  displayStatus(state);
2375
2807
  } else {
2376
- log(state, "Stopped OpenCode process");
2808
+ log2(state, "Stopped OpenCode process");
2377
2809
  }
2378
2810
  state.opencodeProcess = null;
2379
2811
  }
@@ -2396,10 +2828,11 @@ async function run(options) {
2396
2828
  running: true,
2397
2829
  activityLog: [],
2398
2830
  messageCount: 0,
2831
+ lastProxiedActivityAt: null,
2399
2832
  authHeader: ""
2400
2833
  };
2401
2834
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
2402
- log(
2835
+ log2(
2403
2836
  state,
2404
2837
  "Warning: No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
2405
2838
  false
@@ -2410,7 +2843,7 @@ async function run(options) {
2410
2843
  logActivity(state, { type: "info", message: "Shutting down..." });
2411
2844
  displayStatus(state);
2412
2845
  } else {
2413
- log(state, "Shutting down...");
2846
+ log2(state, "Shutting down...");
2414
2847
  }
2415
2848
  await cleanup(state);
2416
2849
  await shutdownTelemetry();
@@ -2443,7 +2876,7 @@ async function run(options) {
2443
2876
  const resolved = await resolveAgentIdFromKey(state.authHeader);
2444
2877
  if (resolved.agent_id) {
2445
2878
  state.agentId = resolved.agent_id;
2446
- log(state, `Resolved agent ID from key: ${state.agentId}`);
2879
+ log2(state, `Resolved agent ID from key: ${state.agentId}`);
2447
2880
  if (state.interactive && !state.json) {
2448
2881
  logActivity(state, {
2449
2882
  type: "info",
@@ -2506,14 +2939,21 @@ async function run(options) {
2506
2939
  port: state.port,
2507
2940
  interactive: state.interactive,
2508
2941
  agentId: state.agentId,
2509
- log: (message) => log(state, message)
2942
+ log: (message) => log2(state, message)
2510
2943
  });
2511
2944
  state.port = oc.port;
2512
2945
  state.opencodeProcess = oc.process;
2513
2946
  state.opencodeVersion = oc.version;
2514
2947
  state.opencodeConnected = oc.process !== null || oc.version !== null;
2515
- const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2516
- ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2948
+ const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2949
+ ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
2950
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
2951
+ if (versionWarning) {
2952
+ log2(state, versionWarning, false);
2953
+ if (state.interactive && !state.json) {
2954
+ logActivity(state, { type: "info", message: versionWarning });
2955
+ }
2956
+ }
2517
2957
  } catch (error2) {
2518
2958
  ocSpinner?.fail(error2.message);
2519
2959
  throw error2;
@@ -2578,9 +3018,14 @@ async function run(options) {
2578
3018
  logActivity(state, { type: "error", error: error2 });
2579
3019
  if (state.interactive) displayStatus(state);
2580
3020
  },
2581
- // Web traffic is proxied transparently; only note opencode is live.
3021
+ // Web traffic is proxied transparently; note opencode is live and stamp
3022
+ // proxied activity so the idle loop treats interactive proxy use as work.
3023
+ // Fires per forwarded response head (incl. every SSE open) and excludes
3024
+ // the internal drain-ping, so an actively-used proxy keeps the timer
3025
+ // fresh while a lone idle SSE with no follow-up requests still ages out.
2582
3026
  onResponse: () => {
2583
3027
  state.opencodeConnected = true;
3028
+ state.lastProxiedActivityAt = Date.now();
2584
3029
  },
2585
3030
  // A channel message was queued and the api-worker pinged us over the
2586
3031
  // tunnel to drain immediately instead of waiting for the next poll tick.
@@ -2619,7 +3064,7 @@ async function run(options) {
2619
3064
  throw error2;
2620
3065
  }
2621
3066
  if (!interactive || state.json) {
2622
- log(state, "Driving channel messages...");
3067
+ log2(state, "Driving channel messages...");
2623
3068
  }
2624
3069
  await driveChannels(state, channelDriver);
2625
3070
  await cleanup(state);
@@ -2631,7 +3076,7 @@ async function run(options) {
2631
3076
  })
2632
3077
  );
2633
3078
  } else if (!interactive) {
2634
- log(state, `Completed. Processed ${state.messageCount} message(s).`);
3079
+ log2(state, `Completed. Processed ${state.messageCount} message(s).`);
2635
3080
  }
2636
3081
  await shutdownTelemetry();
2637
3082
  process.exit(0);
@@ -2653,8 +3098,9 @@ async function run(options) {
2653
3098
  }
2654
3099
 
2655
3100
  // src/index.ts
3101
+ var { version } = createRequire(import.meta.url)("../package.json");
2656
3102
  var program = new Command();
2657
- program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
3103
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version(version).option(
2658
3104
  "--endpoint <url>",
2659
3105
  "Evident API base URL (default: production; e.g. http://localhost:3001)"
2660
3106
  ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {