@evident-ai/cli 3.0.1-dev.d06ae3c → 3.0.1-dev.d0ec1bd

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
@@ -471,12 +471,6 @@ import chalk6 from "chalk";
471
471
  import ora3 from "ora";
472
472
  import { select as select3 } from "@inquirer/prompts";
473
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
-
480
474
  // ../../packages/types/src/telemetry/index.ts
481
475
  var TelemetryEventTypes = {
482
476
  // Agent activity events (shown in web UI activity log)
@@ -515,7 +509,10 @@ function stripQuery(url) {
515
509
  }
516
510
 
517
511
  // src/lib/telemetry.ts
518
- var CLI_VERSION = process.env.npm_package_version || "unknown";
512
+ var CLI_VERSION = (true ? "3.0.0" : void 0) ?? process.env.npm_package_version ?? "unknown";
513
+ function getCliVersion() {
514
+ return CLI_VERSION;
515
+ }
519
516
  var eventBuffer = [];
520
517
  var flushTimeout = null;
521
518
  var isShuttingDown = false;
@@ -715,7 +712,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
715
712
  }
716
713
 
717
714
  // src/lib/opencode/opencode-version-gate.ts
718
- var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
715
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11", "1.18.3"];
719
716
  function isQueueValidatedVersion(version2) {
720
717
  if (!version2) return false;
721
718
  return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
@@ -1039,7 +1036,11 @@ function roleOf(m) {
1039
1036
  }
1040
1037
  function completedOf(m) {
1041
1038
  if (!m || typeof m !== "object") return void 0;
1042
- return m.info?.time?.completed;
1039
+ return m.info?.time?.completed ?? m.time?.completed;
1040
+ }
1041
+ function createdOf(m) {
1042
+ if (!m || typeof m !== "object") return void 0;
1043
+ return m.info?.time?.created ?? m.time?.created;
1043
1044
  }
1044
1045
  function idOf(m) {
1045
1046
  if (!m || typeof m !== "object") return void 0;
@@ -1059,10 +1060,24 @@ function finishOf(m) {
1059
1060
  const infoFinish = m.info?.finish;
1060
1061
  return typeof infoFinish === "string" ? infoFinish : void 0;
1061
1062
  }
1063
+ function errorOf(m) {
1064
+ if (!m || typeof m !== "object") return void 0;
1065
+ return m.info?.error ?? m.error;
1066
+ }
1062
1067
  function isAssistantInFlight(m) {
1063
1068
  if (completedOf(m) == null) return true;
1064
1069
  return finishOf(m) === "tool-calls";
1065
1070
  }
1071
+ async function getSessionMessages(port, sessionId) {
1072
+ try {
1073
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
1074
+ if (!res.ok) return null;
1075
+ const body = await res.json();
1076
+ return Array.isArray(body) ? body : null;
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1066
1081
  async function createOpenCodeSession(port, directory) {
1067
1082
  const url = new URL(`${opencodeBase(port)}/session`);
1068
1083
  if (directory && directory.trim()) {
@@ -1080,9 +1095,16 @@ async function createOpenCodeSession(port, directory) {
1080
1095
  const data = await response.json();
1081
1096
  return data.id;
1082
1097
  }
1083
- async function sendPromptAsync(port, sessionId, content, options, messageId) {
1098
+ function messageText(m) {
1099
+ if (!m || !Array.isArray(m.parts)) return "";
1100
+ return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1101
+ }
1102
+ async function sendPromptAsync(port, sessionId, content, options) {
1103
+ const before = await getSessionMessages(port, sessionId);
1104
+ const knownUserIds = new Set(
1105
+ (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1106
+ );
1084
1107
  const body = {
1085
- messageID: messageId,
1086
1108
  parts: [{ type: "text", text: content }]
1087
1109
  };
1088
1110
  if (options?.agent) {
@@ -1106,6 +1128,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1106
1128
  const text = await res.text().catch(() => "");
1107
1129
  throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1108
1130
  }
1131
+ const READ_BACK_ATTEMPTS = 5;
1132
+ const READ_BACK_DELAY_MS = 150;
1133
+ for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
1134
+ const after = await getSessionMessages(port, sessionId);
1135
+ if (after) {
1136
+ let best = null;
1137
+ for (const m of after) {
1138
+ if (roleOf(m) !== "user") continue;
1139
+ const id = idOf(m);
1140
+ if (typeof id !== "string" || knownUserIds.has(id)) continue;
1141
+ if (messageText(m) !== content) continue;
1142
+ const created = createdOf(m) ?? 0;
1143
+ if (best === null || created > best.created) {
1144
+ best = { id, created };
1145
+ }
1146
+ }
1147
+ if (best) return best.id;
1148
+ }
1149
+ if (attempt < READ_BACK_ATTEMPTS - 1) {
1150
+ await new Promise((resolve) => setTimeout(resolve, READ_BACK_DELAY_MS));
1151
+ }
1152
+ }
1153
+ return null;
1109
1154
  }
1110
1155
  function findAssistantReplyAfter(messages, userMessageId) {
1111
1156
  if (!messages || messages.length === 0) return null;
@@ -1122,19 +1167,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
1122
1167
  }
1123
1168
  function findLastAssistantReplyFor(messages, userMessageId) {
1124
1169
  if (!messages || messages.length === 0) return null;
1170
+ let lastCorrelated = null;
1171
+ let lastNonErrored = null;
1125
1172
  for (let i = messages.length - 1; i >= 0; i--) {
1126
1173
  const m = messages[i];
1127
- if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1174
+ if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
1175
+ if (lastCorrelated === null) lastCorrelated = m;
1176
+ if (errorOf(m) == null) {
1177
+ lastNonErrored = m;
1178
+ break;
1179
+ }
1128
1180
  }
1181
+ if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
1129
1182
  const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1130
1183
  if (userIndex === -1) return null;
1131
1184
  let last = null;
1185
+ let lastOk = null;
1132
1186
  for (let i = userIndex + 1; i < messages.length; i++) {
1133
1187
  const role = roleOf(messages[i]);
1134
1188
  if (role === "user") break;
1135
- if (role === "assistant") last = messages[i];
1189
+ if (role === "assistant") {
1190
+ last = messages[i];
1191
+ if (errorOf(messages[i]) == null) lastOk = messages[i];
1192
+ }
1136
1193
  }
1137
- return last;
1194
+ return lastOk ?? last;
1138
1195
  }
1139
1196
  function messageRunState(messages, userMessageId) {
1140
1197
  if (!messages || messages.length === 0) return "unknown";
@@ -1144,7 +1201,21 @@ function messageRunState(messages, userMessageId) {
1144
1201
  if (!reply) return "unknown";
1145
1202
  }
1146
1203
  if (!reply) return "queued";
1147
- return isAssistantInFlight(reply) ? "running" : "done";
1204
+ if (isAssistantInFlight(reply)) return "running";
1205
+ return errorOf(reply) != null ? "failed" : "done";
1206
+ }
1207
+ function messageError(messages, userMessageId) {
1208
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1209
+ const error2 = errorOf(reply);
1210
+ if (error2 == null) return null;
1211
+ if (typeof error2 === "string") return error2;
1212
+ if (typeof error2 === "object") {
1213
+ const e = error2;
1214
+ const dataMessage = e.data?.message;
1215
+ if (typeof dataMessage === "string") return dataMessage;
1216
+ if (typeof e.message === "string") return e.message;
1217
+ }
1218
+ return "The agent run failed.";
1148
1219
  }
1149
1220
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1150
1221
  if (!messages || messages.length === 0) return false;
@@ -1152,9 +1223,6 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1152
1223
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1153
1224
  );
1154
1225
  }
1155
- function opencodeMessageIdFor2(queuedMessageId) {
1156
- return opencodeMessageIdFor(queuedMessageId);
1157
- }
1158
1226
 
1159
1227
  // src/lib/tunnel/connection.ts
1160
1228
  import WebSocket2 from "ws";
@@ -1559,7 +1627,6 @@ var DEFAULT_RETRY_POLICY = {
1559
1627
  };
1560
1628
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1561
1629
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1562
- var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1563
1630
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1564
1631
  var ChannelAuthError = class extends Error {
1565
1632
  constructor(message) {
@@ -1595,11 +1662,18 @@ var ChannelDriver = class {
1595
1662
  sleep;
1596
1663
  pausedPollIntervalMs;
1597
1664
  pausedMaxWaitMs;
1598
- dispatchConfirmMs;
1599
1665
  stuckQueuedMs;
1600
1666
  now;
1601
1667
  /** Cache of conversationId → opencode sessionId. */
1602
1668
  sessions = /* @__PURE__ */ new Map();
1669
+ /**
1670
+ * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1671
+ * longer idempotent (no caller-supplied `messageID`), and its read-back picks
1672
+ * "the one new user row" — which is only unambiguous if no OTHER dispatch into
1673
+ * the SAME session interleaves its snapshot→POST→read-back. This map chains each
1674
+ * session's dispatches so they run serially; distinct sessions stay concurrent.
1675
+ */
1676
+ sessionDispatchLocks = /* @__PURE__ */ new Map();
1603
1677
  /**
1604
1678
  * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1605
1679
  * session: one polling loop services all of that session's in-flight messages.
@@ -1650,6 +1724,20 @@ var ChannelDriver = class {
1650
1724
  * the row leaves the processing list, exactly like `dontRedispatch`.
1651
1725
  */
1652
1726
  doneUndeliverable = /* @__PURE__ */ new Set();
1727
+ /**
1728
+ * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1729
+ * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
1730
+ * is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
1731
+ * persist hasn't landed before tick N+1 re-reads the still-null
1732
+ * `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
1733
+ * A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
1734
+ * short-circuits while it is present, so a null-id row is re-dispatched AT MOST
1735
+ * ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
1736
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
1737
+ * re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
1738
+ * so the NEXT tick may retry exactly once more).
1739
+ */
1740
+ awaitingReadopt = /* @__PURE__ */ new Set();
1653
1741
  /**
1654
1742
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1655
1743
  * first session creation so drain-created sessions are rooted at the project
@@ -1672,7 +1760,6 @@ var ChannelDriver = class {
1672
1760
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1673
1761
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1674
1762
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1675
- this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1676
1763
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1677
1764
  this.now = config2.now ?? (() => Date.now());
1678
1765
  }
@@ -1764,11 +1851,11 @@ var ChannelDriver = class {
1764
1851
  skippedAlreadyDispatched += 1;
1765
1852
  continue;
1766
1853
  }
1767
- const opencodeMessageId = opencodeMessageIdFor2(message.id);
1768
1854
  const options = {
1769
1855
  agent: message.opencode_agent ?? void 0,
1770
1856
  model: message.opencode_model ?? void 0
1771
1857
  };
1858
+ let opencodeMessageId;
1772
1859
  try {
1773
1860
  this.log({
1774
1861
  level: "info",
@@ -1776,7 +1863,10 @@ var ChannelDriver = class {
1776
1863
  conversation_id: conv.id,
1777
1864
  message_id: message.id
1778
1865
  });
1779
- await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1866
+ opencodeMessageId = await this.dispatchLocked(
1867
+ sessionId,
1868
+ () => sendPromptAsync(this.port, sessionId, message.content, options)
1869
+ );
1780
1870
  } catch (err) {
1781
1871
  if (err instanceof ChannelAuthError) throw err;
1782
1872
  this.dispatched.delete(message.id);
@@ -1790,6 +1880,15 @@ var ChannelDriver = class {
1790
1880
  });
1791
1881
  continue;
1792
1882
  }
1883
+ if (opencodeMessageId === null) {
1884
+ this.log({
1885
+ level: "error",
1886
+ message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
1887
+ conversation_id: conv.id,
1888
+ message_id: message.id
1889
+ });
1890
+ continue;
1891
+ }
1793
1892
  this.dispatched.add(message.id);
1794
1893
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1795
1894
  dispatched += 1;
@@ -1838,6 +1937,25 @@ var ChannelDriver = class {
1838
1937
  // -------------------------------------------------------------------------
1839
1938
  // Per-session watcher (WI-3)
1840
1939
  // -------------------------------------------------------------------------
1940
+ /**
1941
+ * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
1942
+ * opencode session (Task 2.1a), so two dispatches into the SAME session can
1943
+ * never interleave and mis-correlate their read-backs. Distinct sessions run
1944
+ * concurrently. The chained tail intentionally ignores the prior result/error
1945
+ * (each dispatch reports its own outcome to its caller).
1946
+ */
1947
+ dispatchLocked(sessionId, fn) {
1948
+ const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
1949
+ const run2 = prior.then(fn, fn);
1950
+ this.sessionDispatchLocks.set(
1951
+ sessionId,
1952
+ run2.then(
1953
+ () => void 0,
1954
+ () => void 0
1955
+ )
1956
+ );
1957
+ return run2;
1958
+ }
1841
1959
  /** Register a freshly-dispatched message with its session's watcher state. */
1842
1960
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
1843
1961
  let watcher = this.watchers.get(sessionId);
@@ -1902,10 +2020,10 @@ var ChannelDriver = class {
1902
2020
  started: true,
1903
2021
  done: false,
1904
2022
  // Not yet reported stuck-queued. The once-guard (`stuckReported`) applies,
1905
- // AND the stuck-queued observer now INCLUDES re-adopted queued wedges: it
1906
- // gates on `state === 'queued'` (turn produced no reply), not on `started`,
1907
- // so a re-adopted row left wedged in `queued` still emits the signal once
1908
- // (queued-followup-redrive, #210).
2023
+ // AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
2024
+ // on `state === 'queued'` (turn produced no reply), not on `started`, so a
2025
+ // re-adopted row left wedged in `queued` still emits the signal once
2026
+ // (#210/#220 observability).
1909
2027
  stuckReported: false
1910
2028
  });
1911
2029
  }
@@ -1993,10 +2111,15 @@ var ChannelDriver = class {
1993
2111
  async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1994
2112
  const conv = watcher.conv;
1995
2113
  const state = messageRunState(messages, inFlight.opencodeMessageId);
1996
- if ((state === "running" || state === "done") && !inFlight.started) {
2114
+ if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
1997
2115
  let claimed;
1998
2116
  try {
1999
- claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
2117
+ claimed = await this.markProcessing(
2118
+ conv.id,
2119
+ inFlight.evidentMessageId,
2120
+ sessionId,
2121
+ inFlight.opencodeMessageId
2122
+ );
2000
2123
  } catch (err) {
2001
2124
  if (err instanceof ChannelAuthError) throw err;
2002
2125
  this.log({
@@ -2026,7 +2149,12 @@ var ChannelDriver = class {
2026
2149
  message_id: inFlight.evidentMessageId
2027
2150
  });
2028
2151
  try {
2029
- await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
2152
+ await this.markDone(
2153
+ conv.id,
2154
+ inFlight.evidentMessageId,
2155
+ sessionId,
2156
+ inFlight.opencodeMessageId
2157
+ );
2030
2158
  } catch (err) {
2031
2159
  if (err instanceof ChannelAuthError) throw err;
2032
2160
  if (err instanceof ChannelTerminalError) {
@@ -2062,12 +2190,55 @@ var ChannelDriver = class {
2062
2190
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2063
2191
  return;
2064
2192
  }
2065
- if (state === "unknown") {
2066
- if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
2067
- await this.redispatchInFlight(sessionId, inFlight);
2193
+ if (state === "failed") {
2194
+ if (!inFlight.done) {
2195
+ const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
2196
+ this.log({
2197
+ level: "error",
2198
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2199
+ conversation_id: conv.id,
2200
+ message_id: inFlight.evidentMessageId
2201
+ });
2202
+ try {
2203
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
2204
+ } catch (err) {
2205
+ if (err instanceof ChannelAuthError) throw err;
2206
+ if (err instanceof ChannelTerminalError) {
2207
+ this.log({
2208
+ level: "error",
2209
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2210
+ conversation_id: conv.id,
2211
+ message_id: inFlight.evidentMessageId
2212
+ });
2213
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2214
+ return;
2215
+ }
2216
+ if (this.now() >= inFlight.deadline) {
2217
+ this.log({
2218
+ level: "error",
2219
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
2220
+ conversation_id: conv.id,
2221
+ message_id: inFlight.evidentMessageId
2222
+ });
2223
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2224
+ return;
2225
+ }
2226
+ this.log({
2227
+ level: "error",
2228
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2229
+ conversation_id: conv.id,
2230
+ message_id: inFlight.evidentMessageId
2231
+ });
2232
+ return;
2233
+ }
2234
+ inFlight.done = true;
2068
2235
  }
2236
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2237
+ return;
2069
2238
  }
2070
- if (state === "queued" && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
2239
+ const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
2240
+ const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
2241
+ if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
2071
2242
  inFlight.stuckReported = true;
2072
2243
  void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
2073
2244
  stuck_for_ms: this.now() - inFlight.dispatchedAt
@@ -2080,41 +2251,11 @@ var ChannelDriver = class {
2080
2251
  conversation_id: conv.id,
2081
2252
  message_id: inFlight.evidentMessageId
2082
2253
  });
2083
- this.removeInFlight(watcher, inFlight.evidentMessageId);
2084
- }
2085
- }
2086
- /**
2087
- * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
2088
- * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
2089
- * fact 9) — one user message + one reply even if the original DID land. Resets
2090
- * the dispatch timestamp so the guard doesn't immediately fire again.
2091
- */
2092
- async redispatchInFlight(sessionId, inFlight) {
2093
- const options = {
2094
- agent: inFlight.message.opencode_agent ?? void 0,
2095
- model: inFlight.message.opencode_model ?? void 0
2096
- };
2097
- this.log({
2098
- level: "info",
2099
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
2100
- message_id: inFlight.evidentMessageId
2101
- });
2102
- try {
2103
- await sendPromptAsync(
2104
- this.port,
2105
- sessionId,
2106
- inFlight.message.content,
2107
- options,
2108
- inFlight.opencodeMessageId
2109
- );
2110
- } catch (err) {
2111
- this.log({
2112
- level: "error",
2113
- message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
2114
- message_id: inFlight.evidentMessageId
2254
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2255
+ watched_for_ms: this.now() - inFlight.dispatchedAt
2115
2256
  });
2257
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2116
2258
  }
2117
- inFlight.dispatchedAt = this.now();
2118
2259
  }
2119
2260
  // -------------------------------------------------------------------------
2120
2261
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
@@ -2203,10 +2344,17 @@ var ChannelDriver = class {
2203
2344
  * Re-adopt ONE `processing` row against the tick's session message snapshot
2204
2345
  * (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
2205
2346
  *
2206
- * Branches on `messageRunState(messages, opencodeMessageIdFor(row.id))`:
2347
+ * Branches on `messageRunState(messages, row.opencode_message_id)` — the
2348
+ * opencode-assigned user-message id persisted on the first `processing` PATCH
2349
+ * (#218). A row with a NULL stored id (dispatched but the read-back never landed
2350
+ * before the restart) has no id to correlate → treated as an orphan and
2351
+ * re-dispatched (at most once, see `forceReadoptRun`):
2207
2352
  * - `done` → `markDone` now (guarded like the watcher's done branch);
2208
- * - `running`/`queued` re-attach a watcher via `registerReadopted` (no re-dispatch);
2209
- * - `unknown` → re-dispatch the STABLE id + attach a watcher (orphan).
2353
+ * - `failed` `markFailed` with the surfaced error (issue #182), so an
2354
+ * errored turn is reported failed on restart, NOT re-dispatched;
2355
+ * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
2356
+ * tracking the stored id so the reply correlates by it;
2357
+ * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
2210
2358
  *
2211
2359
  * Only `ChannelAuthError` propagates.
2212
2360
  */
@@ -2220,8 +2368,8 @@ var ChannelDriver = class {
2220
2368
  });
2221
2369
  return;
2222
2370
  }
2223
- const ocId = opencodeMessageIdFor2(row.id);
2224
- const state = messageRunState(messages, ocId);
2371
+ const ocId = row.opencode_message_id;
2372
+ const state = messageRunState(messages, ocId ?? "");
2225
2373
  if (state === "done") {
2226
2374
  if (this.doneUndeliverable.has(row.id)) {
2227
2375
  this.log({
@@ -2239,7 +2387,7 @@ var ChannelDriver = class {
2239
2387
  message_id: row.id
2240
2388
  });
2241
2389
  try {
2242
- await this.markDone(row.conversation_id, row.id, sessionId);
2390
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId);
2243
2391
  } catch (err) {
2244
2392
  if (err instanceof ChannelAuthError) throw err;
2245
2393
  if (err instanceof ChannelTerminalError) {
@@ -2263,6 +2411,39 @@ var ChannelDriver = class {
2263
2411
  this.dontRedispatch.delete(row.id);
2264
2412
  return;
2265
2413
  }
2414
+ if (state === "failed") {
2415
+ const error2 = messageError(messages, ocId ?? "") ?? void 0;
2416
+ this.log({
2417
+ level: "error",
2418
+ message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2419
+ conversation_id: row.conversation_id,
2420
+ message_id: row.id
2421
+ });
2422
+ try {
2423
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2);
2424
+ } catch (err) {
2425
+ if (err instanceof ChannelAuthError) throw err;
2426
+ if (err instanceof ChannelTerminalError) {
2427
+ this.doneUndeliverable.add(row.id);
2428
+ this.log({
2429
+ level: "error",
2430
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
2431
+ conversation_id: row.conversation_id,
2432
+ message_id: row.id
2433
+ });
2434
+ return;
2435
+ }
2436
+ this.log({
2437
+ level: "error",
2438
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2439
+ conversation_id: row.conversation_id,
2440
+ message_id: row.id
2441
+ });
2442
+ return;
2443
+ }
2444
+ this.dontRedispatch.delete(row.id);
2445
+ return;
2446
+ }
2266
2447
  if (this.dontRedispatch.has(row.id)) {
2267
2448
  this.log({
2268
2449
  level: "info",
@@ -2272,7 +2453,7 @@ var ChannelDriver = class {
2272
2453
  });
2273
2454
  return;
2274
2455
  }
2275
- if (state === "running" || state === "queued") {
2456
+ if ((state === "running" || state === "queued") && ocId) {
2276
2457
  const conv = this.convForRow(sessionId, row);
2277
2458
  const message = this.queuedMessageForRow(row);
2278
2459
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
@@ -2281,7 +2462,7 @@ var ChannelDriver = class {
2281
2462
  this.ensureWatcherRunning(sessionId);
2282
2463
  this.log({
2283
2464
  level: "info",
2284
- message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stable id, no re-dispatch)`,
2465
+ message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2285
2466
  conversation_id: row.conversation_id,
2286
2467
  message_id: row.id
2287
2468
  });
@@ -2290,23 +2471,36 @@ var ChannelDriver = class {
2290
2471
  await this.forceReadoptRun(sessionId, row);
2291
2472
  }
2292
2473
  /**
2293
- * Re-dispatch an orphaned (`unknown`) `processing` row (ADR-0046 Decision §2).
2474
+ * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
2294
2475
  *
2295
- * The stable-id user message is absent from the session, so we (re-)dispatch with
2296
- * the STABLE id (`opencodeMessageIdFor(row.id)`) NOT a divergent per-attempt id.
2297
- * This is what keeps the reply correlatable: the server's completion
2298
- * notification looks for the reply under the stable id, so the fresh turn's reply
2299
- * (which hangs off the stable id) is found and delivered. The residual
2300
- * duplicate-incomplete-turn semantics (ADR §2, `.harness/restart-recovery-orphan-finding.md`)
2301
- * are unchanged and, for an ABSENT id, cannot bite — there is no existing turn
2302
- * to swallow the duplicate.
2476
+ * #218/WI-5: the row's user message is absent (never kept, or a null stored id),
2477
+ * so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
2478
+ * read it back, and register the watcher under the assigned id so the reply
2479
+ * correlates server-side.
2303
2480
  *
2304
- * `evidentMessageId = row.id` addresses the SERVER row; the stable
2305
- * `opencodeMessageId` is what the watcher polls. Deadline anchored to
2481
+ * ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
2482
+ * id). Without a guard, if this dispatches on tick N but the read-back+persist
2483
+ * hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
2484
+ * tick N+1 would dispatch AGAIN → duplicate user turns. The `awaitingReadopt`
2485
+ * latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
2486
+ * short-circuit while the row is latched; clear it on a successful dispatch (the
2487
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
2488
+ * re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
2489
+ * may retry exactly once more).
2490
+ *
2491
+ * `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
2306
2492
  * `processed_at` (Invariant 1).
2307
2493
  */
2308
2494
  async forceReadoptRun(sessionId, row) {
2309
- const ocId = opencodeMessageIdFor2(row.id);
2495
+ if (this.awaitingReadopt.has(row.id)) {
2496
+ this.log({
2497
+ level: "info",
2498
+ message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
2499
+ conversation_id: row.conversation_id,
2500
+ message_id: row.id
2501
+ });
2502
+ return;
2503
+ }
2310
2504
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2311
2505
  this.dontRedispatch.add(row.id);
2312
2506
  this.log({
@@ -2323,13 +2517,19 @@ var ChannelDriver = class {
2323
2517
  };
2324
2518
  this.log({
2325
2519
  level: "info",
2326
- message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching with the stable id`,
2520
+ message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
2327
2521
  conversation_id: row.conversation_id,
2328
2522
  message_id: row.id
2329
2523
  });
2524
+ this.awaitingReadopt.add(row.id);
2525
+ let ocId;
2330
2526
  try {
2331
- await sendPromptAsync(this.port, sessionId, row.content, options, ocId);
2527
+ ocId = await this.dispatchLocked(
2528
+ sessionId,
2529
+ () => sendPromptAsync(this.port, sessionId, row.content, options)
2530
+ );
2332
2531
  } catch (err) {
2532
+ this.awaitingReadopt.delete(row.id);
2333
2533
  if (err instanceof ChannelAuthError) throw err;
2334
2534
  this.log({
2335
2535
  level: "error",
@@ -2339,11 +2539,22 @@ var ChannelDriver = class {
2339
2539
  });
2340
2540
  return;
2341
2541
  }
2542
+ if (ocId === null) {
2543
+ this.awaitingReadopt.delete(row.id);
2544
+ this.log({
2545
+ level: "error",
2546
+ message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
2547
+ conversation_id: row.conversation_id,
2548
+ message_id: row.id
2549
+ });
2550
+ return;
2551
+ }
2342
2552
  const conv = this.convForRow(sessionId, row);
2343
2553
  const message = this.queuedMessageForRow(row);
2344
2554
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
2345
2555
  this.dispatched.add(row.id);
2346
2556
  this.readopted.add(row.id);
2557
+ this.awaitingReadopt.delete(row.id);
2347
2558
  this.ensureWatcherRunning(sessionId);
2348
2559
  }
2349
2560
  /**
@@ -2613,13 +2824,17 @@ var ChannelDriver = class {
2613
2824
  * A single attempt (no internal retry): the watcher's per-tick loop is the
2614
2825
  * retry vehicle for the swap-to-running.
2615
2826
  */
2616
- async markProcessing(conversationId, messageId, sessionId) {
2827
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
2617
2828
  const res = await this.fetchImpl(
2618
2829
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2619
2830
  {
2620
2831
  method: "PATCH",
2621
2832
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2622
- body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
2833
+ body: JSON.stringify({
2834
+ status: "processing",
2835
+ opencode_session_id: sessionId,
2836
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
2837
+ })
2623
2838
  }
2624
2839
  );
2625
2840
  this.assertAuth(res, "marking message as processing");
@@ -2657,13 +2872,17 @@ var ChannelDriver = class {
2657
2872
  * watcher retries next tick within the
2658
2873
  * deadline, Finding 4).
2659
2874
  */
2660
- async markDone(conversationId, messageId, sessionId) {
2875
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
2661
2876
  const res = await this.fetchImpl(
2662
2877
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2663
2878
  {
2664
2879
  method: "PATCH",
2665
2880
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2666
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
2881
+ body: JSON.stringify({
2882
+ status: "done",
2883
+ opencode_session_id: sessionId,
2884
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
2885
+ })
2667
2886
  }
2668
2887
  );
2669
2888
  this.assertAuth(res, "marking message as done");
@@ -2673,7 +2892,17 @@ var ChannelDriver = class {
2673
2892
  }
2674
2893
  throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
2675
2894
  }
2676
- async markFailed(conversationId, messageId) {
2895
+ /**
2896
+ * Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
2897
+ * when provided (issue #182): a bare `markFailed(conv, msg)` sends
2898
+ * `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
2899
+ * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
2900
+ * failure reason reaches the channel.
2901
+ */
2902
+ async markFailed(conversationId, messageId, sessionId, error2) {
2903
+ const body = { status: "failed" };
2904
+ if (sessionId !== void 0) body.opencode_session_id = sessionId;
2905
+ if (error2 !== void 0) body.error = error2;
2677
2906
  await this.callWithRetry(
2678
2907
  "marking message as failed",
2679
2908
  () => this.fetchImpl(
@@ -2681,7 +2910,7 @@ var ChannelDriver = class {
2681
2910
  {
2682
2911
  method: "PATCH",
2683
2912
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2684
- body: JSON.stringify({ status: "failed" })
2913
+ body: JSON.stringify(body)
2685
2914
  }
2686
2915
  )
2687
2916
  );
@@ -3037,6 +3266,7 @@ async function getAgentInfo(agentId, authHeader) {
3037
3266
  // src/commands/run.ts
3038
3267
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
3039
3268
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3269
+ var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3040
3270
  function log2(state, message, isError = false) {
3041
3271
  if (state.json) {
3042
3272
  console.log(
@@ -3374,6 +3604,7 @@ async function run(options) {
3374
3604
  apiUrl: getApiUrlConfig(),
3375
3605
  getAuthHeader: () => state.authHeader,
3376
3606
  conversationFilter: state.conversationFilter,
3607
+ stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
3377
3608
  log: (entry) => logActivity(state, {
3378
3609
  type: entry.level === "error" ? "error" : "info",
3379
3610
  message: entry.message,
@@ -3393,7 +3624,11 @@ async function run(options) {
3393
3624
  type: "info",
3394
3625
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
3395
3626
  });
3396
- emitAgentConnected(state.agentId, { port: state.port });
3627
+ emitAgentConnected(state.agentId, {
3628
+ port: state.port,
3629
+ cli_version: getCliVersion(),
3630
+ opencode_version: state.opencodeVersion
3631
+ });
3397
3632
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
3398
3633
  if (state.interactive) displayStatus(state);
3399
3634
  channelDriver.drainPending().then((processed) => {