@evident-ai/cli 3.0.1-dev.6a683e7 → 3.0.1-dev.7272711

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,72 @@ 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 isSessionActivelyGenerating(messages) {
1082
+ if (!messages || messages.length === 0) return false;
1083
+ const last = messages[messages.length - 1];
1084
+ if (roleOf(last) !== "assistant") return false;
1085
+ return completedOf(last) == null;
1086
+ }
1087
+ function sessionLastActivityMs(session) {
1088
+ const candidates = [
1089
+ session.time?.updated,
1090
+ session.time?.created,
1091
+ session.time_updated,
1092
+ session.time_created,
1093
+ session.updated,
1094
+ session.created
1095
+ ];
1096
+ for (const c of candidates) {
1097
+ if (typeof c === "number" && Number.isFinite(c)) return c;
1098
+ }
1099
+ return null;
1100
+ }
1101
+ async function listSessions(port) {
1102
+ try {
1103
+ const res = await fetch(`${opencodeBase(port)}/session`);
1104
+ if (!res.ok) return null;
1105
+ const body = await res.json();
1106
+ return Array.isArray(body) ? body : null;
1107
+ } catch {
1108
+ return null;
1109
+ }
1110
+ }
1111
+ async function deleteSession(port, id) {
1112
+ try {
1113
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
1114
+ return res.status >= 200 && res.status < 300;
1115
+ } catch {
1116
+ return false;
1117
+ }
1118
+ }
1119
+ async function sessionExists(port, id) {
1120
+ try {
1121
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`);
1122
+ if (res.status >= 200 && res.status < 300) return true;
1123
+ if (res.status === 404) return false;
1124
+ return null;
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ }
1066
1129
  async function createOpenCodeSession(port, directory) {
1067
1130
  const url = new URL(`${opencodeBase(port)}/session`);
1068
1131
  if (directory && directory.trim()) {
@@ -1080,9 +1143,16 @@ async function createOpenCodeSession(port, directory) {
1080
1143
  const data = await response.json();
1081
1144
  return data.id;
1082
1145
  }
1083
- async function sendPromptAsync(port, sessionId, content, options, messageId) {
1146
+ function messageText(m) {
1147
+ if (!m || !Array.isArray(m.parts)) return "";
1148
+ return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1149
+ }
1150
+ async function sendPromptAsync(port, sessionId, content, options) {
1151
+ const before = await getSessionMessages(port, sessionId);
1152
+ const knownUserIds = new Set(
1153
+ (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1154
+ );
1084
1155
  const body = {
1085
- messageID: messageId,
1086
1156
  parts: [{ type: "text", text: content }]
1087
1157
  };
1088
1158
  if (options?.agent) {
@@ -1106,6 +1176,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1106
1176
  const text = await res.text().catch(() => "");
1107
1177
  throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1108
1178
  }
1179
+ const READ_BACK_ATTEMPTS = 5;
1180
+ const READ_BACK_DELAY_MS = 150;
1181
+ for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
1182
+ const after = await getSessionMessages(port, sessionId);
1183
+ if (after) {
1184
+ let best = null;
1185
+ for (const m of after) {
1186
+ if (roleOf(m) !== "user") continue;
1187
+ const id = idOf(m);
1188
+ if (typeof id !== "string" || knownUserIds.has(id)) continue;
1189
+ if (messageText(m) !== content) continue;
1190
+ const created = createdOf(m) ?? 0;
1191
+ if (best === null || created > best.created) {
1192
+ best = { id, created };
1193
+ }
1194
+ }
1195
+ if (best) return best.id;
1196
+ }
1197
+ if (attempt < READ_BACK_ATTEMPTS - 1) {
1198
+ await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
1199
+ }
1200
+ }
1201
+ return null;
1109
1202
  }
1110
1203
  function findAssistantReplyAfter(messages, userMessageId) {
1111
1204
  if (!messages || messages.length === 0) return null;
@@ -1122,19 +1215,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
1122
1215
  }
1123
1216
  function findLastAssistantReplyFor(messages, userMessageId) {
1124
1217
  if (!messages || messages.length === 0) return null;
1218
+ let lastCorrelated = null;
1219
+ let lastNonErrored = null;
1125
1220
  for (let i = messages.length - 1; i >= 0; i--) {
1126
1221
  const m = messages[i];
1127
- if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1222
+ if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
1223
+ if (lastCorrelated === null) lastCorrelated = m;
1224
+ if (errorOf(m) == null) {
1225
+ lastNonErrored = m;
1226
+ break;
1227
+ }
1128
1228
  }
1229
+ if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
1129
1230
  const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1130
1231
  if (userIndex === -1) return null;
1131
1232
  let last = null;
1233
+ let lastOk = null;
1132
1234
  for (let i = userIndex + 1; i < messages.length; i++) {
1133
1235
  const role = roleOf(messages[i]);
1134
1236
  if (role === "user") break;
1135
- if (role === "assistant") last = messages[i];
1237
+ if (role === "assistant") {
1238
+ last = messages[i];
1239
+ if (errorOf(messages[i]) == null) lastOk = messages[i];
1240
+ }
1136
1241
  }
1137
- return last;
1242
+ return lastOk ?? last;
1138
1243
  }
1139
1244
  function messageRunState(messages, userMessageId) {
1140
1245
  if (!messages || messages.length === 0) return "unknown";
@@ -1144,7 +1249,26 @@ function messageRunState(messages, userMessageId) {
1144
1249
  if (!reply) return "unknown";
1145
1250
  }
1146
1251
  if (!reply) return "queued";
1147
- return isAssistantInFlight(reply) ? "running" : "done";
1252
+ if (isAssistantInFlight(reply)) return "running";
1253
+ return errorOf(reply) != null ? "failed" : "done";
1254
+ }
1255
+ function isPreamblePinnedRunning(messages, userMessageId) {
1256
+ if (messageRunState(messages, userMessageId) !== "running") return false;
1257
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1258
+ return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1259
+ }
1260
+ function messageError(messages, userMessageId) {
1261
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1262
+ const error2 = errorOf(reply);
1263
+ if (error2 == null) return null;
1264
+ if (typeof error2 === "string") return error2;
1265
+ if (typeof error2 === "object") {
1266
+ const e = error2;
1267
+ const dataMessage = e.data?.message;
1268
+ if (typeof dataMessage === "string") return dataMessage;
1269
+ if (typeof e.message === "string") return e.message;
1270
+ }
1271
+ return "The agent run failed.";
1148
1272
  }
1149
1273
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1150
1274
  if (!messages || messages.length === 0) return false;
@@ -1152,8 +1276,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1152
1276
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1153
1277
  );
1154
1278
  }
1155
- function opencodeMessageIdFor2(queuedMessageId) {
1156
- return opencodeMessageIdFor(queuedMessageId);
1279
+
1280
+ // src/lib/opencode/session-cleanup.ts
1281
+ var DURATION_UNIT_MS = {
1282
+ s: 1e3,
1283
+ m: 60 * 1e3,
1284
+ h: 60 * 60 * 1e3,
1285
+ d: 24 * 60 * 60 * 1e3
1286
+ };
1287
+ function parseDurationMs(input) {
1288
+ const trimmed = input.trim();
1289
+ const match = /^(\d+)([smhd])$/.exec(trimmed);
1290
+ if (!match) {
1291
+ throw new Error(
1292
+ `Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
1293
+ );
1294
+ }
1295
+ const value = Number(match[1]);
1296
+ if (value <= 0) {
1297
+ throw new Error(`Invalid duration "${input}": must be a positive value.`);
1298
+ }
1299
+ return value * DURATION_UNIT_MS[match[2]];
1300
+ }
1301
+ function selectSessionsToDelete(sessions, opts) {
1302
+ const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
1303
+ if (maxAgeMs === void 0 && maxCount === void 0) return [];
1304
+ const ageEligible = (s) => {
1305
+ if (maxAgeMs === void 0) return false;
1306
+ if (s.lastActivityMs === null) return true;
1307
+ return nowMs - s.lastActivityMs > maxAgeMs;
1308
+ };
1309
+ const countEligibleIds = /* @__PURE__ */ new Set();
1310
+ if (maxCount !== void 0) {
1311
+ const byActivityDesc = [...sessions].sort(
1312
+ (a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
1313
+ );
1314
+ for (const s of byActivityDesc.slice(maxCount)) {
1315
+ countEligibleIds.add(s.id);
1316
+ }
1317
+ }
1318
+ const toDelete = [];
1319
+ for (const s of sessions) {
1320
+ if (protectedIds.has(s.id)) continue;
1321
+ if (ageEligible(s) || countEligibleIds.has(s.id)) {
1322
+ toDelete.push(s.id);
1323
+ }
1324
+ }
1325
+ return toDelete;
1326
+ }
1327
+ var DEFAULT_INTERVAL = "1h";
1328
+ function resolve(flag, envValue, fallback) {
1329
+ return flag ?? envValue ?? fallback;
1330
+ }
1331
+ function parseMaxCount(input) {
1332
+ const trimmed = input.trim();
1333
+ if (!/^\d+$/.test(trimmed)) {
1334
+ throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
1335
+ }
1336
+ const value = Number(trimmed);
1337
+ if (value <= 0) {
1338
+ throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
1339
+ }
1340
+ return value;
1341
+ }
1342
+ function resolveSessionCleanupConfig(flags, env = process.env) {
1343
+ const warnings = [];
1344
+ const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
1345
+ const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
1346
+ const intervalRaw = resolve(
1347
+ flags.interval,
1348
+ env.EVIDENT_SESSION_CLEANUP_INTERVAL,
1349
+ DEFAULT_INTERVAL
1350
+ );
1351
+ let maxAgeMs;
1352
+ if (maxAgeRaw !== void 0) {
1353
+ try {
1354
+ maxAgeMs = parseDurationMs(maxAgeRaw);
1355
+ } catch (err) {
1356
+ warnings.push(
1357
+ `Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
1358
+ );
1359
+ }
1360
+ }
1361
+ let maxCount;
1362
+ if (maxCountRaw !== void 0) {
1363
+ try {
1364
+ maxCount = parseMaxCount(maxCountRaw);
1365
+ } catch (err) {
1366
+ warnings.push(
1367
+ `Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
1368
+ );
1369
+ }
1370
+ }
1371
+ let intervalMs;
1372
+ try {
1373
+ intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
1374
+ } catch (err) {
1375
+ warnings.push(
1376
+ `Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
1377
+ );
1378
+ intervalMs = parseDurationMs(DEFAULT_INTERVAL);
1379
+ }
1380
+ const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
1381
+ return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
1157
1382
  }
1158
1383
 
1159
1384
  // src/lib/tunnel/connection.ts
@@ -1232,24 +1457,26 @@ var StreamForwarder = class {
1232
1457
  this.send({ type: "res_end", sid });
1233
1458
  return;
1234
1459
  }
1235
- log("info", "agent_request", {
1236
- correlation_id: correlationId,
1237
- sid,
1238
- method,
1239
- path: stripQuery(path)
1240
- });
1460
+ if (process.env.DEBUG) {
1461
+ log("debug", "agent_request", {
1462
+ correlation_id: correlationId,
1463
+ sid,
1464
+ method,
1465
+ path: stripQuery(path)
1466
+ });
1467
+ }
1241
1468
  const ac = new AbortController();
1242
1469
  let bodyPromise;
1243
1470
  let pushBody;
1244
1471
  let endBody;
1245
1472
  if (has_body) {
1246
1473
  const chunks = [];
1247
- bodyPromise = new Promise((resolve) => {
1474
+ bodyPromise = new Promise((resolve2) => {
1248
1475
  pushBody = (buf) => {
1249
1476
  chunks.push(buf);
1250
1477
  };
1251
1478
  endBody = () => {
1252
- resolve(Buffer.concat(chunks));
1479
+ resolve2(Buffer.concat(chunks));
1253
1480
  };
1254
1481
  });
1255
1482
  }
@@ -1284,12 +1511,14 @@ var StreamForwarder = class {
1284
1511
  if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1285
1512
  });
1286
1513
  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
- });
1514
+ if (process.env.DEBUG) {
1515
+ log("debug", "agent_response", {
1516
+ correlation_id: correlationId,
1517
+ sid,
1518
+ status: upstream.status,
1519
+ duration_ms: Date.now() - startedAt
1520
+ });
1521
+ }
1293
1522
  this.callbacks.onHead?.(sid, upstream.status);
1294
1523
  try {
1295
1524
  if (upstream.body) {
@@ -1365,7 +1594,7 @@ function connectTunnel(options) {
1365
1594
  } = options;
1366
1595
  const tunnelUrl = getTunnelUrlConfig();
1367
1596
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1368
- return new Promise((resolve, reject) => {
1597
+ return new Promise((resolve2, reject) => {
1369
1598
  const ws = new WebSocket2(url, {
1370
1599
  headers: {
1371
1600
  Authorization: authHeader
@@ -1430,7 +1659,7 @@ function connectTunnel(options) {
1430
1659
  clearTimeout(connectionTimeout);
1431
1660
  const connectedAgentId = message.agent_id ?? agentId;
1432
1661
  onConnected?.(connectedAgentId);
1433
- resolve({
1662
+ resolve2({
1434
1663
  ws,
1435
1664
  close: () => ws.close(1e3, "CLI shutdown")
1436
1665
  });
@@ -1559,8 +1788,9 @@ var DEFAULT_RETRY_POLICY = {
1559
1788
  };
1560
1789
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1561
1790
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1562
- var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1563
1791
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1792
+ var HEARTBEAT_MS = 6e4;
1793
+ var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
1564
1794
  var ChannelAuthError = class extends Error {
1565
1795
  constructor(message) {
1566
1796
  super(message);
@@ -1595,11 +1825,18 @@ var ChannelDriver = class {
1595
1825
  sleep;
1596
1826
  pausedPollIntervalMs;
1597
1827
  pausedMaxWaitMs;
1598
- dispatchConfirmMs;
1599
1828
  stuckQueuedMs;
1600
1829
  now;
1601
1830
  /** Cache of conversationId → opencode sessionId. */
1602
1831
  sessions = /* @__PURE__ */ new Map();
1832
+ /**
1833
+ * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1834
+ * longer idempotent (no caller-supplied `messageID`), and its read-back picks
1835
+ * "the one new user row" — which is only unambiguous if no OTHER dispatch into
1836
+ * the SAME session interleaves its snapshot→POST→read-back. This map chains each
1837
+ * session's dispatches so they run serially; distinct sessions stay concurrent.
1838
+ */
1839
+ sessionDispatchLocks = /* @__PURE__ */ new Map();
1603
1840
  /**
1604
1841
  * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1605
1842
  * session: one polling loop services all of that session's in-flight messages.
@@ -1650,6 +1887,20 @@ var ChannelDriver = class {
1650
1887
  * the row leaves the processing list, exactly like `dontRedispatch`.
1651
1888
  */
1652
1889
  doneUndeliverable = /* @__PURE__ */ new Set();
1890
+ /**
1891
+ * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1892
+ * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
1893
+ * is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
1894
+ * persist hasn't landed before tick N+1 re-reads the still-null
1895
+ * `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
1896
+ * A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
1897
+ * short-circuits while it is present, so a null-id row is re-dispatched AT MOST
1898
+ * ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
1899
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
1900
+ * re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
1901
+ * so the NEXT tick may retry exactly once more).
1902
+ */
1903
+ awaitingReadopt = /* @__PURE__ */ new Set();
1653
1904
  /**
1654
1905
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1655
1906
  * first session creation so drain-created sessions are rooted at the project
@@ -1657,8 +1908,33 @@ var ChannelDriver = class {
1657
1908
  * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1658
1909
  */
1659
1910
  opencodeDirectory = void 0;
1911
+ /**
1912
+ * Cache of opencode `sessionId → parentID` (its parent session, or `null` when
1913
+ * the session is a root with no parent). Sub-agents spawned via the `task` tool
1914
+ * run in CHILD sessions whose `parentID` chains up to the Evident-created
1915
+ * (watched) session; we resolve this once per session so a child-session
1916
+ * question/permission can be attributed to the watched session's subtree
1917
+ * (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
1918
+ * entry = not yet resolved; `null` = resolved root (stop walking).
1919
+ */
1920
+ sessionParents = /* @__PURE__ */ new Map();
1660
1921
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1661
1922
  draining = false;
1923
+ /**
1924
+ * The currently-executing `drainPending()` promise, or null when idle. Lets a
1925
+ * graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
1926
+ * is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
1927
+ * drain that entered before `stop()` still registers its watcher).
1928
+ */
1929
+ activeDrain = null;
1930
+ /**
1931
+ * Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
1932
+ * dispatches NEW work (it returns 0 immediately) — but the per-session watcher
1933
+ * loops already running keep going so in-flight turns can finish and deliver
1934
+ * their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
1935
+ * and stops opencode.
1936
+ */
1937
+ stopped = false;
1662
1938
  constructor(config2) {
1663
1939
  this.agentId = config2.agentId;
1664
1940
  this.port = config2.port;
@@ -1672,7 +1948,6 @@ var ChannelDriver = class {
1672
1948
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1673
1949
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1674
1950
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1675
- this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1676
1951
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1677
1952
  this.now = config2.now ?? (() => Date.now());
1678
1953
  }
@@ -1691,8 +1966,21 @@ var ChannelDriver = class {
1691
1966
  * @returns the number of messages NEWLY dispatched to opencode's native queue.
1692
1967
  */
1693
1968
  async drainPending() {
1969
+ if (this.stopped) return 0;
1694
1970
  if (this.draining) return 0;
1695
1971
  this.draining = true;
1972
+ const run2 = this.runDrain();
1973
+ this.activeDrain = run2.then(
1974
+ () => {
1975
+ this.activeDrain = null;
1976
+ },
1977
+ () => {
1978
+ this.activeDrain = null;
1979
+ }
1980
+ );
1981
+ return run2;
1982
+ }
1983
+ async runDrain() {
1696
1984
  let dispatched = 0;
1697
1985
  try {
1698
1986
  const conversations = await this.getPendingConversations();
@@ -1704,6 +1992,7 @@ var ChannelDriver = class {
1704
1992
  });
1705
1993
  }
1706
1994
  for (const conv of conversations) {
1995
+ if (this.stopped) break;
1707
1996
  dispatched += await this.processConversation(conv);
1708
1997
  }
1709
1998
  await this.readoptProcessing();
@@ -1724,6 +2013,73 @@ var ChannelDriver = class {
1724
2013
  }
1725
2014
  return false;
1726
2015
  }
2016
+ /**
2017
+ * OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
2018
+ * exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
2019
+ * `watchers` entry whose `inFlight` set is non-empty — the same predicate
2020
+ * `hasInFlightWatchers()` uses, lifted to return the ids.
2021
+ *
2022
+ * Deliberately does NOT include `this.sessions` (the permanent, never-pruned
2023
+ * conversation→session cache). Protecting every bound-but-idle session there
2024
+ * would shield nearly every session and defeat cleanup — AND it is unnecessary:
2025
+ * `ensureSession` is self-healing (it recreates a session whose id no longer
2026
+ * exists), so deleting an idle bound session is harmless — the conversation's
2027
+ * next turn transparently rebinds a fresh one. The only thing worth protecting
2028
+ * is a session with a turn ACTIVELY in flight right now: tearing that down
2029
+ * mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
2030
+ */
2031
+ protectedSessionIds() {
2032
+ const ids = /* @__PURE__ */ new Set();
2033
+ for (const [sessionId, watcher] of this.watchers) {
2034
+ if (watcher.inFlight.size > 0) ids.add(sessionId);
2035
+ }
2036
+ return ids;
2037
+ }
2038
+ /**
2039
+ * Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
2040
+ * this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
2041
+ * — but the watcher loops already tracking in-flight turns keep running, so a
2042
+ * turn that has finished (or is about to) still fires `markDone` and delivers
2043
+ * its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
2044
+ */
2045
+ stop() {
2046
+ this.stopped = true;
2047
+ }
2048
+ /**
2049
+ * Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
2050
+ * graceful shutdown, so a turn whose reply is ready — or completes within the
2051
+ * window — is delivered before the process exits, instead of being cut off and
2052
+ * left for the ADR-0046 restart-recovery path.
2053
+ *
2054
+ * Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
2055
+ * far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
2056
+ * window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
2057
+ * set empties OR the timeout elapses. Anything still in flight at the timeout is
2058
+ * safe to abandon — it stays `processing` server-side and is re-adopted on the
2059
+ * next runner start (ADR-0046).
2060
+ *
2061
+ * @returns true if all in-flight work settled within the window; false if the
2062
+ * timeout elapsed with work still in flight.
2063
+ */
2064
+ async waitForInFlight(timeoutMs) {
2065
+ const deadline = this.now() + timeoutMs;
2066
+ const step = Math.min(this.pausedPollIntervalMs, 250);
2067
+ if (this.activeDrain) {
2068
+ let drainSettled = false;
2069
+ void this.activeDrain.then(() => {
2070
+ drainSettled = true;
2071
+ });
2072
+ while (!drainSettled) {
2073
+ if (this.now() >= deadline) return false;
2074
+ await this.sleep(step);
2075
+ }
2076
+ }
2077
+ while (this.hasInFlightWatchers()) {
2078
+ if (this.now() >= deadline) return false;
2079
+ await this.sleep(step);
2080
+ }
2081
+ return true;
2082
+ }
1727
2083
  /**
1728
2084
  * Await all outstanding per-session watchers (WI-3).
1729
2085
  *
@@ -1760,15 +2116,16 @@ var ChannelDriver = class {
1760
2116
  let dispatched = 0;
1761
2117
  let skippedAlreadyDispatched = 0;
1762
2118
  for (const message of messages) {
2119
+ if (this.stopped) break;
1763
2120
  if (this.dispatched.has(message.id)) {
1764
2121
  skippedAlreadyDispatched += 1;
1765
2122
  continue;
1766
2123
  }
1767
- const opencodeMessageId = opencodeMessageIdFor2(message.id);
1768
2124
  const options = {
1769
2125
  agent: message.opencode_agent ?? void 0,
1770
2126
  model: message.opencode_model ?? void 0
1771
2127
  };
2128
+ let opencodeMessageId;
1772
2129
  try {
1773
2130
  this.log({
1774
2131
  level: "info",
@@ -1776,10 +2133,23 @@ var ChannelDriver = class {
1776
2133
  conversation_id: conv.id,
1777
2134
  message_id: message.id
1778
2135
  });
1779
- await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
2136
+ opencodeMessageId = await this.dispatchLocked(
2137
+ sessionId,
2138
+ () => sendPromptAsync(this.port, sessionId, message.content, options)
2139
+ );
1780
2140
  } catch (err) {
1781
2141
  if (err instanceof ChannelAuthError) throw err;
1782
2142
  this.dispatched.delete(message.id);
2143
+ if (await sessionExists(this.port, sessionId) === false) {
2144
+ this.sessions.delete(conv.id);
2145
+ this.log({
2146
+ level: "info",
2147
+ 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.`,
2148
+ conversation_id: conv.id,
2149
+ message_id: message.id
2150
+ });
2151
+ break;
2152
+ }
1783
2153
  await this.markFailed(conv.id, message.id).catch(() => {
1784
2154
  });
1785
2155
  this.log({
@@ -1790,6 +2160,15 @@ var ChannelDriver = class {
1790
2160
  });
1791
2161
  continue;
1792
2162
  }
2163
+ if (opencodeMessageId === null) {
2164
+ this.log({
2165
+ level: "error",
2166
+ 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`,
2167
+ conversation_id: conv.id,
2168
+ message_id: message.id
2169
+ });
2170
+ continue;
2171
+ }
1793
2172
  this.dispatched.add(message.id);
1794
2173
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1795
2174
  dispatched += 1;
@@ -1806,16 +2185,33 @@ var ChannelDriver = class {
1806
2185
  return dispatched;
1807
2186
  }
1808
2187
  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;
2188
+ const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
2189
+ if (bound) {
2190
+ const exists = await sessionExists(this.port, bound);
2191
+ if (exists === false) {
2192
+ this.log({
2193
+ level: "info",
2194
+ 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.`,
2195
+ conversation_id: conv.id
2196
+ });
2197
+ this.sessions.delete(conv.id);
2198
+ return this.createAndBindSession(conv.id);
2199
+ }
2200
+ this.sessions.set(conv.id, bound);
2201
+ return bound;
1814
2202
  }
2203
+ return this.createAndBindSession(conv.id);
2204
+ }
2205
+ /**
2206
+ * Create a fresh OpenCode session for a conversation, cache the binding, and
2207
+ * best-effort persist it server-side. Shared by the first-ever bind and the
2208
+ * self-heal recreate path in `ensureSession`.
2209
+ */
2210
+ async createAndBindSession(conversationId) {
1815
2211
  const directory = await this.resolveOpenCodeDirectory();
1816
2212
  const sessionId = await createOpenCodeSession(this.port, directory);
1817
- this.sessions.set(conv.id, sessionId);
1818
- await this.persistSession(conv.id, sessionId).catch(() => {
2213
+ this.sessions.set(conversationId, sessionId);
2214
+ await this.persistSession(conversationId, sessionId).catch(() => {
1819
2215
  });
1820
2216
  return sessionId;
1821
2217
  }
@@ -1838,6 +2234,25 @@ var ChannelDriver = class {
1838
2234
  // -------------------------------------------------------------------------
1839
2235
  // Per-session watcher (WI-3)
1840
2236
  // -------------------------------------------------------------------------
2237
+ /**
2238
+ * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2239
+ * opencode session (Task 2.1a), so two dispatches into the SAME session can
2240
+ * never interleave and mis-correlate their read-backs. Distinct sessions run
2241
+ * concurrently. The chained tail intentionally ignores the prior result/error
2242
+ * (each dispatch reports its own outcome to its caller).
2243
+ */
2244
+ dispatchLocked(sessionId, fn) {
2245
+ const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
2246
+ const run2 = prior.then(fn, fn);
2247
+ this.sessionDispatchLocks.set(
2248
+ sessionId,
2249
+ run2.then(
2250
+ () => void 0,
2251
+ () => void 0
2252
+ )
2253
+ );
2254
+ return run2;
2255
+ }
1841
2256
  /** Register a freshly-dispatched message with its session's watcher state. */
1842
2257
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
1843
2258
  let watcher = this.watchers.get(sessionId);
@@ -1847,7 +2262,9 @@ var ChannelDriver = class {
1847
2262
  inFlight: /* @__PURE__ */ new Map(),
1848
2263
  loop: null,
1849
2264
  reportedQuestions: /* @__PURE__ */ new Set(),
1850
- reportedPermissions: /* @__PURE__ */ new Set()
2265
+ reportedPermissions: /* @__PURE__ */ new Set(),
2266
+ lastGoodPollAt: this.now(),
2267
+ hadUsablePoll: false
1851
2268
  };
1852
2269
  this.watchers.set(sessionId, watcher);
1853
2270
  }
@@ -1860,17 +2277,33 @@ var ChannelDriver = class {
1860
2277
  deadline: now + this.pausedMaxWaitMs,
1861
2278
  started: false,
1862
2279
  done: false,
1863
- stuckReported: false
2280
+ stuckReported: false,
2281
+ lastAliveAt: 0,
2282
+ aliveInFlight: false,
2283
+ awaitingHumanLatched: false,
2284
+ pausedOnQuestion: false,
2285
+ pausedOnPermission: false,
2286
+ pausedClearConfirmed: false,
2287
+ pausedInFlight: false,
2288
+ deliveryDeadlineAnchored: false
1864
2289
  });
1865
2290
  }
1866
2291
  /**
1867
2292
  * Register a RE-ADOPTED `processing` message with its session watcher
1868
2293
  * (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
1869
2294
  * `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
1870
- * `now`: a row already `processing` for e.g. 5 min must give up ~5 min from now
1871
- * (10 min after `processed_at`), not 10 min from now — otherwise its deadline
1872
- * lands ~15 min after `processed_at`, coinciding with the cron reset →
1873
- * double-drive race. `dispatchedAt` stays `now` (only the appear-guard uses it).
2295
+ * `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
2296
+ * fresh dispatch would (10 min after `processed_at`, not 10 min from now).
2297
+ *
2298
+ * This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
2299
+ * give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
2300
+ * opencode reports ACTIVELY `running` is watched to completion (its liveness
2301
+ * heartbeat keeps the cron off its row), while a re-adopted turn that is paused
2302
+ * awaiting a human — or queued/unreachable — is still bounded by `deadline` and
2303
+ * handed to the cron. The old "the `deadline` must settle before the ~15-min
2304
+ * cron or they double-drive" reasoning is superseded: liveness now settles the
2305
+ * actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
2306
+ * (only the appear-guard uses it).
1874
2307
  *
1875
2308
  * `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
1876
2309
  * `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
@@ -1888,7 +2321,9 @@ var ChannelDriver = class {
1888
2321
  inFlight: /* @__PURE__ */ new Map(),
1889
2322
  loop: null,
1890
2323
  reportedQuestions: /* @__PURE__ */ new Set(),
1891
- reportedPermissions: /* @__PURE__ */ new Set()
2324
+ reportedPermissions: /* @__PURE__ */ new Set(),
2325
+ lastGoodPollAt: this.now(),
2326
+ hadUsablePoll: false
1892
2327
  };
1893
2328
  this.watchers.set(sessionId, watcher);
1894
2329
  }
@@ -1902,11 +2337,24 @@ var ChannelDriver = class {
1902
2337
  started: true,
1903
2338
  done: false,
1904
2339
  // 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).
1909
- stuckReported: false
2340
+ // AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
2341
+ // on `state === 'queued'` (turn produced no reply), not on `started`, so a
2342
+ // re-adopted row left wedged in `queued` still emits the signal once
2343
+ // (#210/#220 observability).
2344
+ stuckReported: false,
2345
+ // Task 5.2: a re-adopted actively-running row re-attaches into the SAME
2346
+ // watcher and so hits the SAME actively-running heartbeat branch in
2347
+ // `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
2348
+ // re-adopted and is confirming this row alive" via that `alive` heartbeat,
2349
+ // with no extra `re_adopted` signal needed (folds old WI-6).
2350
+ lastAliveAt: 0,
2351
+ aliveInFlight: false,
2352
+ awaitingHumanLatched: false,
2353
+ pausedOnQuestion: false,
2354
+ pausedOnPermission: false,
2355
+ pausedClearConfirmed: false,
2356
+ pausedInFlight: false,
2357
+ deliveryDeadlineAnchored: false
1910
2358
  });
1911
2359
  }
