@evident-ai/cli 3.0.1-dev.0c07f2c → 3.0.1-dev.0d0721c

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
@@ -285,14 +285,14 @@ function blank() {
285
285
  console.log();
286
286
  }
287
287
  function waitForEnter(prompt = "Press Enter to continue...") {
288
- return new Promise((resolve) => {
288
+ return new Promise((resolve2) => {
289
289
  process.stdout.write(chalk.dim(prompt));
290
290
  const handler = () => {
291
291
  process.stdin.removeListener("data", handler);
292
292
  process.stdin.setRawMode?.(false);
293
293
  process.stdin.pause();
294
294
  console.log();
295
- resolve();
295
+ resolve2();
296
296
  };
297
297
  if (process.stdin.isTTY) {
298
298
  process.stdin.setRawMode?.(true);
@@ -302,7 +302,7 @@ function waitForEnter(prompt = "Press Enter to continue...") {
302
302
  });
303
303
  }
304
304
  function sleep(ms) {
305
- return new Promise((resolve) => setTimeout(resolve, ms));
305
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
306
306
  }
307
307
 
308
308
  // src/commands/login.ts
@@ -376,19 +376,19 @@ async function tokenLogin() {
376
376
  console.log("Visit your Evident dashboard to generate a CLI token.");
377
377
  blank();
378
378
  process.stdout.write("Paste token: ");
379
- const token = await new Promise((resolve) => {
379
+ const token = await new Promise((resolve2) => {
380
380
  let data = "";
381
381
  process.stdin.setEncoding("utf8");
382
382
  process.stdin.on("data", (chunk) => {
383
383
  data += chunk;
384
384
  });
385
385
  process.stdin.on("end", () => {
386
- resolve(data.trim());
386
+ resolve2(data.trim());
387
387
  });
388
388
  if (process.stdin.isTTY) {
389
389
  process.stdin.once("data", (chunk) => {
390
390
  process.stdin.pause();
391
- resolve(chunk.toString().trim());
391
+ resolve2(chunk.toString().trim());
392
392
  });
393
393
  process.stdin.resume();
394
394
  }
@@ -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;
@@ -709,13 +706,13 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
709
706
  if (health.healthy) {
710
707
  return health;
711
708
  }
712
- await new Promise((resolve) => setTimeout(resolve, 1e3));
709
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
713
710
  }
714
711
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
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,66 @@ 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
+ }
1081
+ function sessionLastActivityMs(session) {
1082
+ const candidates = [
1083
+ session.time?.updated,
1084
+ session.time?.created,
1085
+ session.time_updated,
1086
+ session.time_created,
1087
+ session.updated,
1088
+ session.created
1089
+ ];
1090
+ for (const c of candidates) {
1091
+ if (typeof c === "number" && Number.isFinite(c)) return c;
1092
+ }
1093
+ return null;
1094
+ }
1095
+ async function listSessions(port) {
1096
+ try {
1097
+ const res = await fetch(`${opencodeBase(port)}/session`);
1098
+ if (!res.ok) return null;
1099
+ const body = await res.json();
1100
+ return Array.isArray(body) ? body : null;
1101
+ } catch {
1102
+ return null;
1103
+ }
1104
+ }
1105
+ async function deleteSession(port, id) {
1106
+ try {
1107
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
1108
+ return res.status >= 200 && res.status < 300;
1109
+ } catch {
1110
+ return false;
1111
+ }
1112
+ }
1113
+ async function sessionExists(port, id) {
1114
+ try {
1115
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`);
1116
+ if (res.status >= 200 && res.status < 300) return true;
1117
+ if (res.status === 404) return false;
1118
+ return null;
1119
+ } catch {
1120
+ return null;
1121
+ }
1122
+ }
1066
1123
  async function createOpenCodeSession(port, directory) {
1067
1124
  const url = new URL(`${opencodeBase(port)}/session`);
1068
1125
  if (directory && directory.trim()) {
@@ -1080,9 +1137,16 @@ async function createOpenCodeSession(port, directory) {
1080
1137
  const data = await response.json();
1081
1138
  return data.id;
1082
1139
  }
1083
- async function sendPromptAsync(port, sessionId, content, options, messageId) {
1140
+ function messageText(m) {
1141
+ if (!m || !Array.isArray(m.parts)) return "";
1142
+ return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1143
+ }
1144
+ async function sendPromptAsync(port, sessionId, content, options) {
1145
+ const before = await getSessionMessages(port, sessionId);
1146
+ const knownUserIds = new Set(
1147
+ (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1148
+ );
1084
1149
  const body = {
1085
- messageID: messageId,
1086
1150
  parts: [{ type: "text", text: content }]
1087
1151
  };
1088
1152
  if (options?.agent) {
@@ -1106,6 +1170,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1106
1170
  const text = await res.text().catch(() => "");
1107
1171
  throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1108
1172
  }
1173
+ const READ_BACK_ATTEMPTS = 5;
1174
+ const READ_BACK_DELAY_MS = 150;
1175
+ for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
1176
+ const after = await getSessionMessages(port, sessionId);
1177
+ if (after) {
1178
+ let best = null;
1179
+ for (const m of after) {
1180
+ if (roleOf(m) !== "user") continue;
1181
+ const id = idOf(m);
1182
+ if (typeof id !== "string" || knownUserIds.has(id)) continue;
1183
+ if (messageText(m) !== content) continue;
1184
+ const created = createdOf(m) ?? 0;
1185
+ if (best === null || created > best.created) {
1186
+ best = { id, created };
1187
+ }
1188
+ }
1189
+ if (best) return best.id;
1190
+ }
1191
+ if (attempt < READ_BACK_ATTEMPTS - 1) {
1192
+ await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
1193
+ }
1194
+ }
1195
+ return null;
1109
1196
  }
1110
1197
  function findAssistantReplyAfter(messages, userMessageId) {
1111
1198
  if (!messages || messages.length === 0) return null;
@@ -1122,19 +1209,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
1122
1209
  }
1123
1210
  function findLastAssistantReplyFor(messages, userMessageId) {
1124
1211
  if (!messages || messages.length === 0) return null;
1212
+ let lastCorrelated = null;
1213
+ let lastNonErrored = null;
1125
1214
  for (let i = messages.length - 1; i >= 0; i--) {
1126
1215
  const m = messages[i];
1127
- if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1216
+ if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
1217
+ if (lastCorrelated === null) lastCorrelated = m;
1218
+ if (errorOf(m) == null) {
1219
+ lastNonErrored = m;
1220
+ break;
1221
+ }
1128
1222
  }
1223
+ if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
1129
1224
  const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1130
1225
  if (userIndex === -1) return null;
1131
1226
  let last = null;
1227
+ let lastOk = null;
1132
1228
  for (let i = userIndex + 1; i < messages.length; i++) {
1133
1229
  const role = roleOf(messages[i]);
1134
1230
  if (role === "user") break;
1135
- if (role === "assistant") last = messages[i];
1231
+ if (role === "assistant") {
1232
+ last = messages[i];
1233
+ if (errorOf(messages[i]) == null) lastOk = messages[i];
1234
+ }
1136
1235
  }
1137
- return last;
1236
+ return lastOk ?? last;
1138
1237
  }
1139
1238
  function messageRunState(messages, userMessageId) {
1140
1239
  if (!messages || messages.length === 0) return "unknown";
@@ -1144,7 +1243,21 @@ function messageRunState(messages, userMessageId) {
1144
1243
  if (!reply) return "unknown";
1145
1244
  }
1146
1245
  if (!reply) return "queued";
1147
- return isAssistantInFlight(reply) ? "running" : "done";
1246
+ if (isAssistantInFlight(reply)) return "running";
1247
+ return errorOf(reply) != null ? "failed" : "done";
1248
+ }
1249
+ function messageError(messages, userMessageId) {
1250
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1251
+ const error2 = errorOf(reply);
1252
+ if (error2 == null) return null;
1253
+ if (typeof error2 === "string") return error2;
1254
+ if (typeof error2 === "object") {
1255
+ const e = error2;
1256
+ const dataMessage = e.data?.message;
1257
+ if (typeof dataMessage === "string") return dataMessage;
1258
+ if (typeof e.message === "string") return e.message;
1259
+ }
1260
+ return "The agent run failed.";
1148
1261
  }
1149
1262
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1150
1263
  if (!messages || messages.length === 0) return false;
@@ -1152,8 +1265,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1152
1265
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1153
1266
  );
1154
1267
  }
1155
- function opencodeMessageIdFor2(queuedMessageId) {
1156
- return opencodeMessageIdFor(queuedMessageId);
1268
+
1269
+ // src/lib/opencode/session-cleanup.ts
1270
+ var DURATION_UNIT_MS = {
1271
+ s: 1e3,
1272
+ m: 60 * 1e3,
1273
+ h: 60 * 60 * 1e3,
1274
+ d: 24 * 60 * 60 * 1e3
1275
+ };
1276
+ function parseDurationMs(input) {
1277
+ const trimmed = input.trim();
1278
+ const match = /^(\d+)([smhd])$/.exec(trimmed);
1279
+ if (!match) {
1280
+ throw new Error(
1281
+ `Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
1282
+ );
1283
+ }
1284
+ const value = Number(match[1]);
1285
+ if (value <= 0) {
1286
+ throw new Error(`Invalid duration "${input}": must be a positive value.`);
1287
+ }
1288
+ return value * DURATION_UNIT_MS[match[2]];
1289
+ }
1290
+ function selectSessionsToDelete(sessions, opts) {
1291
+ const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
1292
+ if (maxAgeMs === void 0 && maxCount === void 0) return [];
1293
+ const ageEligible = (s) => {
1294
+ if (maxAgeMs === void 0) return false;
1295
+ if (s.lastActivityMs === null) return true;
1296
+ return nowMs - s.lastActivityMs > maxAgeMs;
1297
+ };
1298
+ const countEligibleIds = /* @__PURE__ */ new Set();
1299
+ if (maxCount !== void 0) {
1300
+ const byActivityDesc = [...sessions].sort(
1301
+ (a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
1302
+ );
1303
+ for (const s of byActivityDesc.slice(maxCount)) {
1304
+ countEligibleIds.add(s.id);
1305
+ }
1306
+ }
1307
+ const toDelete = [];
1308
+ for (const s of sessions) {
1309
+ if (protectedIds.has(s.id)) continue;
1310
+ if (ageEligible(s) || countEligibleIds.has(s.id)) {
1311
+ toDelete.push(s.id);
1312
+ }
1313
+ }
1314
+ return toDelete;
1315
+ }
1316
+ var DEFAULT_INTERVAL = "1h";
1317
+ function resolve(flag, envValue, fallback) {
1318
+ return flag ?? envValue ?? fallback;
1319
+ }
1320
+ function parseMaxCount(input) {
1321
+ const trimmed = input.trim();
1322
+ if (!/^\d+$/.test(trimmed)) {
1323
+ throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
1324
+ }
1325
+ const value = Number(trimmed);
1326
+ if (value <= 0) {
1327
+ throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
1328
+ }
1329
+ return value;
1330
+ }
1331
+ function resolveSessionCleanupConfig(flags, env = process.env) {
1332
+ const warnings = [];
1333
+ const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
1334
+ const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
1335
+ const intervalRaw = resolve(
1336
+ flags.interval,
1337
+ env.EVIDENT_SESSION_CLEANUP_INTERVAL,
1338
+ DEFAULT_INTERVAL
1339
+ );
1340
+ let maxAgeMs;
1341
+ if (maxAgeRaw !== void 0) {
1342
+ try {
1343
+ maxAgeMs = parseDurationMs(maxAgeRaw);
1344
+ } catch (err) {
1345
+ warnings.push(
1346
+ `Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
1347
+ );
1348
+ }
1349
+ }
1350
+ let maxCount;
1351
+ if (maxCountRaw !== void 0) {
1352
+ try {
1353
+ maxCount = parseMaxCount(maxCountRaw);
1354
+ } catch (err) {
1355
+ warnings.push(
1356
+ `Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
1357
+ );
1358
+ }
1359
+ }
1360
+ let intervalMs;
1361
+ try {
1362
+ intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
1363
+ } catch (err) {
1364
+ warnings.push(
1365
+ `Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
1366
+ );
1367
+ intervalMs = parseDurationMs(DEFAULT_INTERVAL);
1368
+ }
1369
+ const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
1370
+ return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
1157
1371
  }
1158
1372
 
1159
1373
  // src/lib/tunnel/connection.ts
@@ -1232,24 +1446,26 @@ var StreamForwarder = class {
1232
1446
  this.send({ type: "res_end", sid });
1233
1447
  return;
1234
1448
  }
1235
- log("info", "agent_request", {
1236
- correlation_id: correlationId,
1237
- sid,
1238
- method,
1239
- path: stripQuery(path)
1240
- });
1449
+ if (process.env.DEBUG) {
1450
+ log("debug", "agent_request", {
1451
+ correlation_id: correlationId,
1452
+ sid,
1453
+ method,
1454
+ path: stripQuery(path)
1455
+ });
1456
+ }
1241
1457
  const ac = new AbortController();
1242
1458
  let bodyPromise;
1243
1459
  let pushBody;
1244
1460
  let endBody;
1245
1461
  if (has_body) {
1246
1462
  const chunks = [];
1247
- bodyPromise = new Promise((resolve) => {
1463
+ bodyPromise = new Promise((resolve2) => {
1248
1464
  pushBody = (buf) => {
1249
1465
  chunks.push(buf);
1250
1466
  };
1251
1467
  endBody = () => {
1252
- resolve(Buffer.concat(chunks));
1468
+ resolve2(Buffer.concat(chunks));
1253
1469
  };
1254
1470
  });
1255
1471
  }
@@ -1284,12 +1500,14 @@ var StreamForwarder = class {
1284
1500
  if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1285
1501
  });
1286
1502
  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
- });
1503
+ if (process.env.DEBUG) {
1504
+ log("debug", "agent_response", {
1505
+ correlation_id: correlationId,
1506
+ sid,
1507
+ status: upstream.status,
1508
+ duration_ms: Date.now() - startedAt
1509
+ });
1510
+ }
1293
1511
  this.callbacks.onHead?.(sid, upstream.status);
1294
1512
  try {
1295
1513
  if (upstream.body) {
@@ -1365,7 +1583,7 @@ function connectTunnel(options) {
1365
1583
  } = options;
1366
1584
  const tunnelUrl = getTunnelUrlConfig();
1367
1585
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1368
- return new Promise((resolve, reject) => {
1586
+ return new Promise((resolve2, reject) => {
1369
1587
  const ws = new WebSocket2(url, {
1370
1588
  headers: {
1371
1589
  Authorization: authHeader
@@ -1430,7 +1648,7 @@ function connectTunnel(options) {
1430
1648
  clearTimeout(connectionTimeout);
1431
1649
  const connectedAgentId = message.agent_id ?? agentId;
1432
1650
  onConnected?.(connectedAgentId);
1433
- resolve({
1651
+ resolve2({
1434
1652
  ws,
1435
1653
  close: () => ws.close(1e3, "CLI shutdown")
1436
1654
  });
@@ -1559,7 +1777,6 @@ var DEFAULT_RETRY_POLICY = {
1559
1777
  };
1560
1778
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1561
1779
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1562
- var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1563
1780
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1564
1781
  var ChannelAuthError = class extends Error {
1565
1782
  constructor(message) {
@@ -1595,11 +1812,18 @@ var ChannelDriver = class {
1595
1812
  sleep;
1596
1813
  pausedPollIntervalMs;
1597
1814
  pausedMaxWaitMs;
1598
- dispatchConfirmMs;
1599
1815
  stuckQueuedMs;
1600
1816
  now;
1601
1817
  /** Cache of conversationId → opencode sessionId. */
1602
1818
  sessions = /* @__PURE__ */ new Map();
1819
+ /**
1820
+ * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1821
+ * longer idempotent (no caller-supplied `messageID`), and its read-back picks
1822
+ * "the one new user row" — which is only unambiguous if no OTHER dispatch into
1823
+ * the SAME session interleaves its snapshot→POST→read-back. This map chains each
1824
+ * session's dispatches so they run serially; distinct sessions stay concurrent.
1825
+ */
1826
+ sessionDispatchLocks = /* @__PURE__ */ new Map();
1603
1827
  /**
1604
1828
  * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1605
1829
  * session: one polling loop services all of that session's in-flight messages.
@@ -1650,6 +1874,20 @@ var ChannelDriver = class {
1650
1874
  * the row leaves the processing list, exactly like `dontRedispatch`.
1651
1875
  */
1652
1876
  doneUndeliverable = /* @__PURE__ */ new Set();
1877
+ /**
1878
+ * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1879
+ * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
1880
+ * is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
1881
+ * persist hasn't landed before tick N+1 re-reads the still-null
1882
+ * `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
1883
+ * A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
1884
+ * short-circuits while it is present, so a null-id row is re-dispatched AT MOST
1885
+ * ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
1886
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
1887
+ * re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
1888
+ * so the NEXT tick may retry exactly once more).
1889
+ */
1890
+ awaitingReadopt = /* @__PURE__ */ new Set();
1653
1891
  /**
1654
1892
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1655
1893
  * first session creation so drain-created sessions are rooted at the project
@@ -1657,8 +1895,33 @@ var ChannelDriver = class {
1657
1895
  * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1658
1896
  */
1659
1897
  opencodeDirectory = void 0;
1898
+ /**
1899
+ * Cache of opencode `sessionId → parentID` (its parent session, or `null` when
1900
+ * the session is a root with no parent). Sub-agents spawned via the `task` tool
1901
+ * run in CHILD sessions whose `parentID` chains up to the Evident-created
1902
+ * (watched) session; we resolve this once per session so a child-session
1903
+ * question/permission can be attributed to the watched session's subtree
1904
+ * (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
1905
+ * entry = not yet resolved; `null` = resolved root (stop walking).
1906
+ */
1907
+ sessionParents = /* @__PURE__ */ new Map();
1660
1908
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1661
1909
  draining = false;
1910
+ /**
1911
+ * The currently-executing `drainPending()` promise, or null when idle. Lets a
1912
+ * graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
1913
+ * is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
1914
+ * drain that entered before `stop()` still registers its watcher).
1915
+ */
1916
+ activeDrain = null;
1917
+ /**
1918
+ * Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
1919
+ * dispatches NEW work (it returns 0 immediately) — but the per-session watcher
1920
+ * loops already running keep going so in-flight turns can finish and deliver
1921
+ * their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
1922
+ * and stops opencode.
1923
+ */
1924
+ stopped = false;
1662
1925
  constructor(config2) {
1663
1926
  this.agentId = config2.agentId;
1664
1927
  this.port = config2.port;
@@ -1672,7 +1935,6 @@ var ChannelDriver = class {
1672
1935
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1673
1936
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1674
1937
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1675
- this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1676
1938
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1677
1939
  this.now = config2.now ?? (() => Date.now());
1678
1940
  }
@@ -1691,8 +1953,21 @@ var ChannelDriver = class {
1691
1953
  * @returns the number of messages NEWLY dispatched to opencode's native queue.
1692
1954
  */
1693
1955
  async drainPending() {
1956
+ if (this.stopped) return 0;
1694
1957
  if (this.draining) return 0;
1695
1958
  this.draining = true;
1959
+ const run2 = this.runDrain();
1960
+ this.activeDrain = run2.then(
1961
+ () => {
1962
+ this.activeDrain = null;
1963
+ },
1964
+ () => {
1965
+ this.activeDrain = null;
1966
+ }
1967
+ );
1968
+ return run2;
1969
+ }
1970
+ async runDrain() {
1696
1971
  let dispatched = 0;
1697
1972
  try {
1698
1973
  const conversations = await this.getPendingConversations();
@@ -1704,6 +1979,7 @@ var ChannelDriver = class {
1704
1979
  });
1705
1980
  }
1706
1981
  for (const conv of conversations) {
1982
+ if (this.stopped) break;
1707
1983
  dispatched += await this.processConversation(conv);
1708
1984
  }
1709
1985
  await this.readoptProcessing();
@@ -1724,6 +2000,73 @@ var ChannelDriver = class {
1724
2000
  }
1725
2001
  return false;
1726
2002
  }
2003
+ /**
2004
+ * OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
2005
+ * exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
2006
+ * `watchers` entry whose `inFlight` set is non-empty — the same predicate
2007
+ * `hasInFlightWatchers()` uses, lifted to return the ids.
2008
+ *
2009
+ * Deliberately does NOT include `this.sessions` (the permanent, never-pruned
2010
+ * conversation→session cache). Protecting every bound-but-idle session there
2011
+ * would shield nearly every session and defeat cleanup — AND it is unnecessary:
2012
+ * `ensureSession` is self-healing (it recreates a session whose id no longer
2013
+ * exists), so deleting an idle bound session is harmless — the conversation's
2014
+ * next turn transparently rebinds a fresh one. The only thing worth protecting
2015
+ * is a session with a turn ACTIVELY in flight right now: tearing that down
2016
+ * mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
2017
+ */
2018
+ protectedSessionIds() {
2019
+ const ids = /* @__PURE__ */ new Set();
2020
+ for (const [sessionId, watcher] of this.watchers) {
2021
+ if (watcher.inFlight.size > 0) ids.add(sessionId);
2022
+ }
2023
+ return ids;
2024
+ }
2025
+ /**
2026
+ * Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
2027
+ * this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
2028
+ * — but the watcher loops already tracking in-flight turns keep running, so a
2029
+ * turn that has finished (or is about to) still fires `markDone` and delivers
2030
+ * its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
2031
+ */
2032
+ stop() {
2033
+ this.stopped = true;
2034
+ }
2035
+ /**
2036
+ * Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
2037
+ * graceful shutdown, so a turn whose reply is ready — or completes within the
2038
+ * window — is delivered before the process exits, instead of being cut off and
2039
+ * left for the ADR-0046 restart-recovery path.
2040
+ *
2041
+ * Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
2042
+ * far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
2043
+ * window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
2044
+ * set empties OR the timeout elapses. Anything still in flight at the timeout is
2045
+ * safe to abandon — it stays `processing` server-side and is re-adopted on the
2046
+ * next runner start (ADR-0046).
2047
+ *
2048
+ * @returns true if all in-flight work settled within the window; false if the
2049
+ * timeout elapsed with work still in flight.
2050
+ */
2051
+ async waitForInFlight(timeoutMs) {
2052
+ const deadline = this.now() + timeoutMs;
2053
+ const step = Math.min(this.pausedPollIntervalMs, 250);
2054
+ if (this.activeDrain) {
2055
+ let drainSettled = false;
2056
+ void this.activeDrain.then(() => {
2057
+ drainSettled = true;
2058
+ });
2059
+ while (!drainSettled) {
2060
+ if (this.now() >= deadline) return false;
2061
+ await this.sleep(step);
2062
+ }
2063
+ }
2064
+ while (this.hasInFlightWatchers()) {
2065
+ if (this.now() >= deadline) return false;
2066
+ await this.sleep(step);
2067
+ }
2068
+ return true;
2069
+ }
1727
2070
  /**
1728
2071
  * Await all outstanding per-session watchers (WI-3).
1729
2072
  *
@@ -1760,15 +2103,16 @@ var ChannelDriver = class {
1760
2103
  let dispatched = 0;
1761
2104
  let skippedAlreadyDispatched = 0;
1762
2105
  for (const message of messages) {
2106
+ if (this.stopped) break;
1763
2107
  if (this.dispatched.has(message.id)) {
1764
2108
  skippedAlreadyDispatched += 1;
1765
2109
  continue;
1766
2110
  }
1767
- const opencodeMessageId = opencodeMessageIdFor2(message.id);
1768
2111
  const options = {
1769
2112
  agent: message.opencode_agent ?? void 0,
1770
2113
  model: message.opencode_model ?? void 0
1771
2114
  };
2115
+ let opencodeMessageId;
1772
2116
  try {
1773
2117
  this.log({
1774
2118
  level: "info",
@@ -1776,10 +2120,23 @@ var ChannelDriver = class {
1776
2120
  conversation_id: conv.id,
1777
2121
  message_id: message.id
1778
2122
  });
1779
- await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
2123
+ opencodeMessageId = await this.dispatchLocked(
2124
+ sessionId,
2125
+ () => sendPromptAsync(this.port, sessionId, message.content, options)
2126
+ );
1780
2127
  } catch (err) {
1781
2128
  if (err instanceof ChannelAuthError) throw err;
1782
2129
  this.dispatched.delete(message.id);
2130
+ if (await sessionExists(this.port, sessionId) === false) {
2131
+ this.sessions.delete(conv.id);
2132
+ this.log({
2133
+ level: "info",
2134
+ message: `Message ${message.id.slice(0, 8)} dispatch hit a session (${sessionId.slice(0, 8)}) that was deleted mid-dispatch (cleanup race) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick (recreated then, in order). Already-dispatched turns keep their watcher.`,
2135
+ conversation_id: conv.id,
2136
+ message_id: message.id
2137
+ });
2138
+ break;
2139
+ }
1783
2140
  await this.markFailed(conv.id, message.id).catch(() => {
1784
2141
  });
1785
2142
  this.log({
@@ -1790,6 +2147,15 @@ var ChannelDriver = class {
1790
2147
  });
1791
2148
  continue;
1792
2149
  }
2150
+ if (opencodeMessageId === null) {
2151
+ this.log({
2152
+ level: "error",
2153
+ 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`,
2154
+ conversation_id: conv.id,
2155
+ message_id: message.id
2156
+ });
2157
+ continue;
2158
+ }
1793
2159
  this.dispatched.add(message.id);
1794
2160
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1795
2161
  dispatched += 1;
@@ -1806,16 +2172,33 @@ var ChannelDriver = class {
1806
2172
  return dispatched;
1807
2173
  }
1808
2174
  async ensureSession(conv) {
1809
- const cached = this.sessions.get(conv.id);
1810
- if (cached) return cached;
1811
- if (conv.opencode_session_id) {
1812
- this.sessions.set(conv.id, conv.opencode_session_id);
1813
- return conv.opencode_session_id;
2175
+ const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
2176
+ if (bound) {
2177
+ const exists = await sessionExists(this.port, bound);
2178
+ if (exists === false) {
2179
+ this.log({
2180
+ level: "info",
2181
+ message: `OpenCode session ${bound} for conversation ${conv.id.slice(0, 8)} no longer exists (deleted or DB reset) \u2014 creating a fresh session and rebinding.`,
2182
+ conversation_id: conv.id
2183
+ });
2184
+ this.sessions.delete(conv.id);
2185
+ return this.createAndBindSession(conv.id);
2186
+ }
2187
+ this.sessions.set(conv.id, bound);
2188
+ return bound;
1814
2189
  }
2190
+ return this.createAndBindSession(conv.id);
2191
+ }
2192
+ /**
2193
+ * Create a fresh OpenCode session for a conversation, cache the binding, and
2194
+ * best-effort persist it server-side. Shared by the first-ever bind and the
2195
+ * self-heal recreate path in `ensureSession`.
2196
+ */
2197
+ async createAndBindSession(conversationId) {
1815
2198
  const directory = await this.resolveOpenCodeDirectory();
1816
2199
  const sessionId = await createOpenCodeSession(this.port, directory);
1817
- this.sessions.set(conv.id, sessionId);
1818
- await this.persistSession(conv.id, sessionId).catch(() => {
2200
+ this.sessions.set(conversationId, sessionId);
2201
+ await this.persistSession(conversationId, sessionId).catch(() => {
1819
2202
  });
1820
2203
  return sessionId;
1821
2204
  }
@@ -1838,6 +2221,25 @@ var ChannelDriver = class {
1838
2221
  // -------------------------------------------------------------------------
1839
2222
  // Per-session watcher (WI-3)
1840
2223
  // -------------------------------------------------------------------------
2224
+ /**
2225
+ * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2226
+ * opencode session (Task 2.1a), so two dispatches into the SAME session can
2227
+ * never interleave and mis-correlate their read-backs. Distinct sessions run
2228
+ * concurrently. The chained tail intentionally ignores the prior result/error
2229
+ * (each dispatch reports its own outcome to its caller).
2230
+ */
2231
+ dispatchLocked(sessionId, fn) {
2232
+ const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
2233
+ const run2 = prior.then(fn, fn);
2234
+ this.sessionDispatchLocks.set(
2235
+ sessionId,
2236
+ run2.then(
2237
+ () => void 0,
2238
+ () => void 0
2239
+ )
2240
+ );
2241
+ return run2;
2242
+ }
1841
2243
  /** Register a freshly-dispatched message with its session's watcher state. */
1842
2244
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
1843
2245
  let watcher = this.watchers.get(sessionId);
@@ -1902,10 +2304,10 @@ var ChannelDriver = class {
1902
2304
  started: true,
1903
2305
  done: false,
1904
2306
  // 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).
2307
+ // AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
2308
+ // on `state === 'queued'` (turn produced no reply), not on `started`, so a
2309
+ // re-adopted row left wedged in `queued` still emits the signal once
2310
+ // (#210/#220 observability).
1909
2311
  stuckReported: false
1910
2312
  });
1911
2313
  }
@@ -1993,10 +2395,15 @@ var ChannelDriver = class {
1993
2395
  async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1994
2396
  const conv = watcher.conv;
1995
2397
  const state = messageRunState(messages, inFlight.opencodeMessageId);
1996
- if ((state === "running" || state === "done") && !inFlight.started) {
2398
+ if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
1997
2399
  let claimed;
1998
2400
  try {
1999
- claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
2401
+ claimed = await this.markProcessing(
2402
+ conv.id,
2403
+ inFlight.evidentMessageId,
2404
+ sessionId,
2405
+ inFlight.opencodeMessageId
2406
+ );
2000
2407
  } catch (err) {
2001
2408
  if (err instanceof ChannelAuthError) throw err;
2002
2409
  this.log({
@@ -2026,7 +2433,12 @@ var ChannelDriver = class {
2026
2433
  message_id: inFlight.evidentMessageId
2027
2434
  });
2028
2435
  try {
2029
- await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
2436
+ await this.markDone(
2437
+ conv.id,
2438
+ inFlight.evidentMessageId,
2439
+ sessionId,
2440
+ inFlight.opencodeMessageId
2441
+ );
2030
2442
  } catch (err) {
2031
2443
  if (err instanceof ChannelAuthError) throw err;
2032
2444
  if (err instanceof ChannelTerminalError) {
@@ -2062,12 +2474,55 @@ var ChannelDriver = class {
2062
2474
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2063
2475
  return;
2064
2476
  }
2065
- if (state === "unknown") {
2066
- if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
2067
- await this.redispatchInFlight(sessionId, inFlight);
2477
+ if (state === "failed") {
2478
+ if (!inFlight.done) {
2479
+ const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
2480
+ this.log({
2481
+ level: "error",
2482
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2483
+ conversation_id: conv.id,
2484
+ message_id: inFlight.evidentMessageId
2485
+ });
2486
+ try {
2487
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
2488
+ } catch (err) {
2489
+ if (err instanceof ChannelAuthError) throw err;
2490
+ if (err instanceof ChannelTerminalError) {
2491
+ this.log({
2492
+ level: "error",
2493
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2494
+ conversation_id: conv.id,
2495
+ message_id: inFlight.evidentMessageId
2496
+ });
2497
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2498
+ return;
2499
+ }
2500
+ if (this.now() >= inFlight.deadline) {
2501
+ this.log({
2502
+ level: "error",
2503
+ 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)}`,
2504
+ conversation_id: conv.id,
2505
+ message_id: inFlight.evidentMessageId
2506
+ });
2507
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2508
+ return;
2509
+ }
2510
+ this.log({
2511
+ level: "error",
2512
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2513
+ conversation_id: conv.id,
2514
+ message_id: inFlight.evidentMessageId
2515
+ });
2516
+ return;
2517
+ }
2518
+ inFlight.done = true;
2068
2519
  }
2520
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2521
+ return;
2069
2522
  }
2070
- if (state === "queued" && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
2523
+ const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
2524
+ const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
2525
+ if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
2071
2526
  inFlight.stuckReported = true;
2072
2527
  void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
2073
2528
  stuck_for_ms: this.now() - inFlight.dispatchedAt
@@ -2080,41 +2535,11 @@ var ChannelDriver = class {
2080
2535
  conversation_id: conv.id,
2081
2536
  message_id: inFlight.evidentMessageId
2082
2537
  });
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
2538
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2539
+ watched_for_ms: this.now() - inFlight.dispatchedAt
2115
2540
  });
2541
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2116
2542
  }
2117
- inFlight.dispatchedAt = this.now();
2118
2543
  }
2119
2544
  // -------------------------------------------------------------------------
2120
2545
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
@@ -2203,10 +2628,17 @@ var ChannelDriver = class {
2203
2628
  * Re-adopt ONE `processing` row against the tick's session message snapshot
2204
2629
  * (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
2205
2630
  *
2206
- * Branches on `messageRunState(messages, opencodeMessageIdFor(row.id))`:
2631
+ * Branches on `messageRunState(messages, row.opencode_message_id)` — the
2632
+ * opencode-assigned user-message id persisted on the first `processing` PATCH
2633
+ * (#218). A row with a NULL stored id (dispatched but the read-back never landed
2634
+ * before the restart) has no id to correlate → treated as an orphan and
2635
+ * re-dispatched (at most once, see `forceReadoptRun`):
2207
2636
  * - `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).
2637
+ * - `failed` `markFailed` with the surfaced error (issue #182), so an
2638
+ * errored turn is reported failed on restart, NOT re-dispatched;
2639
+ * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
2640
+ * tracking the stored id so the reply correlates by it;
2641
+ * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
2210
2642
  *
2211
2643
  * Only `ChannelAuthError` propagates.
2212
2644
  */
@@ -2220,8 +2652,8 @@ var ChannelDriver = class {
2220
2652
  });
2221
2653
  return;
2222
2654
  }
2223
- const ocId = opencodeMessageIdFor2(row.id);
2224
- const state = messageRunState(messages, ocId);
2655
+ const ocId = row.opencode_message_id;
2656
+ const state = messageRunState(messages, ocId ?? "");
2225
2657
  if (state === "done") {
2226
2658
  if (this.doneUndeliverable.has(row.id)) {
2227
2659
  this.log({
@@ -2239,7 +2671,7 @@ var ChannelDriver = class {
2239
2671
  message_id: row.id
2240
2672
  });
2241
2673
  try {
2242
- await this.markDone(row.conversation_id, row.id, sessionId);
2674
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId);
2243
2675
  } catch (err) {
2244
2676
  if (err instanceof ChannelAuthError) throw err;
2245
2677
  if (err instanceof ChannelTerminalError) {
@@ -2263,6 +2695,39 @@ var ChannelDriver = class {
2263
2695
  this.dontRedispatch.delete(row.id);
2264
2696
  return;
2265
2697
  }
2698
+ if (state === "failed") {
2699
+ const error2 = messageError(messages, ocId ?? "") ?? void 0;
2700
+ this.log({
2701
+ level: "error",
2702
+ message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2703
+ conversation_id: row.conversation_id,
2704
+ message_id: row.id
2705
+ });
2706
+ try {
2707
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2);
2708
+ } catch (err) {
2709
+ if (err instanceof ChannelAuthError) throw err;
2710
+ if (err instanceof ChannelTerminalError) {
2711
+ this.doneUndeliverable.add(row.id);
2712
+ this.log({
2713
+ level: "error",
2714
+ 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}`,
2715
+ conversation_id: row.conversation_id,
2716
+ message_id: row.id
2717
+ });
2718
+ return;
2719
+ }
2720
+ this.log({
2721
+ level: "error",
2722
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2723
+ conversation_id: row.conversation_id,
2724
+ message_id: row.id
2725
+ });
2726
+ return;
2727
+ }
2728
+ this.dontRedispatch.delete(row.id);
2729
+ return;
2730
+ }
2266
2731
  if (this.dontRedispatch.has(row.id)) {
2267
2732
  this.log({
2268
2733
  level: "info",
@@ -2272,7 +2737,7 @@ var ChannelDriver = class {
2272
2737
  });
2273
2738
  return;
2274
2739
  }
2275
- if (state === "running" || state === "queued") {
2740
+ if ((state === "running" || state === "queued") && ocId) {
2276
2741
  const conv = this.convForRow(sessionId, row);
2277
2742
  const message = this.queuedMessageForRow(row);
2278
2743
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
@@ -2281,7 +2746,7 @@ var ChannelDriver = class {
2281
2746
  this.ensureWatcherRunning(sessionId);
2282
2747
  this.log({
2283
2748
  level: "info",
2284
- message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stable id, no re-dispatch)`,
2749
+ message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2285
2750
  conversation_id: row.conversation_id,
2286
2751
  message_id: row.id
2287
2752
  });
@@ -2290,23 +2755,45 @@ var ChannelDriver = class {
2290
2755
  await this.forceReadoptRun(sessionId, row);
2291
2756
  }
2292
2757
  /**
2293
- * Re-dispatch an orphaned (`unknown`) `processing` row (ADR-0046 Decision §2).
2758
+ * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
2759
+ *
2760
+ * #218/WI-5: the row's user message is absent (never kept, or a null stored id),
2761
+ * so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
2762
+ * read it back, and register the watcher under the assigned id so the reply
2763
+ * correlates server-side.
2294
2764
  *
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.
2765
+ * ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
2766
+ * id). Without a guard, if this dispatches on tick N but the read-back+persist
2767
+ * hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
2768
+ * tick N+1 would dispatch AGAIN duplicate user turns. The `awaitingReadopt`
2769
+ * latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
2770
+ * short-circuit while the row is latched; clear it on a successful dispatch (the
2771
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
2772
+ * re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
2773
+ * may retry exactly once more).
2303
2774
  *
2304
- * `evidentMessageId = row.id` addresses the SERVER row; the stable
2305
- * `opencodeMessageId` is what the watcher polls. Deadline anchored to
2775
+ * `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
2306
2776
  * `processed_at` (Invariant 1).
2307
2777
  */
2308
2778
  async forceReadoptRun(sessionId, row) {
2309
- const ocId = opencodeMessageIdFor2(row.id);
2779
+ if (this.stopped) {
2780
+ this.log({
2781
+ level: "info",
2782
+ message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but the runner is stopping \u2014 not starting a fresh turn; leaving for restart recovery`,
2783
+ conversation_id: row.conversation_id,
2784
+ message_id: row.id
2785
+ });
2786
+ return;
2787
+ }
2788
+ if (this.awaitingReadopt.has(row.id)) {
2789
+ this.log({
2790
+ level: "info",
2791
+ message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
2792
+ conversation_id: row.conversation_id,
2793
+ message_id: row.id
2794
+ });
2795
+ return;
2796
+ }
2310
2797
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2311
2798
  this.dontRedispatch.add(row.id);
2312
2799
  this.log({
@@ -2323,13 +2810,19 @@ var ChannelDriver = class {
2323
2810
  };
2324
2811
  this.log({
2325
2812
  level: "info",
2326
- message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching with the stable id`,
2813
+ message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
2327
2814
  conversation_id: row.conversation_id,
2328
2815
  message_id: row.id
2329
2816
  });
2817
+ this.awaitingReadopt.add(row.id);
2818
+ let ocId;
2330
2819
  try {
2331
- await sendPromptAsync(this.port, sessionId, row.content, options, ocId);
2820
+ ocId = await this.dispatchLocked(
2821
+ sessionId,
2822
+ () => sendPromptAsync(this.port, sessionId, row.content, options)
2823
+ );
2332
2824
  } catch (err) {
2825
+ this.awaitingReadopt.delete(row.id);
2333
2826
  if (err instanceof ChannelAuthError) throw err;
2334
2827
  this.log({
2335
2828
  level: "error",
@@ -2339,11 +2832,22 @@ var ChannelDriver = class {
2339
2832
  });
2340
2833
  return;
2341
2834
  }
2835
+ if (ocId === null) {
2836
+ this.awaitingReadopt.delete(row.id);
2837
+ this.log({
2838
+ level: "error",
2839
+ 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`,
2840
+ conversation_id: row.conversation_id,
2841
+ message_id: row.id
2842
+ });
2843
+ return;
2844
+ }
2342
2845
  const conv = this.convForRow(sessionId, row);
2343
2846
  const message = this.queuedMessageForRow(row);
2344
2847
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
2345
2848
  this.dispatched.add(row.id);
2346
2849
  this.readopted.add(row.id);
2850
+ this.awaitingReadopt.delete(row.id);
2347
2851
  this.ensureWatcherRunning(sessionId);
2348
2852
  }
2349
2853
  /**
@@ -2448,8 +2952,8 @@ var ChannelDriver = class {
2448
2952
  } catch {
2449
2953
  }
2450
2954
  for (const q of questions) {
2451
- if (q.sessionID !== sessionId) continue;
2452
2955
  if (watcher.reportedQuestions.has(q.id)) continue;
2956
+ if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
2453
2957
  const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
2454
2958
  const reported = await this.reportInteraction(
2455
2959
  watcher.conv.id,
@@ -2469,8 +2973,8 @@ var ChannelDriver = class {
2469
2973
  } catch {
2470
2974
  }
2471
2975
  for (const p of permissions) {
2472
- if (p.sessionID !== sessionId) continue;
2473
2976
  if (watcher.reportedPermissions.has(p.id)) continue;
2977
+ if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
2474
2978
  const paused = this.attributeInteraction(watcher, p.messageID, messages);
2475
2979
  const reported = await this.reportInteraction(
2476
2980
  watcher.conv.id,
@@ -2481,6 +2985,50 @@ var ChannelDriver = class {
2481
2985
  if (reported) watcher.reportedPermissions.add(p.id);
2482
2986
  }
2483
2987
  }
2988
+ /**
2989
+ * True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
2990
+ * i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
2991
+ * watched root. Sub-agents spawned via the `task` tool run in child sessions,
2992
+ * so their questions/permissions live under a different `sessionID` that must
2993
+ * still be attributed to the root conversation the watcher owns.
2994
+ *
2995
+ * Parents are cached in `sessionParents` so we walk each session at most once;
2996
+ * a bounded depth cap guards against a cycle or a pathological chain, and any
2997
+ * fetch failure is treated as "not a descendant" (best-effort — the interaction
2998
+ * simply isn't surfaced this tick and is retried next tick once resolvable).
2999
+ */
3000
+ async sessionBelongsTo(sessionId, rootSessionId) {
3001
+ let current = sessionId;
3002
+ for (let depth = 0; current && depth < 32; depth++) {
3003
+ if (current === rootSessionId) return true;
3004
+ const parent = await this.resolveSessionParent(current);
3005
+ if (parent === null || parent === void 0) return false;
3006
+ current = parent;
3007
+ }
3008
+ return false;
3009
+ }
3010
+ /**
3011
+ * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
3012
+ * `null` for a root session (no parent) and `undefined` when opencode is
3013
+ * unreachable / the session can't be read (so the caller stops walking without
3014
+ * caching a wrong answer — the next tick retries).
3015
+ */
3016
+ async resolveSessionParent(sessionId) {
3017
+ const cached = this.sessionParents.get(sessionId);
3018
+ if (cached !== void 0) return cached;
3019
+ let parent = void 0;
3020
+ try {
3021
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3022
+ if (res.ok) {
3023
+ const body = await res.json();
3024
+ parent = body && typeof body.parentID === "string" ? body.parentID : null;
3025
+ }
3026
+ } catch {
3027
+ parent = void 0;
3028
+ }
3029
+ if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3030
+ return parent;
3031
+ }
2484
3032
  /**
2485
3033
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2486
3034
  *
@@ -2613,13 +3161,17 @@ var ChannelDriver = class {
2613
3161
  * A single attempt (no internal retry): the watcher's per-tick loop is the
2614
3162
  * retry vehicle for the swap-to-running.
2615
3163
  */
2616
- async markProcessing(conversationId, messageId, sessionId) {
3164
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
2617
3165
  const res = await this.fetchImpl(
2618
3166
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2619
3167
  {
2620
3168
  method: "PATCH",
2621
3169
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2622
- body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
3170
+ body: JSON.stringify({
3171
+ status: "processing",
3172
+ opencode_session_id: sessionId,
3173
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3174
+ })
2623
3175
  }
2624
3176
  );
2625
3177
  this.assertAuth(res, "marking message as processing");
@@ -2657,13 +3209,17 @@ var ChannelDriver = class {
2657
3209
  * watcher retries next tick within the
2658
3210
  * deadline, Finding 4).
2659
3211
  */
2660
- async markDone(conversationId, messageId, sessionId) {
3212
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
2661
3213
  const res = await this.fetchImpl(
2662
3214
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2663
3215
  {
2664
3216
  method: "PATCH",
2665
3217
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2666
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
3218
+ body: JSON.stringify({
3219
+ status: "done",
3220
+ opencode_session_id: sessionId,
3221
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3222
+ })
2667
3223
  }
2668
3224
  );
2669
3225
  this.assertAuth(res, "marking message as done");
@@ -2673,7 +3229,17 @@ var ChannelDriver = class {
2673
3229
  }
2674
3230
  throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
2675
3231
  }
2676
- async markFailed(conversationId, messageId) {
3232
+ /**
3233
+ * Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
3234
+ * when provided (issue #182): a bare `markFailed(conv, msg)` sends
3235
+ * `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
3236
+ * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3237
+ * failure reason reaches the channel.
3238
+ */
3239
+ async markFailed(conversationId, messageId, sessionId, error2) {
3240
+ const body = { status: "failed" };
3241
+ if (sessionId !== void 0) body.opencode_session_id = sessionId;
3242
+ if (error2 !== void 0) body.error = error2;
2677
3243
  await this.callWithRetry(
2678
3244
  "marking message as failed",
2679
3245
  () => this.fetchImpl(
@@ -2681,7 +3247,7 @@ var ChannelDriver = class {
2681
3247
  {
2682
3248
  method: "PATCH",
2683
3249
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2684
- body: JSON.stringify({ status: "failed" })
3250
+ body: JSON.stringify(body)
2685
3251
  }
2686
3252
  )
2687
3253
  );
@@ -2992,6 +3558,25 @@ async function resolveAgentIdFromKey(authHeader) {
2992
3558
  return { error: `Failed to resolve agent from key: ${message}` };
2993
3559
  }
2994
3560
  }
3561
+ async function notifyAgentDisconnected(agentId, authHeader) {
3562
+ const apiUrl = getApiUrlConfig();
3563
+ try {
3564
+ const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
3565
+ method: "POST",
3566
+ headers: { Authorization: authHeader }
3567
+ });
3568
+ if (!response.ok) {
3569
+ const serverMessage = await readErrorMessage(response);
3570
+ return {
3571
+ ok: false,
3572
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
3573
+ };
3574
+ }
3575
+ return { ok: true };
3576
+ } catch (error2) {
3577
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
3578
+ }
3579
+ }
2995
3580
  async function getAgentInfo(agentId, authHeader) {
2996
3581
  const apiUrl = getApiUrlConfig();
2997
3582
  try {
@@ -3037,6 +3622,8 @@ async function getAgentInfo(agentId, authHeader) {
3037
3622
  // src/commands/run.ts
3038
3623
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
3039
3624
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3625
+ var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3626
+ var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
3040
3627
  function log2(state, message, isError = false) {
3041
3628
  if (state.json) {
3042
3629
  console.log(
@@ -3191,7 +3778,7 @@ async function driveChannels(state, driver) {
3191
3778
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
3192
3779
  if (state.interactive) displayStatus(state);
3193
3780
  }
3194
- await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
3781
+ await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
3195
3782
  if (state.idleTimeout !== null && idlePolls >= 2) {
3196
3783
  const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
3197
3784
  if (idleMs > state.idleTimeout * 1e3) {
@@ -3202,8 +3789,122 @@ async function driveChannels(state, driver) {
3202
3789
  }
3203
3790
  }
3204
3791
  }
3205
- async function cleanup(state) {
3792
+ var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
3793
+ async function runSweep(state, driver, config2) {
3794
+ const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
3795
+ try {
3796
+ const sessions = await listSessions(state.port);
3797
+ if (sessions === null) {
3798
+ logActivity(state, {
3799
+ type: "info",
3800
+ message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
3801
+ });
3802
+ return;
3803
+ }
3804
+ const toDelete = selectSessionsToDelete(
3805
+ sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
3806
+ {
3807
+ maxAgeMs: config2.maxAgeMs,
3808
+ maxCount: config2.maxCount,
3809
+ nowMs: Date.now(),
3810
+ protectedIds: driver.protectedSessionIds()
3811
+ }
3812
+ );
3813
+ const protectedNow = driver.protectedSessionIds();
3814
+ let deleted = 0;
3815
+ let failed = 0;
3816
+ let skippedNewlyActive = 0;
3817
+ for (const id of toDelete) {
3818
+ if (protectedNow.has(id)) {
3819
+ skippedNewlyActive++;
3820
+ logActivity(state, {
3821
+ type: "info",
3822
+ message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
3823
+ });
3824
+ continue;
3825
+ }
3826
+ if (await deleteSession(state.port, id)) deleted++;
3827
+ else failed++;
3828
+ }
3829
+ const failedNote = failed > 0 ? `, failed ${failed}` : "";
3830
+ const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
3831
+ logActivity(state, {
3832
+ type: "info",
3833
+ message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
3834
+ });
3835
+ } catch (error2) {
3836
+ const message = error2 instanceof Error ? error2.message : String(error2);
3837
+ logActivity(state, {
3838
+ type: "error",
3839
+ error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
3840
+ });
3841
+ }
3842
+ }
3843
+ function scheduleSessionCleanup(state, driver, options) {
3844
+ const config2 = resolveSessionCleanupConfig(
3845
+ {
3846
+ maxAge: options.sessionCleanupMaxAge,
3847
+ maxCount: options.sessionCleanupMaxCount,
3848
+ interval: options.sessionCleanupInterval
3849
+ },
3850
+ process.env
3851
+ );
3852
+ for (const warning2 of config2.warnings) {
3853
+ logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
3854
+ }
3855
+ if (!config2.enabled) return;
3856
+ logActivity(state, {
3857
+ type: "info",
3858
+ message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
3859
+ });
3860
+ const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
3861
+ const firstSweep = setTimeout(
3862
+ () => void runSweep(state, driver, config2),
3863
+ SESSION_CLEANUP_FIRST_SWEEP_MS
3864
+ );
3865
+ state.sessionCleanupTimers.push(interval, firstSweep);
3866
+ }
3867
+ async function notifyOffline(state) {
3868
+ if (!state.agentId || !state.authHeader) return;
3869
+ if (!state.connected) {
3870
+ log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
3871
+ return;
3872
+ }
3873
+ const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
3874
+ if (result.ok) {
3875
+ log2(state, "Notified Evident the agent is going offline");
3876
+ } else {
3877
+ logActivity(state, {
3878
+ type: "error",
3879
+ error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
3880
+ });
3881
+ if (state.interactive) displayStatus(state);
3882
+ }
3883
+ }
3884
+ async function cleanup(state, opts = {}) {
3206
3885
  state.running = false;
3886
+ for (const timer of state.sessionCleanupTimers) {
3887
+ clearInterval(timer);
3888
+ clearTimeout(timer);
3889
+ }
3890
+ state.sessionCleanupTimers = [];
3891
+ if (opts.graceful && state.channelDriver) {
3892
+ state.channelDriver.stop();
3893
+ log2(state, "Draining in-flight channel work before shutdown...");
3894
+ if (state.interactive) {
3895
+ logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
3896
+ displayStatus(state);
3897
+ }
3898
+ const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
3899
+ if (!settled) {
3900
+ logActivity(state, {
3901
+ type: "info",
3902
+ message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
3903
+ });
3904
+ if (state.interactive) displayStatus(state);
3905
+ }
3906
+ }
3907
+ await notifyOffline(state);
3207
3908
  if (state.connection) {
3208
3909
  state.connection.close();
3209
3910
  state.connection = null;
@@ -3234,10 +3935,13 @@ async function run(options) {
3234
3935
  opencodeVersion: null,
3235
3936
  opencodeProcess: null,
3236
3937
  connection: null,
3938
+ channelDriver: null,
3237
3939
  running: true,
3940
+ shuttingDown: false,
3238
3941
  activityLog: [],
3239
3942
  messageCount: 0,
3240
3943
  lastProxiedActivityAt: null,
3944
+ sessionCleanupTimers: [],
3241
3945
  authHeader: ""
3242
3946
  };
3243
3947
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
@@ -3248,13 +3952,15 @@ async function run(options) {
3248
3952
  );
3249
3953
  }
3250
3954
  const handleSignal = async () => {
3955
+ if (state.shuttingDown) return;
3956
+ state.shuttingDown = true;
3251
3957
  if (state.interactive) {
3252
3958
  logActivity(state, { type: "info", message: "Shutting down..." });
3253
3959
  displayStatus(state);
3254
3960
  } else {
3255
3961
  log2(state, "Shutting down...");
3256
3962
  }
3257
- await cleanup(state);
3963
+ await cleanup(state, { graceful: true });
3258
3964
  await shutdownTelemetry();
3259
3965
  process.exit(0);
3260
3966
  };
@@ -3374,12 +4080,14 @@ async function run(options) {
3374
4080
  apiUrl: getApiUrlConfig(),
3375
4081
  getAuthHeader: () => state.authHeader,
3376
4082
  conversationFilter: state.conversationFilter,
4083
+ stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
3377
4084
  log: (entry) => logActivity(state, {
3378
4085
  type: entry.level === "error" ? "error" : "info",
3379
4086
  message: entry.message,
3380
4087
  error: entry.level === "error" ? entry.message : void 0
3381
4088
  })
3382
4089
  });
4090
+ state.channelDriver = channelDriver;
3383
4091
  const connection = new RunnerConnection({
3384
4092
  agentId: state.agentId,
3385
4093
  getAuthHeader: () => state.authHeader,
@@ -3393,7 +4101,11 @@ async function run(options) {
3393
4101
  type: "info",
3394
4102
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
3395
4103
  });
3396
- emitAgentConnected(state.agentId, { port: state.port });
4104
+ emitAgentConnected(state.agentId, {
4105
+ port: state.port,
4106
+ cli_version: getCliVersion(),
4107
+ opencode_version: state.opencodeVersion
4108
+ });
3397
4109
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
3398
4110
  if (state.interactive) displayStatus(state);
3399
4111
  channelDriver.drainPending().then((processed) => {
@@ -3472,10 +4184,12 @@ async function run(options) {
3472
4184
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
3473
4185
  throw error2;
3474
4186
  }
4187
+ scheduleSessionCleanup(state, channelDriver, options);
3475
4188
  if (!interactive || state.json) {
3476
4189
  log2(state, "Driving channel messages...");
3477
4190
  }
3478
4191
  await driveChannels(state, channelDriver);
4192
+ if (state.shuttingDown) return;
3479
4193
  await cleanup(state);
3480
4194
  if (state.json) {
3481
4195
  console.log(
@@ -3490,6 +4204,7 @@ async function run(options) {
3490
4204
  await shutdownTelemetry();
3491
4205
  process.exit(0);
3492
4206
  } catch (error2) {
4207
+ if (state.shuttingDown) return;
3493
4208
  await cleanup(state);
3494
4209
  const message = error2 instanceof Error ? error2.message : String(error2);
3495
4210
  if (state.json) {
@@ -3524,7 +4239,16 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
3524
4239
  program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
3525
4240
  program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
3526
4241
  program.command("whoami").description("Show the currently logged in user").action(whoami);
3527
- program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
4242
+ program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
4243
+ "--session-cleanup-max-age <duration>",
4244
+ "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4245
+ ).option(
4246
+ "--session-cleanup-max-count <n>",
4247
+ "Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
4248
+ ).option(
4249
+ "--session-cleanup-interval <duration>",
4250
+ "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
4251
+ ).action(
3528
4252
  (options) => {
3529
4253
  run({
3530
4254
  agent: options.agent,
@@ -3532,7 +4256,11 @@ program.command("run").description("Connect to Evident and process messages").op
3532
4256
  verbose: options.verbose,
3533
4257
  conversation: options.conversation,
3534
4258
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
3535
- json: options.json
4259
+ json: options.json,
4260
+ // Raw strings — the resolver in run.ts single-sources parsing (M1).
4261
+ sessionCleanupMaxAge: options.sessionCleanupMaxAge,
4262
+ sessionCleanupMaxCount: options.sessionCleanupMaxCount,
4263
+ sessionCleanupInterval: options.sessionCleanupInterval
3536
4264
  });
3537
4265
  }
3538
4266
  );