1912
2360
  /**
@@ -1957,12 +2405,30 @@ var ChannelDriver = class {
1957
2405
  messages = Array.isArray(body) ? body : null;
1958
2406
  }
1959
2407
  } catch {
1960
- continue;
1961
2408
  }
2409
+ if (messages != null && messages.length > 0) {
2410
+ watcher.lastGoodPollAt = this.now();
2411
+ watcher.hadUsablePoll = true;
2412
+ } else {
2413
+ const emptyButReachable = messages != null;
2414
+ const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
2415
+ if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
2416
+ continue;
2417
+ }
2418
+ }
2419
+ const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
1962
2420
  for (const inFlight of [...watcher.inFlight.values()]) {
1963
- await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
2421
+ await this.serviceInFlightMessage(
2422
+ sessionId,
2423
+ watcher,
2424
+ inFlight,
2425
+ messages,
2426
+ openQuestions,
2427
+ openPermissions,
2428
+ questionsPolledOk,
2429
+ permissionsPolledOk
2430
+ );
1964
2431
  }
1965
- await this.pollInteractions(sessionId, watcher, messages);
1966
2432
  }
1967
2433
  } catch (err) {
1968
2434
  if (err instanceof ChannelAuthError) {
@@ -1984,19 +2450,49 @@ var ChannelDriver = class {
1984
2450
  });
1985
2451
  }
1986
2452
  }
2453
+ /**
2454
+ * On FIRST observing a terminal (done/failed) state, ensure the delivery
2455
+ * (markDone/markFailed) transient-retry path has a real window. A long
2456
+ * ACTIVELY-running turn is kept past its original `deadline`, so by completion
2457
+ * `now >= deadline` already holds and the retry bound below would fire on the
2458
+ * first transient PATCH failure — dropping the message before its reply lands
2459
+ * (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
2460
+ * to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
2461
+ * or past now, so a still-ample window is left untouched.
2462
+ */
2463
+ anchorDeliveryDeadline(inFlight) {
2464
+ if (inFlight.deliveryDeadlineAnchored) return;
2465
+ inFlight.deliveryDeadlineAnchored = true;
2466
+ if (this.now() >= inFlight.deadline) {
2467
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2468
+ }
2469
+ }
1987
2470
  /**
1988
2471
  * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1989
2472
  * Fires markProcessing on queued→running and markDone on done (each once),
1990
2473
  * applies the idle-path re-dispatch guard, and removes the message from the
1991
2474
  * in-flight set on completion or timeout.
1992
2475
  */
1993
- async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
2476
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
1994
2477
  const conv = watcher.conv;
1995
2478
  const state = messageRunState(messages, inFlight.opencodeMessageId);
1996
- if ((state === "running" || state === "done") && !inFlight.started) {
2479
+ const id = inFlight.evidentMessageId;
2480
+ if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
2481
+ else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
2482
+ if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
2483
+ else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
2484
+ const observedOpen = openQuestions.has(id) || openPermissions.has(id);
2485
+ const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2486
+ const awaitingHuman = observedOpen || latchedPaused;
2487
+ if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
1997
2488
  let claimed;
1998
2489
  try {
1999
- claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
2490
+ claimed = await this.markProcessing(
2491
+ conv.id,
2492
+ inFlight.evidentMessageId,
2493
+ sessionId,
2494
+ inFlight.opencodeMessageId
2495
+ );
2000
2496
  } catch (err) {
2001
2497
  if (err instanceof ChannelAuthError) throw err;
2002
2498
  this.log({
@@ -2018,6 +2514,7 @@ var ChannelDriver = class {
2018
2514
  }
2019
2515
  }
2020
2516
  if (state === "done") {
2517
+ this.anchorDeliveryDeadline(inFlight);
2021
2518
  if (!inFlight.done) {
2022
2519
  this.log({
2023
2520
  level: "info",
@@ -2026,7 +2523,12 @@ var ChannelDriver = class {
2026
2523
  message_id: inFlight.evidentMessageId
2027
2524
  });
2028
2525
  try {
2029
- await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
2526
+ await this.markDone(
2527
+ conv.id,
2528
+ inFlight.evidentMessageId,
2529
+ sessionId,
2530
+ inFlight.opencodeMessageId
2531
+ );
2030
2532
  } catch (err) {
2031
2533
  if (err instanceof ChannelAuthError) throw err;
2032
2534
  if (err instanceof ChannelTerminalError) {
@@ -2062,59 +2564,104 @@ var ChannelDriver = class {
2062
2564
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2063
2565
  return;
2064
2566
  }
2065
- if (state === "unknown") {
2066
- if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
2067
- await this.redispatchInFlight(sessionId, inFlight);
2567
+ if (state === "failed") {
2568
+ this.anchorDeliveryDeadline(inFlight);
2569
+ if (!inFlight.done) {
2570
+ const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
2571
+ this.log({
2572
+ level: "error",
2573
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} errored \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2574
+ conversation_id: conv.id,
2575
+ message_id: inFlight.evidentMessageId
2576
+ });
2577
+ try {
2578
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
2579
+ } catch (err) {
2580
+ if (err instanceof ChannelAuthError) throw err;
2581
+ if (err instanceof ChannelTerminalError) {
2582
+ this.log({
2583
+ level: "error",
2584
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2585
+ conversation_id: conv.id,
2586
+ message_id: inFlight.evidentMessageId
2587
+ });
2588
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2589
+ return;
2590
+ }
2591
+ if (this.now() >= inFlight.deadline) {
2592
+ this.log({
2593
+ level: "error",
2594
+ 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)}`,
2595
+ conversation_id: conv.id,
2596
+ message_id: inFlight.evidentMessageId
2597
+ });
2598
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2599
+ return;
2600
+ }
2601
+ this.log({
2602
+ level: "error",
2603
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2604
+ conversation_id: conv.id,
2605
+ message_id: inFlight.evidentMessageId
2606
+ });
2607
+ return;
2608
+ }
2609
+ inFlight.done = true;
2068
2610
  }
2611
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2612
+ return;
2069
2613
  }
2070
- if (state === "queued" && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
2614
+ const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
2615
+ const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
2616
+ if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
2071
2617
  inFlight.stuckReported = true;
2072
2618
  void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
2073
2619
  stuck_for_ms: this.now() - inFlight.dispatchedAt
2074
2620
  });
2075
2621
  }
2076
- if (this.now() >= inFlight.deadline) {
2622
+ const activelyRunning = state === "running" && !awaitingHuman;
2623
+ if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
2624
+ inFlight.aliveInFlight = true;
2625
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
2626
+ inFlight.aliveInFlight = false;
2627
+ if (ok) inFlight.lastAliveAt = this.now();
2628
+ });
2629
+ }
2630
+ if (awaitingHuman) {
2631
+ if (!inFlight.awaitingHumanLatched) {
2632
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2633
+ inFlight.awaitingHumanLatched = true;
2634
+ }
2635
+ if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
2636
+ inFlight.pausedInFlight = true;
2637
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
2638
+ inFlight.pausedInFlight = false;
2639
+ if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
2640
+ });
2641
+ }
2642
+ } else if (inFlight.awaitingHumanLatched) {
2643
+ inFlight.awaitingHumanLatched = false;
2644
+ inFlight.pausedOnQuestion = false;
2645
+ inFlight.pausedOnPermission = false;
2646
+ inFlight.pausedClearConfirmed = false;
2647
+ }
2648
+ const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
2649
+ const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
2650
+ (sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
2651
+ );
2652
+ const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2653
+ if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2077
2654
  this.log({
2078
2655
  level: "info",
2079
2656
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2080
2657
  conversation_id: conv.id,
2081
2658
  message_id: inFlight.evidentMessageId
2082
2659
  });
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
2660
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2661
+ watched_for_ms: this.now() - inFlight.dispatchedAt
2115
2662
  });
2663
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2116
2664
  }
2117
- inFlight.dispatchedAt = this.now();
2118
2665
  }
2119
2666
  // -------------------------------------------------------------------------
2120
2667
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
@@ -2203,10 +2750,17 @@ var ChannelDriver = class {
2203
2750
  * Re-adopt ONE `processing` row against the tick's session message snapshot
2204
2751
  * (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
2205
2752
  *
2206
- * Branches on `messageRunState(messages, opencodeMessageIdFor(row.id))`:
2753
+ * Branches on `messageRunState(messages, row.opencode_message_id)` — the
2754
+ * opencode-assigned user-message id persisted on the first `processing` PATCH
2755
+ * (#218). A row with a NULL stored id (dispatched but the read-back never landed
2756
+ * before the restart) has no id to correlate → treated as an orphan and
2757
+ * re-dispatched (at most once, see `forceReadoptRun`):
2207
2758
  * - `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).
2759
+ * - `failed` `markFailed` with the surfaced error (issue #182), so an
2760
+ * errored turn is reported failed on restart, NOT re-dispatched;
2761
+ * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
2762
+ * tracking the stored id so the reply correlates by it;
2763
+ * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
2210
2764
  *
2211
2765
  * Only `ChannelAuthError` propagates.
2212
2766
  */
@@ -2220,8 +2774,8 @@ var ChannelDriver = class {
2220
2774
  });
2221
2775
  return;
2222
2776
  }
2223
- const ocId = opencodeMessageIdFor2(row.id);
2224
- const state = messageRunState(messages, ocId);
2777
+ const ocId = row.opencode_message_id;
2778
+ const state = messageRunState(messages, ocId ?? "");
2225
2779
  if (state === "done") {
2226
2780
  if (this.doneUndeliverable.has(row.id)) {
2227
2781
  this.log({
@@ -2239,7 +2793,7 @@ var ChannelDriver = class {
2239
2793
  message_id: row.id
2240
2794
  });
2241
2795
  try {
2242
- await this.markDone(row.conversation_id, row.id, sessionId);
2796
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId);
2243
2797
  } catch (err) {
2244
2798
  if (err instanceof ChannelAuthError) throw err;
2245
2799
  if (err instanceof ChannelTerminalError) {
@@ -2263,6 +2817,39 @@ var ChannelDriver = class {
2263
2817
  this.dontRedispatch.delete(row.id);
2264
2818
  return;
2265
2819
  }
2820
+ if (state === "failed") {
2821
+ const error2 = messageError(messages, ocId ?? "") ?? void 0;
2822
+ this.log({
2823
+ level: "error",
2824
+ message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
2825
+ conversation_id: row.conversation_id,
2826
+ message_id: row.id
2827
+ });
2828
+ try {
2829
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2);
2830
+ } catch (err) {
2831
+ if (err instanceof ChannelAuthError) throw err;
2832
+ if (err instanceof ChannelTerminalError) {
2833
+ this.doneUndeliverable.add(row.id);
2834
+ this.log({
2835
+ level: "error",
2836
+ 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}`,
2837
+ conversation_id: row.conversation_id,
2838
+ message_id: row.id
2839
+ });
2840
+ return;
2841
+ }
2842
+ this.log({
2843
+ level: "error",
2844
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2845
+ conversation_id: row.conversation_id,
2846
+ message_id: row.id
2847
+ });
2848
+ return;
2849
+ }
2850
+ this.dontRedispatch.delete(row.id);
2851
+ return;
2852
+ }
2266
2853
  if (this.dontRedispatch.has(row.id)) {
2267
2854
  this.log({
2268
2855
  level: "info",
@@ -2272,7 +2859,27 @@ var ChannelDriver = class {
2272
2859
  });
2273
2860
  return;
2274
2861
  }
2275
- if (state === "running" || state === "queued") {
2862
+ if (state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
2863
+ const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2864
+ if (descendantAlive === true) {
2865
+ this.log({
2866
+ level: "info",
2867
+ message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
2868
+ conversation_id: row.conversation_id,
2869
+ message_id: row.id
2870
+ });
2871
+ } else {
2872
+ this.log({
2873
+ level: "info",
2874
+ message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned on recovery (root ${sessionId.slice(0, 8)}), no live descendant runner \u2014 re-dispatching from scratch${descendantAlive === null ? " (descendant liveness indeterminate; a restart guarantees no live runner, so this does NOT block the re-dispatch)" : ""}`,
2875
+ conversation_id: row.conversation_id,
2876
+ message_id: row.id
2877
+ });
2878
+ await this.forceReadoptRun(sessionId, row);
2879
+ return;
2880
+ }
2881
+ }
2882
+ if ((state === "running" || state === "queued") && ocId) {
2276
2883
  const conv = this.convForRow(sessionId, row);
2277
2884
  const message = this.queuedMessageForRow(row);
2278
2885
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
@@ -2281,7 +2888,7 @@ var ChannelDriver = class {
2281
2888
  this.ensureWatcherRunning(sessionId);
2282
2889
  this.log({
2283
2890
  level: "info",
2284
- message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stable id, no re-dispatch)`,
2891
+ message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2285
2892
  conversation_id: row.conversation_id,
2286
2893
  message_id: row.id
2287
2894
  });
@@ -2290,23 +2897,45 @@ var ChannelDriver = class {
2290
2897
  await this.forceReadoptRun(sessionId, row);
2291
2898
  }
2292
2899
  /**
2293
- * Re-dispatch an orphaned (`unknown`) `processing` row (ADR-0046 Decision §2).
2900
+ * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
2901
+ *
2902
+ * #218/WI-5: the row's user message is absent (never kept, or a null stored id),
2903
+ * so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
2904
+ * read it back, and register the watcher under the assigned id so the reply
2905
+ * correlates server-side.
2294
2906
  *
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.
2907
+ * ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
2908
+ * id). Without a guard, if this dispatches on tick N but the read-back+persist
2909
+ * hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
2910
+ * tick N+1 would dispatch AGAIN duplicate user turns. The `awaitingReadopt`
2911
+ * latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
2912
+ * short-circuit while the row is latched; clear it on a successful dispatch (the
2913
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
2914
+ * re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
2915
+ * may retry exactly once more).
2303
2916
  *
2304
- * `evidentMessageId = row.id` addresses the SERVER row; the stable
2305
- * `opencodeMessageId` is what the watcher polls. Deadline anchored to
2917
+ * `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
2306
2918
  * `processed_at` (Invariant 1).
2307
2919
  */
2308
2920
  async forceReadoptRun(sessionId, row) {
2309
- const ocId = opencodeMessageIdFor2(row.id);
2921
+ if (this.stopped) {
2922
+ this.log({
2923
+ level: "info",
2924
+ 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`,
2925
+ conversation_id: row.conversation_id,
2926
+ message_id: row.id
2927
+ });
2928
+ return;
2929
+ }
2930
+ if (this.awaitingReadopt.has(row.id)) {
2931
+ this.log({
2932
+ level: "info",
2933
+ message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
2934
+ conversation_id: row.conversation_id,
2935
+ message_id: row.id
2936
+ });
2937
+ return;
2938
+ }
2310
2939
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2311
2940
  this.dontRedispatch.add(row.id);
2312
2941
  this.log({
@@ -2323,13 +2952,19 @@ var ChannelDriver = class {
2323
2952
  };
2324
2953
  this.log({
2325
2954
  level: "info",
2326
- message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching with the stable id`,
2955
+ message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
2327
2956
  conversation_id: row.conversation_id,
2328
2957
  message_id: row.id
2329
2958
  });
2959
+ this.awaitingReadopt.add(row.id);
2960
+ let ocId;
2330
2961
  try {
2331
- await sendPromptAsync(this.port, sessionId, row.content, options, ocId);
2962
+ ocId = await this.dispatchLocked(
2963
+ sessionId,
2964
+ () => sendPromptAsync(this.port, sessionId, row.content, options)
2965
+ );
2332
2966
  } catch (err) {
2967
+ this.awaitingReadopt.delete(row.id);
2333
2968
  if (err instanceof ChannelAuthError) throw err;
2334
2969
  this.log({
2335
2970
  level: "error",
@@ -2339,11 +2974,22 @@ var ChannelDriver = class {
2339
2974
  });
2340
2975
  return;
2341
2976
  }
2977
+ if (ocId === null) {
2978
+ this.awaitingReadopt.delete(row.id);
2979
+ this.log({
2980
+ level: "error",
2981
+ 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`,
2982
+ conversation_id: row.conversation_id,
2983
+ message_id: row.id
2984
+ });
2985
+ return;
2986
+ }
2342
2987
  const conv = this.convForRow(sessionId, row);
2343
2988
  const message = this.queuedMessageForRow(row);
2344
2989
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
2345
2990
  this.dispatched.add(row.id);
2346
2991
  this.readopted.add(row.id);
2992
+ this.awaitingReadopt.delete(row.id);
2347
2993
  this.ensureWatcherRunning(sessionId);
2348
2994
  }
2349
2995
  /**
@@ -2436,21 +3082,41 @@ var ChannelDriver = class {
2436
3082
  * RUNNING (not done) is the one that paused. With one running message that is
2437
3083
  * unambiguous; with several we prefer an explicit messageID match, else the
2438
3084
  * oldest running message.
3085
+ *
3086
+ * Returns the set of in-flight Evident message ids that are paused awaiting a
3087
+ * human — an outstanding (still-open) question/permission is attributed to them.
3088
+ * `serviceInFlightMessage` uses this to keep an actively-running turn watched
3089
+ * forever (ADR-0047) while still bounding a turn merely blocked on a person who
3090
+ * may never answer. Attribution here covers ALL open interactions, not just
3091
+ * NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
3092
+ * even after it was already surfaced to the channel.
2439
3093
  */
2440
3094
  async pollInteractions(sessionId, watcher, messages) {
3095
+ const openQuestions = /* @__PURE__ */ new Set();
3096
+ const openPermissions = /* @__PURE__ */ new Set();
3097
+ let questionsPolledOk = true;
3098
+ let permissionsPolledOk = true;
2441
3099
  let questions = [];
2442
3100
  try {
2443
3101
  const res = await this.fetchImpl(`${this.opencodeBase}/question`);
2444
3102
  if (res.ok) {
2445
3103
  const body = await res.json();
2446
- questions = Array.isArray(body) ? body : [];
3104
+ if (Array.isArray(body)) {
3105
+ questions = body;
3106
+ } else {
3107
+ questionsPolledOk = false;
3108
+ }
3109
+ } else {
3110
+ questionsPolledOk = false;
2447
3111
  }
2448
3112
  } catch {
3113
+ questionsPolledOk = false;
2449
3114
  }
2450
3115
  for (const q of questions) {
2451
- if (q.sessionID !== sessionId) continue;
2452
- if (watcher.reportedQuestions.has(q.id)) continue;
3116
+ if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
2453
3117
  const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
3118
+ if (paused) openQuestions.add(paused.evidentMessageId);
3119
+ if (watcher.reportedQuestions.has(q.id)) continue;
2454
3120
  const reported = await this.reportInteraction(
2455
3121
  watcher.conv.id,
2456
3122
  "question",
@@ -2464,14 +3130,22 @@ var ChannelDriver = class {
2464
3130
  const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2465
3131
  if (res.ok) {
2466
3132
  const body = await res.json();
2467
- permissions = Array.isArray(body) ? body : [];
3133
+ if (Array.isArray(body)) {
3134
+ permissions = body;
3135
+ } else {
3136
+ permissionsPolledOk = false;
3137
+ }
3138
+ } else {
3139
+ permissionsPolledOk = false;
2468
3140
  }
2469
3141
  } catch {
3142
+ permissionsPolledOk = false;
2470
3143
  }
2471
3144
  for (const p of permissions) {
2472
- if (p.sessionID !== sessionId) continue;
2473
- if (watcher.reportedPermissions.has(p.id)) continue;
3145
+ if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
2474
3146
  const paused = this.attributeInteraction(watcher, p.messageID, messages);
3147
+ if (paused) openPermissions.add(paused.evidentMessageId);
3148
+ if (watcher.reportedPermissions.has(p.id)) continue;
2475
3149
  const reported = await this.reportInteraction(
2476
3150
  watcher.conv.id,
2477
3151
  "permission",
@@ -2480,6 +3154,109 @@ var ChannelDriver = class {
2480
3154
  );
2481
3155
  if (reported) watcher.reportedPermissions.add(p.id);
2482
3156
  }
3157
+ return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
3158
+ }
3159
+ /**
3160
+ * True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
3161
+ * i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
3162
+ * watched root. Sub-agents spawned via the `task` tool run in child sessions,
3163
+ * so their questions/permissions live under a different `sessionID` that must
3164
+ * still be attributed to the root conversation the watcher owns.
3165
+ *
3166
+ * Parents are cached in `sessionParents` so we walk each session at most once;
3167
+ * a bounded depth cap guards against a cycle or a pathological chain, and any
3168
+ * fetch failure is treated as "not a descendant" (best-effort — the interaction
3169
+ * simply isn't surfaced this tick and is retried next tick once resolvable).
3170
+ */
3171
+ async sessionBelongsTo(sessionId, rootSessionId) {
3172
+ let current = sessionId;
3173
+ for (let depth = 0; current && depth < 32; depth++) {
3174
+ if (current === rootSessionId) return true;
3175
+ const parent = await this.resolveSessionParent(current);
3176
+ if (parent === null || parent === void 0) return false;
3177
+ current = parent;
3178
+ }
3179
+ return false;
3180
+ }
3181
+ /**
3182
+ * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
3183
+ * `null` for a root session (no parent) and `undefined` when opencode is
3184
+ * unreachable / the session can't be read (so the caller stops walking without
3185
+ * caching a wrong answer — the next tick retries).
3186
+ */
3187
+ async resolveSessionParent(sessionId) {
3188
+ const cached = this.sessionParents.get(sessionId);
3189
+ if (cached !== void 0) return cached;
3190
+ let parent = void 0;
3191
+ try {
3192
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3193
+ if (res.ok) {
3194
+ const body = await res.json();
3195
+ parent = body && typeof body.parentID === "string" ? body.parentID : null;
3196
+ }
3197
+ } catch {
3198
+ parent = void 0;
3199
+ }
3200
+ if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3201
+ return parent;
3202
+ }
3203
+ /**
3204
+ * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3205
+ * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
3206
+ *
3207
+ * The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
3208
+ * runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
3209
+ * completed `finish: "tool-calls"` root reply encountered during re-adoption is
3210
+ * idle by OpenCode's own definition and is re-dispatched. This method exists only
3211
+ * so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
3212
+ * provably in flight at the exact moment of recovery.
3213
+ *
3214
+ * "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
3215
+ * ACTIVELY generating — its LAST message is an assistant still mid-generation
3216
+ * (`completed == null`, via `isSessionActivelyGenerating`). An
3217
+ * INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
3218
+ * completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
3219
+ * is generating once the runner is gone), so it does NOT veto. (This is
3220
+ * deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
3221
+ * shapes and would falsely veto — re-hanging the very turn this path recovers.)
3222
+ *
3223
+ * Return contract (encoded so WI-3 need not re-derive it):
3224
+ * - `true` → a descendant is provably, actively generating (veto re-dispatch).
3225
+ * - `false` → descendants exist but none is actively generating (the restart
3226
+ * case), OR no descendant is found at all.
3227
+ * - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
3228
+ *
3229
+ * ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
3230
+ * as `false` and does NOT veto — a restart guarantees no live runner, so an
3231
+ * indeterminate cross-check almost always means "couldn't reach a child that no
3232
+ * longer exists". The inversion lives in the caller; this method just reports
3233
+ * true/false/null faithfully.
3234
+ *
3235
+ * VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
3236
+ * (already proven by the existing child-session interaction tests, via
3237
+ * `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
3238
+ * terminal state. We do NOT depend on any session-level `busy`/`idle` field —
3239
+ * there is none on `GET /session/:id`; OpenCode's busy state is in-memory
3240
+ * `SessionStatus` only.
3241
+ */
3242
+ async isAnyDescendantSessionAlive(rootSessionId) {
3243
+ const sessions = await listSessions(this.port);
3244
+ if (!sessions) {
3245
+ this.log({
3246
+ level: "error",
3247
+ message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3248
+ });
3249
+ return null;
3250
+ }
3251
+ for (const candidate of sessions) {
3252
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
3253
+ if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
3254
+ const childMsgs = await getSessionMessages(this.port, candidate.id);
3255
+ if (isSessionActivelyGenerating(childMsgs)) {
3256
+ return true;
3257
+ }
3258
+ }
3259
+ return false;
2483
3260
  }
2484
3261
  /**
2485
3262
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -2613,13 +3390,17 @@ var ChannelDriver = class {
2613
3390
  * A single attempt (no internal retry): the watcher's per-tick loop is the
2614
3391
  * retry vehicle for the swap-to-running.
2615
3392
  */
2616
- async markProcessing(conversationId, messageId, sessionId) {
3393
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
2617
3394
  const res = await this.fetchImpl(
2618
3395
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2619
3396
  {
2620
3397
  method: "PATCH",
2621
3398
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2622
- body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
3399
+ body: JSON.stringify({
3400
+ status: "processing",
3401
+ opencode_session_id: sessionId,
3402
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3403
+ })
2623
3404
  }
2624
3405
  );
2625
3406
  this.assertAuth(res, "marking message as processing");
@@ -2657,13 +3438,17 @@ var ChannelDriver = class {
2657
3438
  * watcher retries next tick within the
2658
3439
  * deadline, Finding 4).
2659
3440
  */
2660
- async markDone(conversationId, messageId, sessionId) {
3441
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
2661
3442
  const res = await this.fetchImpl(
2662
3443
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2663
3444
  {
2664
3445
  method: "PATCH",
2665
3446
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2666
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
3447
+ body: JSON.stringify({
3448
+ status: "done",
3449
+ opencode_session_id: sessionId,
3450
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3451
+ })
2667
3452
  }
2668
3453
  );
2669
3454
  this.assertAuth(res, "marking message as done");
@@ -2673,7 +3458,17 @@ var ChannelDriver = class {
2673
3458
  }
2674
3459
  throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
2675
3460
  }
2676
- async markFailed(conversationId, messageId) {
3461
+ /**
3462
+ * Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
3463
+ * when provided (issue #182): a bare `markFailed(conv, msg)` sends
3464
+ * `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
3465
+ * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3466
+ * failure reason reaches the channel.
3467
+ */
3468
+ async markFailed(conversationId, messageId, sessionId, error2) {
3469
+ const body = { status: "failed" };
3470
+ if (sessionId !== void 0) body.opencode_session_id = sessionId;
3471
+ if (error2 !== void 0) body.error = error2;
2677
3472
  await this.callWithRetry(
2678
3473
  "marking message as failed",
2679
3474
  () => this.fetchImpl(
@@ -2681,7 +3476,7 @@ var ChannelDriver = class {
2681
3476
  {
2682
3477
  method: "PATCH",
2683
3478
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2684
- body: JSON.stringify({ status: "failed" })
3479
+ body: JSON.stringify(body)
2685
3480
  }
2686
3481
  )
2687
3482
  );
@@ -2694,6 +3489,12 @@ var ChannelDriver = class {
2694
3489
  * MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
2695
3490
  * watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
2696
3491
  * context (no silent catch, per development-workflow).
3492
+ *
3493
+ * Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
3494
+ * telemetry), but the `paused` liveness-clear uses it to know whether to
3495
+ * RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
3496
+ * stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
3497
+ * leaves liveness").
2697
3498
  */
2698
3499
  async postSignal(conversationId, messageId, signal, extra) {
2699
3500
  try {
@@ -2712,7 +3513,9 @@ var ChannelDriver = class {
2712
3513
  conversation_id: conversationId,
2713
3514
  message_id: messageId
2714
3515
  });
3516
+ return false;
2715
3517
  }
3518
+ return true;
2716
3519
  } catch (err) {
2717
3520
  this.log({
2718
3521
  level: "error",
@@ -2720,6 +3523,7 @@ var ChannelDriver = class {
2720
3523
  conversation_id: conversationId,
2721
3524
  message_id: messageId
2722
3525
  });
3526
+ return false;
2723
3527
  }
2724
3528
  }
2725
3529
  async persistSession(conversationId, sessionId) {
@@ -2992,6 +3796,25 @@ async function resolveAgentIdFromKey(authHeader) {
2992
3796
  return { error: `Failed to resolve agent from key: ${message}` };
2993
3797
  }
2994
3798
  }
3799
+ async function notifyAgentDisconnected(agentId, authHeader) {
3800
+ const apiUrl = getApiUrlConfig();
3801
+ try {
3802
+ const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
3803
+ method: "POST",
3804
+ headers: { Authorization: authHeader }
3805
+ });
3806
+ if (!response.ok) {
3807
+ const serverMessage = await readErrorMessage(response);
3808
+ return {
3809
+ ok: false,
3810
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
3811
+ };
3812
+ }
3813
+ return { ok: true };
3814
+ } catch (error2) {
3815
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
3816
+ }
3817
+ }
2995
3818
  async function getAgentInfo(agentId, authHeader) {
2996
3819
  const apiUrl = getApiUrlConfig();
2997
3820
  try {
@@ -3037,6 +3860,8 @@ async function getAgentInfo(agentId, authHeader) {
3037
3860
  // src/commands/run.ts
3038
3861
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
3039
3862
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3863
+ var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3864
+ var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
3040
3865
  function log2(state, message, isError = false) {
3041
3866
  if (state.json) {
3042
3867
  console.log(
@@ -3191,7 +4016,7 @@ async function driveChannels(state, driver) {
3191
4016
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
3192
4017
  if (state.interactive) displayStatus(state);
3193
4018
  }
3194
- await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
4019
+ await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
3195
4020
  if (state.idleTimeout !== null && idlePolls >= 2) {
3196
4021
  const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
3197
4022
  if (idleMs > state.idleTimeout * 1e3) {
@@ -3202,8 +4027,122 @@ async function driveChannels(state, driver) {
3202
4027
  }
3203
4028
  }
3204
4029
  }
3205
- async function cleanup(state) {
4030
+ var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
4031
+ async function runSweep(state, driver, config2) {
4032
+ const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
4033
+ try {
4034
+ const sessions = await listSessions(state.port);
4035
+ if (sessions === null) {
4036
+ logActivity(state, {
4037
+ type: "info",
4038
+ message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
4039
+ });
4040
+ return;
4041
+ }
4042
+ const toDelete = selectSessionsToDelete(
4043
+ sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
4044
+ {
4045
+ maxAgeMs: config2.maxAgeMs,
4046
+ maxCount: config2.maxCount,
4047
+ nowMs: Date.now(),
4048
+ protectedIds: driver.protectedSessionIds()
4049
+ }
4050
+ );
4051
+ const protectedNow = driver.protectedSessionIds();
4052
+ let deleted = 0;
4053
+ let failed = 0;
4054
+ let skippedNewlyActive = 0;
4055
+ for (const id of toDelete) {
4056
+ if (protectedNow.has(id)) {
4057
+ skippedNewlyActive++;
4058
+ logActivity(state, {
4059
+ type: "info",
4060
+ message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
4061
+ });
4062
+ continue;
4063
+ }
4064
+ if (await deleteSession(state.port, id)) deleted++;
4065
+ else failed++;
4066
+ }
4067
+ const failedNote = failed > 0 ? `, failed ${failed}` : "";
4068
+ const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
4069
+ logActivity(state, {
4070
+ type: "info",
4071
+ message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
4072
+ });
4073
+ } catch (error2) {
4074
+ const message = error2 instanceof Error ? error2.message : String(error2);
4075
+ logActivity(state, {
4076
+ type: "error",
4077
+ error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
4078
+ });
4079
+ }
4080
+ }
4081
+ function scheduleSessionCleanup(state, driver, options) {
4082
+ const config2 = resolveSessionCleanupConfig(
4083
+ {
4084
+ maxAge: options.sessionCleanupMaxAge,
4085
+ maxCount: options.sessionCleanupMaxCount,
4086
+ interval: options.sessionCleanupInterval
4087
+ },
4088
+ process.env
4089
+ );
4090
+ for (const warning2 of config2.warnings) {
4091
+ logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4092
+ }
4093
+ if (!config2.enabled) return;
4094
+ logActivity(state, {
4095
+ type: "info",
4096
+ message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
4097
+ });
4098
+ const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
4099
+ const firstSweep = setTimeout(
4100
+ () => void runSweep(state, driver, config2),
4101
+ SESSION_CLEANUP_FIRST_SWEEP_MS
4102
+ );
4103
+ state.sessionCleanupTimers.push(interval, firstSweep);
4104
+ }
4105
+ async function notifyOffline(state) {
4106
+ if (!state.agentId || !state.authHeader) return;
4107
+ if (!state.connected) {
4108
+ log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
4109
+ return;
4110
+ }
4111
+ const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
4112
+ if (result.ok) {
4113
+ log2(state, "Notified Evident the agent is going offline");
4114
+ } else {
4115
+ logActivity(state, {
4116
+ type: "error",
4117
+ error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
4118
+ });
4119
+ if (state.interactive) displayStatus(state);
4120
+ }
4121
+ }
4122
+ async function cleanup(state, opts = {}) {
3206
4123
  state.running = false;
4124
+ for (const timer of state.sessionCleanupTimers) {
4125
+ clearInterval(timer);
4126
+ clearTimeout(timer);
4127
+ }
4128
+ state.sessionCleanupTimers = [];
4129
+ if (opts.graceful && state.channelDriver) {
4130
+ state.channelDriver.stop();
4131
+ log2(state, "Draining in-flight channel work before shutdown...");
4132
+ if (state.interactive) {
4133
+ logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
4134
+ displayStatus(state);
4135
+ }
4136
+ const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
4137
+ if (!settled) {
4138
+ logActivity(state, {
4139
+ type: "info",
4140
+ message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
4141
+ });
4142
+ if (state.interactive) displayStatus(state);
4143
+ }
4144
+ }
4145
+ await notifyOffline(state);
3207
4146
  if (state.connection) {
3208
4147
  state.connection.close();
3209
4148
  state.connection = null;
@@ -3234,10 +4173,13 @@ async function run(options) {
3234
4173
  opencodeVersion: null,
3235
4174
  opencodeProcess: null,
3236
4175
  connection: null,
4176
+ channelDriver: null,
3237
4177
  running: true,
4178
+ shuttingDown: false,
3238
4179
  activityLog: [],
3239
4180
  messageCount: 0,
3240
4181
  lastProxiedActivityAt: null,
4182
+ sessionCleanupTimers: [],
3241
4183
  authHeader: ""
3242
4184
  };
3243
4185
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
@@ -3248,13 +4190,15 @@ async function run(options) {
3248
4190
  );
3249
4191
  }
3250
4192
  const handleSignal = async () => {
4193
+ if (state.shuttingDown) return;
4194
+ state.shuttingDown = true;
3251
4195
  if (state.interactive) {
3252
4196
  logActivity(state, { type: "info", message: "Shutting down..." });
3253
4197
  displayStatus(state);
3254
4198
  } else {
3255
4199
  log2(state, "Shutting down...");
3256
4200
  }
3257
- await cleanup(state);
4201
+ await cleanup(state, { graceful: true });
3258
4202
  await shutdownTelemetry();
3259
4203
  process.exit(0);
3260
4204
  };
@@ -3374,12 +4318,14 @@ async function run(options) {
3374
4318
  apiUrl: getApiUrlConfig(),
3375
4319
  getAuthHeader: () => state.authHeader,
3376
4320
  conversationFilter: state.conversationFilter,
4321
+ stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
3377
4322
  log: (entry) => logActivity(state, {
3378
4323
  type: entry.level === "error" ? "error" : "info",
3379
4324
  message: entry.message,
3380
4325
  error: entry.level === "error" ? entry.message : void 0
3381
4326
  })
3382
4327
  });
4328
+ state.channelDriver = channelDriver;
3383
4329
  const connection = new RunnerConnection({
3384
4330
  agentId: state.agentId,
3385
4331
  getAuthHeader: () => state.authHeader,
@@ -3393,7 +4339,11 @@ async function run(options) {
3393
4339
  type: "info",
3394
4340
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
3395
4341
  });
3396
- emitAgentConnected(state.agentId, { port: state.port });
4342
+ emitAgentConnected(state.agentId, {
4343
+ port: state.port,
4344
+ cli_version: getCliVersion(),
4345
+ opencode_version: state.opencodeVersion
4346
+ });
3397
4347
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
3398
4348
  if (state.interactive) displayStatus(state);
3399
4349
  channelDriver.drainPending().then((processed) => {
@@ -3472,10 +4422,12 @@ async function run(options) {
3472
4422
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
3473
4423
  throw error2;
3474
4424
  }
4425
+ scheduleSessionCleanup(state, channelDriver, options);
3475
4426
  if (!interactive || state.json) {
3476
4427
  log2(state, "Driving channel messages...");
3477
4428
  }
3478
4429
  await driveChannels(state, channelDriver);
4430
+ if (state.shuttingDown) return;
3479
4431
  await cleanup(state);
3480
4432
  if (state.json) {
3481
4433
  console.log(
@@ -3490,6 +4442,7 @@ async function run(options) {
3490
4442
  await shutdownTelemetry();
3491
4443
  process.exit(0);
3492
4444
  } catch (error2) {
4445
+ if (state.shuttingDown) return;
3493
4446
  await cleanup(state);
3494
4447
  const message = error2 instanceof Error ? error2.message : String(error2);
3495
4448
  if (state.json) {
@@ -3524,7 +4477,16 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
3524
4477
  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
4478
  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
4479
  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(
4480
+ 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(
4481
+ "--session-cleanup-max-age <duration>",
4482
+ "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4483
+ ).option(
4484
+ "--session-cleanup-max-count <n>",
4485
+ "Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
4486
+ ).option(
4487
+ "--session-cleanup-interval <duration>",
4488
+ "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
4489
+ ).action(
3528
4490
  (options) => {
3529
4491
  run({
3530
4492
  agent: options.agent,
@@ -3532,7 +4494,11 @@ program.command("run").description("Connect to Evident and process messages").op
3532
4494
  verbose: options.verbose,
3533
4495
  conversation: options.conversation,
3534
4496
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
3535
- json: options.json
4497
+ json: options.json,
4498
+ // Raw strings — the resolver in run.ts single-sources parsing (M1).
4499
+ sessionCleanupMaxAge: options.sessionCleanupMaxAge,
4500
+ sessionCleanupMaxCount: options.sessionCleanupMaxCount,
4501
+ sessionCleanupInterval: options.sessionCleanupInterval
3536
4502
  });
3537
4503
  }
3538
4504
  );