@evident-ai/cli 3.0.1-dev.c152b49 → 3.0.1-dev.c56b413

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;
@@ -1067,6 +1068,94 @@ function isAssistantInFlight(m) {
1067
1068
  if (completedOf(m) == null) return true;
1068
1069
  return finishOf(m) === "tool-calls";
1069
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
+ }
1129
+ async function getSessionStatuses(port) {
1130
+ try {
1131
+ const res = await fetch(`${opencodeBase(port)}/session/status`);
1132
+ if (!res.ok) {
1133
+ console.error(
1134
+ `[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
1135
+ );
1136
+ return null;
1137
+ }
1138
+ const body = await res.json();
1139
+ if (body == null || typeof body !== "object" || Array.isArray(body)) {
1140
+ console.error(
1141
+ `[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
1142
+ );
1143
+ return null;
1144
+ }
1145
+ return body;
1146
+ } catch (err) {
1147
+ console.error(
1148
+ `[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1149
+ );
1150
+ return null;
1151
+ }
1152
+ }
1153
+ async function isSessionOngoing(port, id) {
1154
+ const map = await getSessionStatuses(port);
1155
+ if (map == null) return null;
1156
+ const entry = map[id];
1157
+ return entry != null && entry.type !== "idle";
1158
+ }
1070
1159
  async function createOpenCodeSession(port, directory) {
1071
1160
  const url = new URL(`${opencodeBase(port)}/session`);
1072
1161
  if (directory && directory.trim()) {
@@ -1084,9 +1173,16 @@ async function createOpenCodeSession(port, directory) {
1084
1173
  const data = await response.json();
1085
1174
  return data.id;
1086
1175
  }
1087
- async function sendPromptAsync(port, sessionId, content, options, messageId) {
1176
+ function messageText(m) {
1177
+ if (!m || !Array.isArray(m.parts)) return "";
1178
+ return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1179
+ }
1180
+ async function sendPromptAsync(port, sessionId, content, options) {
1181
+ const before = await getSessionMessages(port, sessionId);
1182
+ const knownUserIds = new Set(
1183
+ (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1184
+ );
1088
1185
  const body = {
1089
- messageID: messageId,
1090
1186
  parts: [{ type: "text", text: content }]
1091
1187
  };
1092
1188
  if (options?.agent) {
@@ -1110,6 +1206,29 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
1110
1206
  const text = await res.text().catch(() => "");
1111
1207
  throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1112
1208
  }
1209
+ const READ_BACK_ATTEMPTS = 5;
1210
+ const READ_BACK_DELAY_MS = 150;
1211
+ for (let attempt = 0; attempt < READ_BACK_ATTEMPTS; attempt++) {
1212
+ const after = await getSessionMessages(port, sessionId);
1213
+ if (after) {
1214
+ let best = null;
1215
+ for (const m of after) {
1216
+ if (roleOf(m) !== "user") continue;
1217
+ const id = idOf(m);
1218
+ if (typeof id !== "string" || knownUserIds.has(id)) continue;
1219
+ if (messageText(m) !== content) continue;
1220
+ const created = createdOf(m) ?? 0;
1221
+ if (best === null || created > best.created) {
1222
+ best = { id, created };
1223
+ }
1224
+ }
1225
+ if (best) return best.id;
1226
+ }
1227
+ if (attempt < READ_BACK_ATTEMPTS - 1) {
1228
+ await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
1229
+ }
1230
+ }
1231
+ return null;
1113
1232
  }
1114
1233
  function findAssistantReplyAfter(messages, userMessageId) {
1115
1234
  if (!messages || messages.length === 0) return null;
@@ -1126,19 +1245,31 @@ function findAssistantReplyAfter(messages, userMessageId) {
1126
1245
  }
1127
1246
  function findLastAssistantReplyFor(messages, userMessageId) {
1128
1247
  if (!messages || messages.length === 0) return null;
1248
+ let lastCorrelated = null;
1249
+ let lastNonErrored = null;
1129
1250
  for (let i = messages.length - 1; i >= 0; i--) {
1130
1251
  const m = messages[i];
1131
- if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1252
+ if (roleOf(m) !== "assistant" || parentIdOf(m) !== userMessageId) continue;
1253
+ if (lastCorrelated === null) lastCorrelated = m;
1254
+ if (errorOf(m) == null) {
1255
+ lastNonErrored = m;
1256
+ break;
1257
+ }
1132
1258
  }
1259
+ if (lastCorrelated) return lastNonErrored ?? lastCorrelated;
1133
1260
  const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1134
1261
  if (userIndex === -1) return null;
1135
1262
  let last = null;
1263
+ let lastOk = null;
1136
1264
  for (let i = userIndex + 1; i < messages.length; i++) {
1137
1265
  const role = roleOf(messages[i]);
1138
1266
  if (role === "user") break;
1139
- if (role === "assistant") last = messages[i];
1267
+ if (role === "assistant") {
1268
+ last = messages[i];
1269
+ if (errorOf(messages[i]) == null) lastOk = messages[i];
1270
+ }
1140
1271
  }
1141
- return last;
1272
+ return lastOk ?? last;
1142
1273
  }
1143
1274
  function messageRunState(messages, userMessageId) {
1144
1275
  if (!messages || messages.length === 0) return "unknown";
@@ -1151,6 +1282,11 @@ function messageRunState(messages, userMessageId) {
1151
1282
  if (isAssistantInFlight(reply)) return "running";
1152
1283
  return errorOf(reply) != null ? "failed" : "done";
1153
1284
  }
1285
+ function isPreamblePinnedRunning(messages, userMessageId) {
1286
+ if (messageRunState(messages, userMessageId) !== "running") return false;
1287
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1288
+ return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1289
+ }
1154
1290
  function messageError(messages, userMessageId) {
1155
1291
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1156
1292
  const error2 = errorOf(reply);
@@ -1170,8 +1306,109 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1170
1306
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1171
1307
  );
1172
1308
  }
1173
- function opencodeMessageIdFor2(queuedMessageId) {
1174
- return opencodeMessageIdFor(queuedMessageId);
1309
+
1310
+ // src/lib/opencode/session-cleanup.ts
1311
+ var DURATION_UNIT_MS = {
1312
+ s: 1e3,
1313
+ m: 60 * 1e3,
1314
+ h: 60 * 60 * 1e3,
1315
+ d: 24 * 60 * 60 * 1e3
1316
+ };
1317
+ function parseDurationMs(input) {
1318
+ const trimmed = input.trim();
1319
+ const match = /^(\d+)([smhd])$/.exec(trimmed);
1320
+ if (!match) {
1321
+ throw new Error(
1322
+ `Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
1323
+ );
1324
+ }
1325
+ const value = Number(match[1]);
1326
+ if (value <= 0) {
1327
+ throw new Error(`Invalid duration "${input}": must be a positive value.`);
1328
+ }
1329
+ return value * DURATION_UNIT_MS[match[2]];
1330
+ }
1331
+ function selectSessionsToDelete(sessions, opts) {
1332
+ const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
1333
+ if (maxAgeMs === void 0 && maxCount === void 0) return [];
1334
+ const ageEligible = (s) => {
1335
+ if (maxAgeMs === void 0) return false;
1336
+ if (s.lastActivityMs === null) return true;
1337
+ return nowMs - s.lastActivityMs > maxAgeMs;
1338
+ };
1339
+ const countEligibleIds = /* @__PURE__ */ new Set();
1340
+ if (maxCount !== void 0) {
1341
+ const byActivityDesc = [...sessions].sort(
1342
+ (a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
1343
+ );
1344
+ for (const s of byActivityDesc.slice(maxCount)) {
1345
+ countEligibleIds.add(s.id);
1346
+ }
1347
+ }
1348
+ const toDelete = [];
1349
+ for (const s of sessions) {
1350
+ if (protectedIds.has(s.id)) continue;
1351
+ if (ageEligible(s) || countEligibleIds.has(s.id)) {
1352
+ toDelete.push(s.id);
1353
+ }
1354
+ }
1355
+ return toDelete;
1356
+ }
1357
+ var DEFAULT_INTERVAL = "1h";
1358
+ function resolve(flag, envValue, fallback) {
1359
+ return flag ?? envValue ?? fallback;
1360
+ }
1361
+ function parseMaxCount(input) {
1362
+ const trimmed = input.trim();
1363
+ if (!/^\d+$/.test(trimmed)) {
1364
+ throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
1365
+ }
1366
+ const value = Number(trimmed);
1367
+ if (value <= 0) {
1368
+ throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
1369
+ }
1370
+ return value;
1371
+ }
1372
+ function resolveSessionCleanupConfig(flags, env = process.env) {
1373
+ const warnings = [];
1374
+ const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
1375
+ const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
1376
+ const intervalRaw = resolve(
1377
+ flags.interval,
1378
+ env.EVIDENT_SESSION_CLEANUP_INTERVAL,
1379
+ DEFAULT_INTERVAL
1380
+ );
1381
+ let maxAgeMs;
1382
+ if (maxAgeRaw !== void 0) {
1383
+ try {
1384
+ maxAgeMs = parseDurationMs(maxAgeRaw);
1385
+ } catch (err) {
1386
+ warnings.push(
1387
+ `Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
1388
+ );
1389
+ }
1390
+ }
1391
+ let maxCount;
1392
+ if (maxCountRaw !== void 0) {
1393
+ try {
1394
+ maxCount = parseMaxCount(maxCountRaw);
1395
+ } catch (err) {
1396
+ warnings.push(
1397
+ `Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
1398
+ );
1399
+ }
1400
+ }
1401
+ let intervalMs;
1402
+ try {
1403
+ intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
1404
+ } catch (err) {
1405
+ warnings.push(
1406
+ `Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
1407
+ );
1408
+ intervalMs = parseDurationMs(DEFAULT_INTERVAL);
1409
+ }
1410
+ const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
1411
+ return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
1175
1412
  }
1176
1413
 
1177
1414
  // src/lib/tunnel/connection.ts
@@ -1250,24 +1487,26 @@ var StreamForwarder = class {
1250
1487
  this.send({ type: "res_end", sid });
1251
1488
  return;
1252
1489
  }
1253
- log("info", "agent_request", {
1254
- correlation_id: correlationId,
1255
- sid,
1256
- method,
1257
- path: stripQuery(path)
1258
- });
1490
+ if (process.env.DEBUG) {
1491
+ log("debug", "agent_request", {
1492
+ correlation_id: correlationId,
1493
+ sid,
1494
+ method,
1495
+ path: stripQuery(path)
1496
+ });
1497
+ }
1259
1498
  const ac = new AbortController();
1260
1499
  let bodyPromise;
1261
1500
  let pushBody;
1262
1501
  let endBody;
1263
1502
  if (has_body) {
1264
1503
  const chunks = [];
1265
- bodyPromise = new Promise((resolve) => {
1504
+ bodyPromise = new Promise((resolve2) => {
1266
1505
  pushBody = (buf) => {
1267
1506
  chunks.push(buf);
1268
1507
  };
1269
1508
  endBody = () => {
1270
- resolve(Buffer.concat(chunks));
1509
+ resolve2(Buffer.concat(chunks));
1271
1510
  };
1272
1511
  });
1273
1512
  }
@@ -1302,12 +1541,14 @@ var StreamForwarder = class {
1302
1541
  if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1303
1542
  });
1304
1543
  this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1305
- log("info", "agent_response", {
1306
- correlation_id: correlationId,
1307
- sid,
1308
- status: upstream.status,
1309
- duration_ms: Date.now() - startedAt
1310
- });
1544
+ if (process.env.DEBUG) {
1545
+ log("debug", "agent_response", {
1546
+ correlation_id: correlationId,
1547
+ sid,
1548
+ status: upstream.status,
1549
+ duration_ms: Date.now() - startedAt
1550
+ });
1551
+ }
1311
1552
  this.callbacks.onHead?.(sid, upstream.status);
1312
1553
  try {
1313
1554
  if (upstream.body) {
@@ -1383,7 +1624,7 @@ function connectTunnel(options) {
1383
1624
  } = options;
1384
1625
  const tunnelUrl = getTunnelUrlConfig();
1385
1626
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1386
- return new Promise((resolve, reject) => {
1627
+ return new Promise((resolve2, reject) => {
1387
1628
  const ws = new WebSocket2(url, {
1388
1629
  headers: {
1389
1630
  Authorization: authHeader
@@ -1448,7 +1689,7 @@ function connectTunnel(options) {
1448
1689
  clearTimeout(connectionTimeout);
1449
1690
  const connectedAgentId = message.agent_id ?? agentId;
1450
1691
  onConnected?.(connectedAgentId);
1451
- resolve({
1692
+ resolve2({
1452
1693
  ws,
1453
1694
  close: () => ws.close(1e3, "CLI shutdown")
1454
1695
  });
@@ -1570,6 +1811,12 @@ function messageIdOf(m) {
1570
1811
  const infoId = m.info?.id;
1571
1812
  return typeof infoId === "string" ? infoId : void 0;
1572
1813
  }
1814
+ var LOG_LEVELS = {
1815
+ debug: 0,
1816
+ info: 1,
1817
+ warn: 2,
1818
+ error: 3
1819
+ };
1573
1820
  var DEFAULT_RETRY_POLICY = {
1574
1821
  maxAttempts: 6,
1575
1822
  baseDelayMs: 500,
@@ -1577,8 +1824,10 @@ var DEFAULT_RETRY_POLICY = {
1577
1824
  };
1578
1825
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1579
1826
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1580
- var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1581
1827
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1828
+ var HEARTBEAT_MS = 6e4;
1829
+ var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
1830
+ var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
1582
1831
  var ChannelAuthError = class extends Error {
1583
1832
  constructor(message) {
1584
1833
  super(message);
@@ -1613,11 +1862,18 @@ var ChannelDriver = class {
1613
1862
  sleep;
1614
1863
  pausedPollIntervalMs;
1615
1864
  pausedMaxWaitMs;
1616
- dispatchConfirmMs;
1617
1865
  stuckQueuedMs;
1618
1866
  now;
1619
1867
  /** Cache of conversationId → opencode sessionId. */
1620
1868
  sessions = /* @__PURE__ */ new Map();
1869
+ /**
1870
+ * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1871
+ * longer idempotent (no caller-supplied `messageID`), and its read-back picks
1872
+ * "the one new user row" — which is only unambiguous if no OTHER dispatch into
1873
+ * the SAME session interleaves its snapshot→POST→read-back. This map chains each
1874
+ * session's dispatches so they run serially; distinct sessions stay concurrent.
1875
+ */
1876
+ sessionDispatchLocks = /* @__PURE__ */ new Map();
1621
1877
  /**
1622
1878
  * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1623
1879
  * session: one polling loop services all of that session's in-flight messages.
@@ -1668,6 +1924,29 @@ var ChannelDriver = class {
1668
1924
  * the row leaves the processing list, exactly like `dontRedispatch`.
1669
1925
  */
1670
1926
  doneUndeliverable = /* @__PURE__ */ new Set();
1927
+ /**
1928
+ * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
1929
+ * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
1930
+ * every ~2s drain until the status map becomes readable — but the server-visible
1931
+ * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
1932
+ * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
1933
+ * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
1934
+ */
1935
+ readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
1936
+ /**
1937
+ * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1938
+ * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
1939
+ * is NOT idempotent: if `forceReadoptRun` dispatches on tick N but the read-back +
1940
+ * persist hasn't landed before tick N+1 re-reads the still-null
1941
+ * `row.opencode_message_id`, tick N+1 would dispatch AGAIN → duplicate user turns.
1942
+ * A row is added here right before its `sendPromptAsync` and `forceReadoptRun`
1943
+ * short-circuits while it is present, so a null-id row is re-dispatched AT MOST
1944
+ * ONCE per outstanding read-back. Cleared on a SUCCESSFUL dispatch+read-back (the
1945
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
1946
+ * re-entry) OR on a failed/unresolved dispatch (the message is genuinely un-sent,
1947
+ * so the NEXT tick may retry exactly once more).
1948
+ */
1949
+ awaitingReadopt = /* @__PURE__ */ new Set();
1671
1950
  /**
1672
1951
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1673
1952
  * first session creation so drain-created sessions are rooted at the project
@@ -1675,8 +1954,43 @@ var ChannelDriver = class {
1675
1954
  * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1676
1955
  */
1677
1956
  opencodeDirectory = void 0;
1957
+ /**
1958
+ * Cache of opencode `sessionId → parentID` (its parent session, or `null` when
1959
+ * the session is a root with no parent). Sub-agents spawned via the `task` tool
1960
+ * run in CHILD sessions whose `parentID` chains up to the Evident-created
1961
+ * (watched) session; we resolve this once per session so a child-session
1962
+ * question/permission can be attributed to the watched session's subtree
1963
+ * (`sessionBelongsTo`) instead of being dropped by an exact-id filter. A missing
1964
+ * entry = not yet resolved; `null` = resolved root (stop walking).
1965
+ */
1966
+ sessionParents = /* @__PURE__ */ new Map();
1967
+ /**
1968
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
1969
+ * NON-EMPTY name is stored (terminal — a real session name won't later un-name),
1970
+ * so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
1971
+ * resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
1972
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
1973
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
1974
+ * no watcher) can resolve the title.
1975
+ */
1976
+ sessionTitles = /* @__PURE__ */ new Map();
1678
1977
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1679
1978
  draining = false;
1979
+ /**
1980
+ * The currently-executing `drainPending()` promise, or null when idle. Lets a
1981
+ * graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
1982
+ * is about to dispatch is not missed by the `hasInFlightWatchers()` check (a
1983
+ * drain that entered before `stop()` still registers its watcher).
1984
+ */
1985
+ activeDrain = null;
1986
+ /**
1987
+ * Set by `stop()` on graceful shutdown. Once stopped, `drainPending` no longer
1988
+ * dispatches NEW work (it returns 0 immediately) — but the per-session watcher
1989
+ * loops already running keep going so in-flight turns can finish and deliver
1990
+ * their reply. `run.ts` awaits `waitForInFlight()` before it closes the tunnel
1991
+ * and stops opencode.
1992
+ */
1993
+ stopped = false;
1680
1994
  constructor(config2) {
1681
1995
  this.agentId = config2.agentId;
1682
1996
  this.port = config2.port;
@@ -1690,7 +2004,6 @@ var ChannelDriver = class {
1690
2004
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1691
2005
  this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1692
2006
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1693
- this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1694
2007
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1695
2008
  this.now = config2.now ?? (() => Date.now());
1696
2009
  }
@@ -1709,8 +2022,21 @@ var ChannelDriver = class {
1709
2022
  * @returns the number of messages NEWLY dispatched to opencode's native queue.
1710
2023
  */
1711
2024
  async drainPending() {
2025
+ if (this.stopped) return 0;
1712
2026
  if (this.draining) return 0;
1713
2027
  this.draining = true;
2028
+ const run2 = this.runDrain();
2029
+ this.activeDrain = run2.then(
2030
+ () => {
2031
+ this.activeDrain = null;
2032
+ },
2033
+ () => {
2034
+ this.activeDrain = null;
2035
+ }
2036
+ );
2037
+ return run2;
2038
+ }
2039
+ async runDrain() {
1714
2040
  let dispatched = 0;
1715
2041
  try {
1716
2042
  const conversations = await this.getPendingConversations();
@@ -1722,6 +2048,7 @@ var ChannelDriver = class {
1722
2048
  });
1723
2049
  }
1724
2050
  for (const conv of conversations) {
2051
+ if (this.stopped) break;
1725
2052
  dispatched += await this.processConversation(conv);
1726
2053
  }
1727
2054
  await this.readoptProcessing();
@@ -1742,6 +2069,73 @@ var ChannelDriver = class {
1742
2069
  }
1743
2070
  return false;
1744
2071
  }
2072
+ /**
2073
+ * OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
2074
+ * exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
2075
+ * `watchers` entry whose `inFlight` set is non-empty — the same predicate
2076
+ * `hasInFlightWatchers()` uses, lifted to return the ids.
2077
+ *
2078
+ * Deliberately does NOT include `this.sessions` (the permanent, never-pruned
2079
+ * conversation→session cache). Protecting every bound-but-idle session there
2080
+ * would shield nearly every session and defeat cleanup — AND it is unnecessary:
2081
+ * `ensureSession` is self-healing (it recreates a session whose id no longer
2082
+ * exists), so deleting an idle bound session is harmless — the conversation's
2083
+ * next turn transparently rebinds a fresh one. The only thing worth protecting
2084
+ * is a session with a turn ACTIVELY in flight right now: tearing that down
2085
+ * mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
2086
+ */
2087
+ protectedSessionIds() {
2088
+ const ids = /* @__PURE__ */ new Set();
2089
+ for (const [sessionId, watcher] of this.watchers) {
2090
+ if (watcher.inFlight.size > 0) ids.add(sessionId);
2091
+ }
2092
+ return ids;
2093
+ }
2094
+ /**
2095
+ * Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
2096
+ * this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
2097
+ * — but the watcher loops already tracking in-flight turns keep running, so a
2098
+ * turn that has finished (or is about to) still fires `markDone` and delivers
2099
+ * its reply. Pair with `waitForInFlight()` to bound how long shutdown waits.
2100
+ */
2101
+ stop() {
2102
+ this.stopped = true;
2103
+ }
2104
+ /**
2105
+ * Wait (up to `timeoutMs`) for in-flight watcher work to settle during a
2106
+ * graceful shutdown, so a turn whose reply is ready — or completes within the
2107
+ * window — is delivered before the process exits, instead of being cut off and
2108
+ * left for the ADR-0046 restart-recovery path.
2109
+ *
2110
+ * Bounded on purpose: the watcher's own give-up deadline is up to 10 minutes,
2111
+ * far longer than a shutdown grace period (e.g. Fargate's SIGTERM→SIGKILL
2112
+ * window). We poll `hasInFlightWatchers()` and return as soon as the in-flight
2113
+ * set empties OR the timeout elapses. Anything still in flight at the timeout is
2114
+ * safe to abandon — it stays `processing` server-side and is re-adopted on the
2115
+ * next runner start (ADR-0046).
2116
+ *
2117
+ * @returns true if all in-flight work settled within the window; false if the
2118
+ * timeout elapsed with work still in flight.
2119
+ */
2120
+ async waitForInFlight(timeoutMs) {
2121
+ const deadline = this.now() + timeoutMs;
2122
+ const step = Math.min(this.pausedPollIntervalMs, 250);
2123
+ if (this.activeDrain) {
2124
+ let drainSettled = false;
2125
+ void this.activeDrain.then(() => {
2126
+ drainSettled = true;
2127
+ });
2128
+ while (!drainSettled) {
2129
+ if (this.now() >= deadline) return false;
2130
+ await this.sleep(step);
2131
+ }
2132
+ }
2133
+ while (this.hasInFlightWatchers()) {
2134
+ if (this.now() >= deadline) return false;
2135
+ await this.sleep(step);
2136
+ }
2137
+ return true;
2138
+ }
1745
2139
  /**
1746
2140
  * Await all outstanding per-session watchers (WI-3).
1747
2141
  *
@@ -1778,15 +2172,16 @@ var ChannelDriver = class {
1778
2172
  let dispatched = 0;
1779
2173
  let skippedAlreadyDispatched = 0;
1780
2174
  for (const message of messages) {
2175
+ if (this.stopped) break;
1781
2176
  if (this.dispatched.has(message.id)) {
1782
2177
  skippedAlreadyDispatched += 1;
1783
2178
  continue;
1784
2179
  }
1785
- const opencodeMessageId = opencodeMessageIdFor2(message.id);
1786
2180
  const options = {
1787
2181
  agent: message.opencode_agent ?? void 0,
1788
2182
  model: message.opencode_model ?? void 0
1789
2183
  };
2184
+ let opencodeMessageId;
1790
2185
  try {
1791
2186
  this.log({
1792
2187
  level: "info",
@@ -1794,10 +2189,23 @@ var ChannelDriver = class {
1794
2189
  conversation_id: conv.id,
1795
2190
  message_id: message.id
1796
2191
  });
1797
- await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
2192
+ opencodeMessageId = await this.dispatchLocked(
2193
+ sessionId,
2194
+ () => sendPromptAsync(this.port, sessionId, message.content, options)
2195
+ );
1798
2196
  } catch (err) {
1799
2197
  if (err instanceof ChannelAuthError) throw err;
1800
2198
  this.dispatched.delete(message.id);
2199
+ if (await sessionExists(this.port, sessionId) === false) {
2200
+ this.sessions.delete(conv.id);
2201
+ this.log({
2202
+ level: "warn",
2203
+ 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.`,
2204
+ conversation_id: conv.id,
2205
+ message_id: message.id
2206
+ });
2207
+ break;
2208
+ }
1801
2209
  await this.markFailed(conv.id, message.id).catch(() => {
1802
2210
  });
1803
2211
  this.log({
@@ -1808,6 +2216,15 @@ var ChannelDriver = class {
1808
2216
  });
1809
2217
  continue;
1810
2218
  }
2219
+ if (opencodeMessageId === null) {
2220
+ this.log({
2221
+ level: "warn",
2222
+ 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`,
2223
+ conversation_id: conv.id,
2224
+ message_id: message.id
2225
+ });
2226
+ continue;
2227
+ }
1811
2228
  this.dispatched.add(message.id);
1812
2229
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1813
2230
  dispatched += 1;
@@ -1815,7 +2232,7 @@ var ChannelDriver = class {
1815
2232
  }
1816
2233
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
1817
2234
  this.log({
1818
- level: "error",
2235
+ level: "warn",
1819
2236
  message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
1820
2237
  conversation_id: conv.id
1821
2238
  });
@@ -1824,16 +2241,33 @@ var ChannelDriver = class {
1824
2241
  return dispatched;
1825
2242
  }
1826
2243
  async ensureSession(conv) {
1827
- const cached = this.sessions.get(conv.id);
1828
- if (cached) return cached;
1829
- if (conv.opencode_session_id) {
1830
- this.sessions.set(conv.id, conv.opencode_session_id);
1831
- return conv.opencode_session_id;
2244
+ const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
2245
+ if (bound) {
2246
+ const exists = await sessionExists(this.port, bound);
2247
+ if (exists === false) {
2248
+ this.log({
2249
+ level: "debug",
2250
+ 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.`,
2251
+ conversation_id: conv.id
2252
+ });
2253
+ this.sessions.delete(conv.id);
2254
+ return this.createAndBindSession(conv.id);
2255
+ }
2256
+ this.sessions.set(conv.id, bound);
2257
+ return bound;
1832
2258
  }
2259
+ return this.createAndBindSession(conv.id);
2260
+ }
2261
+ /**
2262
+ * Create a fresh OpenCode session for a conversation, cache the binding, and
2263
+ * best-effort persist it server-side. Shared by the first-ever bind and the
2264
+ * self-heal recreate path in `ensureSession`.
2265
+ */
2266
+ async createAndBindSession(conversationId) {
1833
2267
  const directory = await this.resolveOpenCodeDirectory();
1834
2268
  const sessionId = await createOpenCodeSession(this.port, directory);
1835
- this.sessions.set(conv.id, sessionId);
1836
- await this.persistSession(conv.id, sessionId).catch(() => {
2269
+ this.sessions.set(conversationId, sessionId);
2270
+ await this.persistSession(conversationId, sessionId).catch(() => {
1837
2271
  });
1838
2272
  return sessionId;
1839
2273
  }
@@ -1847,7 +2281,7 @@ var ChannelDriver = class {
1847
2281
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1848
2282
  if (!this.opencodeDirectory) {
1849
2283
  this.log({
1850
- level: "info",
2284
+ level: "warn",
1851
2285
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1852
2286
  });
1853
2287
  }
@@ -1856,6 +2290,25 @@ var ChannelDriver = class {
1856
2290
  // -------------------------------------------------------------------------
1857
2291
  // Per-session watcher (WI-3)
1858
2292
  // -------------------------------------------------------------------------
2293
+ /**
2294
+ * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2295
+ * opencode session (Task 2.1a), so two dispatches into the SAME session can
2296
+ * never interleave and mis-correlate their read-backs. Distinct sessions run
2297
+ * concurrently. The chained tail intentionally ignores the prior result/error
2298
+ * (each dispatch reports its own outcome to its caller).
2299
+ */
2300
+ dispatchLocked(sessionId, fn) {
2301
+ const prior = this.sessionDispatchLocks.get(sessionId) ?? Promise.resolve();
2302
+ const run2 = prior.then(fn, fn);
2303
+ this.sessionDispatchLocks.set(
2304
+ sessionId,
2305
+ run2.then(
2306
+ () => void 0,
2307
+ () => void 0
2308
+ )
2309
+ );
2310
+ return run2;
2311
+ }
1859
2312
  /** Register a freshly-dispatched message with its session's watcher state. */
1860
2313
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
1861
2314
  let watcher = this.watchers.get(sessionId);
@@ -1865,7 +2318,9 @@ var ChannelDriver = class {
1865
2318
  inFlight: /* @__PURE__ */ new Map(),
1866
2319
  loop: null,
1867
2320
  reportedQuestions: /* @__PURE__ */ new Set(),
1868
- reportedPermissions: /* @__PURE__ */ new Set()
2321
+ reportedPermissions: /* @__PURE__ */ new Set(),
2322
+ lastGoodPollAt: this.now(),
2323
+ hadUsablePoll: false
1869
2324
  };
1870
2325
  this.watchers.set(sessionId, watcher);
1871
2326
  }
@@ -1875,20 +2330,37 @@ var ChannelDriver = class {
1875
2330
  opencodeMessageId,
1876
2331
  message,
1877
2332
  dispatchedAt: now,
2333
+ processingAnchorMs: now,
1878
2334
  deadline: now + this.pausedMaxWaitMs,
1879
2335
  started: false,
1880
2336
  done: false,
1881
- stuckReported: false
2337
+ stuckReported: false,
2338
+ lastAliveAt: 0,
2339
+ aliveInFlight: false,
2340
+ awaitingHumanLatched: false,
2341
+ pausedOnQuestion: false,
2342
+ pausedOnPermission: false,
2343
+ pausedClearConfirmed: false,
2344
+ pausedInFlight: false,
2345
+ deliveryDeadlineAnchored: false
1882
2346
  });
1883
2347
  }
1884
2348
  /**
1885
2349
  * Register a RE-ADOPTED `processing` message with its session watcher
1886
2350
  * (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
1887
2351
  * `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
1888
- * `now`: a row already `processing` for e.g. 5 min must give up ~5 min from now
1889
- * (10 min after `processed_at`), not 10 min from now — otherwise its deadline
1890
- * lands ~15 min after `processed_at`, coinciding with the cron reset →
1891
- * double-drive race. `dispatchedAt` stays `now` (only the appear-guard uses it).
2352
+ * `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
2353
+ * fresh dispatch would (10 min after `processed_at`, not 10 min from now).
2354
+ *
2355
+ * This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
2356
+ * give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
2357
+ * opencode reports ACTIVELY `running` is watched to completion (its liveness
2358
+ * heartbeat keeps the cron off its row), while a re-adopted turn that is paused
2359
+ * awaiting a human — or queued/unreachable — is still bounded by `deadline` and
2360
+ * handed to the cron. The old "the `deadline` must settle before the ~15-min
2361
+ * cron or they double-drive" reasoning is superseded: liveness now settles the
2362
+ * actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
2363
+ * (only the appear-guard uses it).
1892
2364
  *
1893
2365
  * `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
1894
2366
  * `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
@@ -1906,7 +2378,9 @@ var ChannelDriver = class {
1906
2378
  inFlight: /* @__PURE__ */ new Map(),
1907
2379
  loop: null,
1908
2380
  reportedQuestions: /* @__PURE__ */ new Set(),
1909
- reportedPermissions: /* @__PURE__ */ new Set()
2381
+ reportedPermissions: /* @__PURE__ */ new Set(),
2382
+ lastGoodPollAt: this.now(),
2383
+ hadUsablePoll: false
1910
2384
  };
1911
2385
  this.watchers.set(sessionId, watcher);
1912
2386
  }
@@ -1915,16 +2389,33 @@ var ChannelDriver = class {
1915
2389
  opencodeMessageId,
1916
2390
  message,
1917
2391
  dispatchedAt: this.now(),
2392
+ // Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
2393
+ // value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
2394
+ // reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
2395
+ processingAnchorMs: processedAtMs,
1918
2396
  deadline: processedAtMs + this.pausedMaxWaitMs,
1919
2397
  // The server row is ALREADY `processing`; do not re-fire markProcessing.
1920
2398
  started: true,
1921
2399
  done: false,
1922
2400
  // Not yet reported stuck-queued. The once-guard (`stuckReported`) applies,
1923
- // AND the stuck-queued observer now INCLUDES re-adopted queued wedges: it
1924
- // gates on `state === 'queued'` (turn produced no reply), not on `started`,
1925
- // so a re-adopted row left wedged in `queued` still emits the signal once
1926
- // (queued-followup-redrive, #210).
1927
- stuckReported: false
2401
+ // AND the stuck-queued observer INCLUDES re-adopted queued wedges: it gates
2402
+ // on `state === 'queued'` (turn produced no reply), not on `started`, so a
2403
+ // re-adopted row left wedged in `queued` still emits the signal once
2404
+ // (#210/#220 observability).
2405
+ stuckReported: false,
2406
+ // Task 5.2: a re-adopted actively-running row re-attaches into the SAME
2407
+ // watcher and so hits the SAME actively-running heartbeat branch in
2408
+ // `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
2409
+ // re-adopted and is confirming this row alive" via that `alive` heartbeat,
2410
+ // with no extra `re_adopted` signal needed (folds old WI-6).
2411
+ lastAliveAt: 0,
2412
+ aliveInFlight: false,
2413
+ awaitingHumanLatched: false,
2414
+ pausedOnQuestion: false,
2415
+ pausedOnPermission: false,
2416
+ pausedClearConfirmed: false,
2417
+ pausedInFlight: false,
2418
+ deliveryDeadlineAnchored: false
1928
2419
  });
1929
2420
  }
1930
2421
  /**
@@ -1975,12 +2466,30 @@ var ChannelDriver = class {
1975
2466
  messages = Array.isArray(body) ? body : null;
1976
2467
  }
1977
2468
  } catch {
1978
- continue;
1979
2469
  }
2470
+ if (messages != null && messages.length > 0) {
2471
+ watcher.lastGoodPollAt = this.now();
2472
+ watcher.hadUsablePoll = true;
2473
+ } else {
2474
+ const emptyButReachable = messages != null;
2475
+ const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
2476
+ if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
2477
+ continue;
2478
+ }
2479
+ }
2480
+ const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
1980
2481
  for (const inFlight of [...watcher.inFlight.values()]) {
1981
- await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
2482
+ await this.serviceInFlightMessage(
2483
+ sessionId,
2484
+ watcher,
2485
+ inFlight,
2486
+ messages,
2487
+ openQuestions,
2488
+ openPermissions,
2489
+ questionsPolledOk,
2490
+ permissionsPolledOk
2491
+ );
1982
2492
  }
1983
- await this.pollInteractions(sessionId, watcher, messages);
1984
2493
  }
1985
2494
  } catch (err) {
1986
2495
  if (err instanceof ChannelAuthError) {
@@ -2002,23 +2511,55 @@ var ChannelDriver = class {
2002
2511
  });
2003
2512
  }
2004
2513
  }
2514
+ /**
2515
+ * On FIRST observing a terminal (done/failed) state, ensure the delivery
2516
+ * (markDone/markFailed) transient-retry path has a real window. A long
2517
+ * ACTIVELY-running turn is kept past its original `deadline`, so by completion
2518
+ * `now >= deadline` already holds and the retry bound below would fire on the
2519
+ * first transient PATCH failure — dropping the message before its reply lands
2520
+ * (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
2521
+ * to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
2522
+ * or past now, so a still-ample window is left untouched.
2523
+ */
2524
+ anchorDeliveryDeadline(inFlight) {
2525
+ if (inFlight.deliveryDeadlineAnchored) return;
2526
+ inFlight.deliveryDeadlineAnchored = true;
2527
+ if (this.now() >= inFlight.deadline) {
2528
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2529
+ }
2530
+ }
2005
2531
  /**
2006
2532
  * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
2007
2533
  * Fires markProcessing on queued→running and markDone on done (each once),
2008
2534
  * applies the idle-path re-dispatch guard, and removes the message from the
2009
2535
  * in-flight set on completion or timeout.
2010
2536
  */
2011
- async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
2537
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
2012
2538
  const conv = watcher.conv;
2013
2539
  const state = messageRunState(messages, inFlight.opencodeMessageId);
2540
+ const id = inFlight.evidentMessageId;
2541
+ if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
2542
+ else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
2543
+ if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
2544
+ else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
2545
+ const observedOpen = openQuestions.has(id) || openPermissions.has(id);
2546
+ const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2547
+ const awaitingHuman = observedOpen || latchedPaused;
2014
2548
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2549
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2015
2550
  let claimed;
2016
2551
  try {
2017
- claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
2552
+ claimed = await this.markProcessing(
2553
+ conv.id,
2554
+ inFlight.evidentMessageId,
2555
+ sessionId,
2556
+ inFlight.opencodeMessageId,
2557
+ title
2558
+ );
2018
2559
  } catch (err) {
2019
2560
  if (err instanceof ChannelAuthError) throw err;
2020
2561
  this.log({
2021
- level: "error",
2562
+ level: "warn",
2022
2563
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2023
2564
  conversation_id: conv.id,
2024
2565
  message_id: inFlight.evidentMessageId
@@ -2028,7 +2569,7 @@ var ChannelDriver = class {
2028
2569
  inFlight.started = true;
2029
2570
  if (!claimed) {
2030
2571
  this.log({
2031
- level: "info",
2572
+ level: "debug",
2032
2573
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2033
2574
  conversation_id: conv.id,
2034
2575
  message_id: inFlight.evidentMessageId
@@ -2036,6 +2577,7 @@ var ChannelDriver = class {
2036
2577
  }
2037
2578
  }
2038
2579
  if (state === "done") {
2580
+ this.anchorDeliveryDeadline(inFlight);
2039
2581
  if (!inFlight.done) {
2040
2582
  this.log({
2041
2583
  level: "info",
@@ -2043,13 +2585,20 @@ var ChannelDriver = class {
2043
2585
  conversation_id: conv.id,
2044
2586
  message_id: inFlight.evidentMessageId
2045
2587
  });
2588
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2046
2589
  try {
2047
- await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
2590
+ await this.markDone(
2591
+ conv.id,
2592
+ inFlight.evidentMessageId,
2593
+ sessionId,
2594
+ inFlight.opencodeMessageId,
2595
+ title
2596
+ );
2048
2597
  } catch (err) {
2049
2598
  if (err instanceof ChannelAuthError) throw err;
2050
2599
  if (err instanceof ChannelTerminalError) {
2051
2600
  this.log({
2052
- level: "error",
2601
+ level: "warn",
2053
2602
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2054
2603
  conversation_id: conv.id,
2055
2604
  message_id: inFlight.evidentMessageId
@@ -2059,7 +2608,7 @@ var ChannelDriver = class {
2059
2608
  }
2060
2609
  if (this.now() >= inFlight.deadline) {
2061
2610
  this.log({
2062
- level: "error",
2611
+ level: "warn",
2063
2612
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
2064
2613
  conversation_id: conv.id,
2065
2614
  message_id: inFlight.evidentMessageId
@@ -2068,7 +2617,7 @@ var ChannelDriver = class {
2068
2617
  return;
2069
2618
  }
2070
2619
  this.log({
2071
- level: "error",
2620
+ level: "warn",
2072
2621
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2073
2622
  conversation_id: conv.id,
2074
2623
  message_id: inFlight.evidentMessageId
@@ -2081,6 +2630,7 @@ var ChannelDriver = class {
2081
2630
  return;
2082
2631
  }
2083
2632
  if (state === "failed") {
2633
+ this.anchorDeliveryDeadline(inFlight);
2084
2634
  if (!inFlight.done) {
2085
2635
  const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
2086
2636
  this.log({
@@ -2095,7 +2645,7 @@ var ChannelDriver = class {
2095
2645
  if (err instanceof ChannelAuthError) throw err;
2096
2646
  if (err instanceof ChannelTerminalError) {
2097
2647
  this.log({
2098
- level: "error",
2648
+ level: "warn",
2099
2649
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2100
2650
  conversation_id: conv.id,
2101
2651
  message_id: inFlight.evidentMessageId
@@ -2105,7 +2655,7 @@ var ChannelDriver = class {
2105
2655
  }
2106
2656
  if (this.now() >= inFlight.deadline) {
2107
2657
  this.log({
2108
- level: "error",
2658
+ level: "warn",
2109
2659
  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)}`,
2110
2660
  conversation_id: conv.id,
2111
2661
  message_id: inFlight.evidentMessageId
@@ -2114,7 +2664,7 @@ var ChannelDriver = class {
2114
2664
  return;
2115
2665
  }
2116
2666
  this.log({
2117
- level: "error",
2667
+ level: "warn",
2118
2668
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2119
2669
  conversation_id: conv.id,
2120
2670
  message_id: inFlight.evidentMessageId
@@ -2126,59 +2676,70 @@ var ChannelDriver = class {
2126
2676
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2127
2677
  return;
2128
2678
  }
2129
- if (state === "unknown") {
2130
- if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
2131
- await this.redispatchInFlight(sessionId, inFlight);
2132
- }
2133
- }
2134
- if (state === "queued" && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
2679
+ const pastStuckBound = this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs;
2680
+ const sessionIdle = state === "queued" && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId);
2681
+ if (state === "queued" && pastStuckBound && sessionIdle && !inFlight.stuckReported) {
2135
2682
  inFlight.stuckReported = true;
2136
2683
  void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
2137
2684
  stuck_for_ms: this.now() - inFlight.dispatchedAt
2138
2685
  });
2139
2686
  }
2140
- if (this.now() >= inFlight.deadline) {
2687
+ const activelyRunning = state === "running" && !awaitingHuman;
2688
+ if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2141
2689
  this.log({
2142
- level: "info",
2143
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2690
+ level: "warn",
2691
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
2144
2692
  conversation_id: conv.id,
2145
2693
  message_id: inFlight.evidentMessageId
2146
2694
  });
2695
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2696
+ watched_for_ms: this.now() - inFlight.processingAnchorMs
2697
+ });
2147
2698
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2699
+ return;
2148
2700
  }
2149
- }
2150
- /**
2151
- * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
2152
- * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
2153
- * fact 9) one user message + one reply even if the original DID land. Resets
2154
- * the dispatch timestamp so the guard doesn't immediately fire again.
2155
- */
2156
- async redispatchInFlight(sessionId, inFlight) {
2157
- const options = {
2158
- agent: inFlight.message.opencode_agent ?? void 0,
2159
- model: inFlight.message.opencode_model ?? void 0
2160
- };
2161
- this.log({
2162
- level: "info",
2163
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
2164
- message_id: inFlight.evidentMessageId
2165
- });
2166
- try {
2167
- await sendPromptAsync(
2168
- this.port,
2169
- sessionId,
2170
- inFlight.message.content,
2171
- options,
2172
- inFlight.opencodeMessageId
2173
- );
2174
- } catch (err) {
2701
+ if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
2702
+ inFlight.aliveInFlight = true;
2703
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
2704
+ inFlight.aliveInFlight = false;
2705
+ if (ok) inFlight.lastAliveAt = this.now();
2706
+ });
2707
+ }
2708
+ if (awaitingHuman) {
2709
+ if (!inFlight.awaitingHumanLatched) {
2710
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2711
+ inFlight.awaitingHumanLatched = true;
2712
+ }
2713
+ if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
2714
+ inFlight.pausedInFlight = true;
2715
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
2716
+ inFlight.pausedInFlight = false;
2717
+ if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
2718
+ });
2719
+ }
2720
+ } else if (inFlight.awaitingHumanLatched) {
2721
+ inFlight.awaitingHumanLatched = false;
2722
+ inFlight.pausedOnQuestion = false;
2723
+ inFlight.pausedOnPermission = false;
2724
+ inFlight.pausedClearConfirmed = false;
2725
+ }
2726
+ const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
2727
+ const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
2728
+ (sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
2729
+ );
2730
+ const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2731
+ if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2175
2732
  this.log({
2176
- level: "error",
2177
- message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
2733
+ level: "debug",
2734
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2735
+ conversation_id: conv.id,
2178
2736
  message_id: inFlight.evidentMessageId
2179
2737
  });
2738
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2739
+ watched_for_ms: this.now() - inFlight.dispatchedAt
2740
+ });
2741
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2180
2742
  }
2181
- inFlight.dispatchedAt = this.now();
2182
2743
  }
2183
2744
  // -------------------------------------------------------------------------
2184
2745
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
@@ -2199,15 +2760,20 @@ var ChannelDriver = class {
2199
2760
  */
2200
2761
  async readoptProcessing() {
2201
2762
  const rows = await this.getProcessingMessages();
2202
- if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
2763
+ if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
2203
2764
  const stillProcessing = new Set(rows.map((r) => r.id));
2204
- for (const id of [...this.dontRedispatch, ...this.doneUndeliverable]) {
2765
+ for (const id of [
2766
+ ...this.dontRedispatch,
2767
+ ...this.doneUndeliverable,
2768
+ ...this.readoptPollUnresolvedSignalled
2769
+ ]) {
2205
2770
  if (!stillProcessing.has(id)) {
2206
2771
  const cleared = this.dontRedispatch.delete(id);
2207
2772
  const clearedUndeliverable = this.doneUndeliverable.delete(id);
2773
+ this.readoptPollUnresolvedSignalled.delete(id);
2208
2774
  if (cleared || clearedUndeliverable) {
2209
2775
  this.log({
2210
- level: "info",
2776
+ level: "debug",
2211
2777
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2212
2778
  message_id: id
2213
2779
  });
@@ -2220,7 +2786,7 @@ var ChannelDriver = class {
2220
2786
  for (const row of rows) {
2221
2787
  if (!row.opencode_session_id) {
2222
2788
  this.log({
2223
- level: "error",
2789
+ level: "warn",
2224
2790
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2225
2791
  conversation_id: row.conversation_id,
2226
2792
  message_id: row.id
@@ -2237,7 +2803,7 @@ var ChannelDriver = class {
2237
2803
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2238
2804
  if (!res.ok) {
2239
2805
  this.log({
2240
- level: "error",
2806
+ level: "warn",
2241
2807
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2242
2808
  });
2243
2809
  continue;
@@ -2245,7 +2811,7 @@ var ChannelDriver = class {
2245
2811
  const body = await res.json();
2246
2812
  if (!Array.isArray(body)) {
2247
2813
  this.log({
2248
- level: "error",
2814
+ level: "warn",
2249
2815
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2250
2816
  });
2251
2817
  continue;
@@ -2253,13 +2819,15 @@ var ChannelDriver = class {
2253
2819
  messages = body;
2254
2820
  } catch (err) {
2255
2821
  this.log({
2256
- level: "error",
2822
+ level: "warn",
2257
2823
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2258
2824
  });
2259
2825
  continue;
2260
2826
  }
2827
+ const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
2828
+ const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
2261
2829
  for (const row of sessionRows) {
2262
- await this.readoptOne(sessionId, row, messages);
2830
+ await this.readoptOne(sessionId, row, messages, sessionOngoing);
2263
2831
  }
2264
2832
  }
2265
2833
  }
@@ -2267,31 +2835,36 @@ var ChannelDriver = class {
2267
2835
  * Re-adopt ONE `processing` row against the tick's session message snapshot
2268
2836
  * (ADR-0046 Decision §1/§2). Idempotent: skips a row already being driven.
2269
2837
  *
2270
- * Branches on `messageRunState(messages, opencodeMessageIdFor(row.id))`:
2838
+ * Branches on `messageRunState(messages, row.opencode_message_id)` — the
2839
+ * opencode-assigned user-message id persisted on the first `processing` PATCH
2840
+ * (#218). A row with a NULL stored id (dispatched but the read-back never landed
2841
+ * before the restart) has no id to correlate → treated as an orphan and
2842
+ * re-dispatched (at most once, see `forceReadoptRun`):
2271
2843
  * - `done` → `markDone` now (guarded like the watcher's done branch);
2272
2844
  * - `failed` → `markFailed` with the surfaced error (issue #182), so an
2273
2845
  * errored turn is reported failed on restart, NOT re-dispatched;
2274
- * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch);
2275
- * - `unknown` → re-dispatch the STABLE id + attach a watcher (orphan).
2846
+ * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
2847
+ * tracking the stored id so the reply correlates by it;
2848
+ * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
2276
2849
  *
2277
2850
  * Only `ChannelAuthError` propagates.
2278
2851
  */
2279
- async readoptOne(sessionId, row, messages) {
2852
+ async readoptOne(sessionId, row, messages, sessionOngoing) {
2280
2853
  if (this.isTracked(sessionId, row.id)) {
2281
2854
  this.log({
2282
- level: "info",
2855
+ level: "debug",
2283
2856
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2284
2857
  conversation_id: row.conversation_id,
2285
2858
  message_id: row.id
2286
2859
  });
2287
2860
  return;
2288
2861
  }
2289
- const ocId = opencodeMessageIdFor2(row.id);
2290
- const state = messageRunState(messages, ocId);
2862
+ const ocId = row.opencode_message_id;
2863
+ const state = messageRunState(messages, ocId ?? "");
2291
2864
  if (state === "done") {
2292
2865
  if (this.doneUndeliverable.has(row.id)) {
2293
2866
  this.log({
2294
- level: "info",
2867
+ level: "debug",
2295
2868
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2296
2869
  conversation_id: row.conversation_id,
2297
2870
  message_id: row.id
@@ -2305,21 +2878,23 @@ var ChannelDriver = class {
2305
2878
  message_id: row.id
2306
2879
  });
2307
2880
  try {
2308
- await this.markDone(row.conversation_id, row.id, sessionId);
2881
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
2882
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
2309
2883
  } catch (err) {
2310
2884
  if (err instanceof ChannelAuthError) throw err;
2311
2885
  if (err instanceof ChannelTerminalError) {
2312
2886
  this.doneUndeliverable.add(row.id);
2313
2887
  this.log({
2314
- level: "error",
2888
+ level: "warn",
2315
2889
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
2316
2890
  conversation_id: row.conversation_id,
2317
2891
  message_id: row.id
2318
2892
  });
2893
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2319
2894
  return;
2320
2895
  }
2321
2896
  this.log({
2322
- level: "error",
2897
+ level: "warn",
2323
2898
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2324
2899
  conversation_id: row.conversation_id,
2325
2900
  message_id: row.id
@@ -2327,10 +2902,11 @@ var ChannelDriver = class {
2327
2902
  return;
2328
2903
  }
2329
2904
  this.dontRedispatch.delete(row.id);
2905
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
2330
2906
  return;
2331
2907
  }
2332
2908
  if (state === "failed") {
2333
- const error2 = messageError(messages, ocId) ?? void 0;
2909
+ const error2 = messageError(messages, ocId ?? "") ?? void 0;
2334
2910
  this.log({
2335
2911
  level: "error",
2336
2912
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -2344,15 +2920,16 @@ var ChannelDriver = class {
2344
2920
  if (err instanceof ChannelTerminalError) {
2345
2921
  this.doneUndeliverable.add(row.id);
2346
2922
  this.log({
2347
- level: "error",
2923
+ level: "warn",
2348
2924
  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}`,
2349
2925
  conversation_id: row.conversation_id,
2350
2926
  message_id: row.id
2351
2927
  });
2928
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2352
2929
  return;
2353
2930
  }
2354
2931
  this.log({
2355
- level: "error",
2932
+ level: "warn",
2356
2933
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2357
2934
  conversation_id: row.conversation_id,
2358
2935
  message_id: row.id
@@ -2360,18 +2937,84 @@ var ChannelDriver = class {
2360
2937
  return;
2361
2938
  }
2362
2939
  this.dontRedispatch.delete(row.id);
2940
+ void this.postSignal(row.conversation_id, row.id, "readopt_failed");
2363
2941
  return;
2364
2942
  }
2365
2943
  if (this.dontRedispatch.has(row.id)) {
2366
2944
  this.log({
2367
- level: "info",
2945
+ level: "debug",
2368
2946
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2369
2947
  conversation_id: row.conversation_id,
2370
2948
  message_id: row.id
2371
2949
  });
2372
2950
  return;
2373
2951
  }
2374
- if (state === "running" || state === "queued") {
2952
+ let statusReadableOngoing = null;
2953
+ if (state === "running" && ocId) {
2954
+ const reply = findLastAssistantReplyFor(messages, ocId);
2955
+ const shape = this.replyCompletionShape(reply);
2956
+ const ongoing = sessionOngoing;
2957
+ statusReadableOngoing = ongoing;
2958
+ if (ongoing === false) {
2959
+ this.log({
2960
+ level: "info",
2961
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status (absent/idle) \u2014 re-dispatching from scratch (status-gated recovery)`,
2962
+ conversation_id: row.conversation_id,
2963
+ message_id: row.id
2964
+ });
2965
+ await this.forceReadoptRun(sessionId, row);
2966
+ return;
2967
+ }
2968
+ if (ongoing === true) {
2969
+ this.log({
2970
+ level: "debug",
2971
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
2972
+ conversation_id: row.conversation_id,
2973
+ message_id: row.id
2974
+ });
2975
+ } else {
2976
+ if (shape === "b1") {
2977
+ this.log({
2978
+ level: "debug",
2979
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
2980
+ conversation_id: row.conversation_id,
2981
+ message_id: row.id
2982
+ });
2983
+ if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
2984
+ this.readoptPollUnresolvedSignalled.add(row.id);
2985
+ void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
2986
+ }
2987
+ return;
2988
+ }
2989
+ this.log({
2990
+ level: "debug",
2991
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
2992
+ conversation_id: row.conversation_id,
2993
+ message_id: row.id
2994
+ });
2995
+ }
2996
+ }
2997
+ if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
2998
+ const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2999
+ if (descendantAlive === true) {
3000
+ this.log({
3001
+ level: "debug",
3002
+ 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)`,
3003
+ conversation_id: row.conversation_id,
3004
+ message_id: row.id
3005
+ });
3006
+ } else {
3007
+ this.log({
3008
+ level: "info",
3009
+ 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)" : ""}`,
3010
+ conversation_id: row.conversation_id,
3011
+ message_id: row.id
3012
+ });
3013
+ await this.forceReadoptRun(sessionId, row);
3014
+ return;
3015
+ }
3016
+ }
3017
+ if ((state === "running" || state === "queued") && ocId) {
2375
3018
  const conv = this.convForRow(sessionId, row);
2376
3019
  const message = this.queuedMessageForRow(row);
2377
3020
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
@@ -2379,41 +3022,65 @@ var ChannelDriver = class {
2379
3022
  this.readopted.add(row.id);
2380
3023
  this.ensureWatcherRunning(sessionId);
2381
3024
  this.log({
2382
- level: "info",
2383
- message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stable id, no re-dispatch)`,
3025
+ level: "debug",
3026
+ message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2384
3027
  conversation_id: row.conversation_id,
2385
3028
  message_id: row.id
2386
3029
  });
3030
+ void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
2387
3031
  return;
2388
3032
  }
2389
3033
  await this.forceReadoptRun(sessionId, row);
2390
3034
  }
2391
3035
  /**
2392
- * Re-dispatch an orphaned (`unknown`) `processing` row (ADR-0046 Decision §2).
3036
+ * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
3037
+ *
3038
+ * #218/WI-5: the row's user message is absent (never kept, or a null stored id),
3039
+ * so we re-`prompt_async` WITHOUT a caller id (opencode assigns a monotonic one),
3040
+ * read it back, and register the watcher under the assigned id so the reply
3041
+ * correlates server-side.
2393
3042
  *
2394
- * The stable-id user message is absent from the session, so we (re-)dispatch with
2395
- * the STABLE id (`opencodeMessageIdFor(row.id)`) NOT a divergent per-attempt id.
2396
- * This is what keeps the reply correlatable: the server's completion
2397
- * notification looks for the reply under the stable id, so the fresh turn's reply
2398
- * (which hangs off the stable id) is found and delivered. The residual
2399
- * duplicate-incomplete-turn semantics (ADR §2, `.harness/restart-recovery-orphan-finding.md`)
2400
- * are unchanged and, for an ABSENT id, cannot bite there is no existing turn
2401
- * to swallow the duplicate.
3043
+ * ⚠️ AT-MOST-ONCE (High-2): dispatch is no longer idempotent (no caller-supplied
3044
+ * id). Without a guard, if this dispatches on tick N but the read-back+persist
3045
+ * hasn't landed before tick N+1 re-reads the still-null `opencode_message_id`,
3046
+ * tick N+1 would dispatch AGAIN duplicate user turns. The `awaitingReadopt`
3047
+ * latch makes a null-id row re-dispatched AT MOST ONCE per outstanding read-back:
3048
+ * short-circuit while the row is latched; clear it on a successful dispatch (the
3049
+ * row is then tracked in `dispatched`, so `readoptOne`'s early skip prevents
3050
+ * re-entry) OR on a failed/unresolved dispatch (genuinely un-sent → the next tick
3051
+ * may retry exactly once more).
2402
3052
  *
2403
- * `evidentMessageId = row.id` addresses the SERVER row; the stable
2404
- * `opencodeMessageId` is what the watcher polls. Deadline anchored to
3053
+ * `evidentMessageId = row.id` addresses the SERVER row. Deadline anchored to
2405
3054
  * `processed_at` (Invariant 1).
2406
3055
  */
2407
3056
  async forceReadoptRun(sessionId, row) {
2408
- const ocId = opencodeMessageIdFor2(row.id);
3057
+ if (this.stopped) {
3058
+ this.log({
3059
+ level: "debug",
3060
+ 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`,
3061
+ conversation_id: row.conversation_id,
3062
+ message_id: row.id
3063
+ });
3064
+ return;
3065
+ }
3066
+ if (this.awaitingReadopt.has(row.id)) {
3067
+ this.log({
3068
+ level: "debug",
3069
+ message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3070
+ conversation_id: row.conversation_id,
3071
+ message_id: row.id
3072
+ });
3073
+ return;
3074
+ }
2409
3075
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2410
3076
  this.dontRedispatch.add(row.id);
2411
3077
  this.log({
2412
- level: "info",
3078
+ level: "debug",
2413
3079
  message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but its re-adopt window has already elapsed \u2014 not dispatching an unwatchable turn; parking until it leaves processing (cron will reset it)`,
2414
3080
  conversation_id: row.conversation_id,
2415
3081
  message_id: row.id
2416
3082
  });
3083
+ void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
2417
3084
  return;
2418
3085
  }
2419
3086
  const options = {
@@ -2422,20 +3089,38 @@ var ChannelDriver = class {
2422
3089
  };
2423
3090
  this.log({
2424
3091
  level: "info",
2425
- message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching with the stable id`,
3092
+ message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned (user message absent) \u2014 re-dispatching (opencode assigns a fresh id)`,
2426
3093
  conversation_id: row.conversation_id,
2427
3094
  message_id: row.id
2428
3095
  });
3096
+ this.awaitingReadopt.add(row.id);
3097
+ let ocId;
2429
3098
  try {
2430
- await sendPromptAsync(this.port, sessionId, row.content, options, ocId);
3099
+ ocId = await this.dispatchLocked(
3100
+ sessionId,
3101
+ () => sendPromptAsync(this.port, sessionId, row.content, options)
3102
+ );
2431
3103
  } catch (err) {
3104
+ this.awaitingReadopt.delete(row.id);
2432
3105
  if (err instanceof ChannelAuthError) throw err;
2433
3106
  this.log({
2434
- level: "error",
3107
+ level: "warn",
2435
3108
  message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2436
3109
  conversation_id: row.conversation_id,
2437
3110
  message_id: row.id
2438
3111
  });
3112
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3113
+ return;
3114
+ }
3115
+ if (ocId === null) {
3116
+ this.awaitingReadopt.delete(row.id);
3117
+ this.log({
3118
+ level: "warn",
3119
+ 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`,
3120
+ conversation_id: row.conversation_id,
3121
+ message_id: row.id
3122
+ });
3123
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2439
3124
  return;
2440
3125
  }
2441
3126
  const conv = this.convForRow(sessionId, row);
@@ -2443,7 +3128,9 @@ var ChannelDriver = class {
2443
3128
  this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
2444
3129
  this.dispatched.add(row.id);
2445
3130
  this.readopted.add(row.id);
3131
+ this.awaitingReadopt.delete(row.id);
2446
3132
  this.ensureWatcherRunning(sessionId);
3133
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
2447
3134
  }
2448
3135
  /**
2449
3136
  * True if `evidentMessageId` is already being driven — either in the
@@ -2513,7 +3200,7 @@ var ChannelDriver = class {
2513
3200
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
2514
3201
  this.dontRedispatch.add(evidentMessageId);
2515
3202
  this.log({
2516
- level: "info",
3203
+ level: "debug",
2517
3204
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
2518
3205
  conversation_id: watcher.conv.id,
2519
3206
  message_id: evidentMessageId
@@ -2535,21 +3222,41 @@ var ChannelDriver = class {
2535
3222
  * RUNNING (not done) is the one that paused. With one running message that is
2536
3223
  * unambiguous; with several we prefer an explicit messageID match, else the
2537
3224
  * oldest running message.
3225
+ *
3226
+ * Returns the set of in-flight Evident message ids that are paused awaiting a
3227
+ * human — an outstanding (still-open) question/permission is attributed to them.
3228
+ * `serviceInFlightMessage` uses this to keep an actively-running turn watched
3229
+ * forever (ADR-0047) while still bounding a turn merely blocked on a person who
3230
+ * may never answer. Attribution here covers ALL open interactions, not just
3231
+ * NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
3232
+ * even after it was already surfaced to the channel.
2538
3233
  */
2539
3234
  async pollInteractions(sessionId, watcher, messages) {
3235
+ const openQuestions = /* @__PURE__ */ new Set();
3236
+ const openPermissions = /* @__PURE__ */ new Set();
3237
+ let questionsPolledOk = true;
3238
+ let permissionsPolledOk = true;
2540
3239
  let questions = [];
2541
3240
  try {
2542
3241
  const res = await this.fetchImpl(`${this.opencodeBase}/question`);
2543
3242
  if (res.ok) {
2544
3243
  const body = await res.json();
2545
- questions = Array.isArray(body) ? body : [];
3244
+ if (Array.isArray(body)) {
3245
+ questions = body;
3246
+ } else {
3247
+ questionsPolledOk = false;
3248
+ }
3249
+ } else {
3250
+ questionsPolledOk = false;
2546
3251
  }
2547
3252
  } catch {
3253
+ questionsPolledOk = false;
2548
3254
  }
2549
3255
  for (const q of questions) {
2550
- if (q.sessionID !== sessionId) continue;
2551
- if (watcher.reportedQuestions.has(q.id)) continue;
3256
+ if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
2552
3257
  const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
3258
+ if (paused) openQuestions.add(paused.evidentMessageId);
3259
+ if (watcher.reportedQuestions.has(q.id)) continue;
2553
3260
  const reported = await this.reportInteraction(
2554
3261
  watcher.conv.id,
2555
3262
  "question",
@@ -2563,14 +3270,22 @@ var ChannelDriver = class {
2563
3270
  const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2564
3271
  if (res.ok) {
2565
3272
  const body = await res.json();
2566
- permissions = Array.isArray(body) ? body : [];
3273
+ if (Array.isArray(body)) {
3274
+ permissions = body;
3275
+ } else {
3276
+ permissionsPolledOk = false;
3277
+ }
3278
+ } else {
3279
+ permissionsPolledOk = false;
2567
3280
  }
2568
3281
  } catch {
3282
+ permissionsPolledOk = false;
2569
3283
  }
2570
3284
  for (const p of permissions) {
2571
- if (p.sessionID !== sessionId) continue;
2572
- if (watcher.reportedPermissions.has(p.id)) continue;
3285
+ if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
2573
3286
  const paused = this.attributeInteraction(watcher, p.messageID, messages);
3287
+ if (paused) openPermissions.add(paused.evidentMessageId);
3288
+ if (watcher.reportedPermissions.has(p.id)) continue;
2574
3289
  const reported = await this.reportInteraction(
2575
3290
  watcher.conv.id,
2576
3291
  "permission",
@@ -2579,6 +3294,173 @@ var ChannelDriver = class {
2579
3294
  );
2580
3295
  if (reported) watcher.reportedPermissions.add(p.id);
2581
3296
  }
3297
+ return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
3298
+ }
3299
+ /**
3300
+ * True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
3301
+ * i.e. its `parentID` chain (resolved via `GET /session/:id`) reaches the
3302
+ * watched root. Sub-agents spawned via the `task` tool run in child sessions,
3303
+ * so their questions/permissions live under a different `sessionID` that must
3304
+ * still be attributed to the root conversation the watcher owns.
3305
+ *
3306
+ * Parents are cached in `sessionParents` so we walk each session at most once;
3307
+ * a bounded depth cap guards against a cycle or a pathological chain, and any
3308
+ * fetch failure is treated as "not a descendant" (best-effort — the interaction
3309
+ * simply isn't surfaced this tick and is retried next tick once resolvable).
3310
+ */
3311
+ async sessionBelongsTo(sessionId, rootSessionId) {
3312
+ let current = sessionId;
3313
+ for (let depth = 0; current && depth < 32; depth++) {
3314
+ if (current === rootSessionId) return true;
3315
+ const parent = await this.resolveSessionParent(current);
3316
+ if (parent === null || parent === void 0) return false;
3317
+ current = parent;
3318
+ }
3319
+ return false;
3320
+ }
3321
+ /**
3322
+ * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
3323
+ * `null` for a root session (no parent) and `undefined` when opencode is
3324
+ * unreachable / the session can't be read (so the caller stops walking without
3325
+ * caching a wrong answer — the next tick retries).
3326
+ */
3327
+ async resolveSessionParent(sessionId) {
3328
+ const cached = this.sessionParents.get(sessionId);
3329
+ if (cached !== void 0) return cached;
3330
+ let parent = void 0;
3331
+ try {
3332
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3333
+ if (res.ok) {
3334
+ const body = await res.json();
3335
+ parent = body && typeof body.parentID === "string" ? body.parentID : null;
3336
+ }
3337
+ } catch {
3338
+ parent = void 0;
3339
+ }
3340
+ if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3341
+ return parent;
3342
+ }
3343
+ /**
3344
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3345
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3346
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3347
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3348
+ * Best-effort:
3349
+ * - a resolved NON-EMPTY title is cached and terminal (a real session name
3350
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3351
+ * - while the title is still absent/empty we do NOT latch it — OpenCode names
3352
+ * sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
3353
+ * must leave the cache unresolved and re-fetch on the next need so a later
3354
+ * call (e.g. at `done`) picks up the name assigned in the meantime. Such a
3355
+ * call returns `null` (omit the title on THIS PATCH) without caching;
3356
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3357
+ * and returns `null` — it must NEVER throw or block completion.
3358
+ * A failure is logged with agent/session context (no silent catch).
3359
+ */
3360
+ async resolveSessionTitle(sessionId, conversationId) {
3361
+ const cached = this.sessionTitles.get(sessionId);
3362
+ if (cached != null) return cached;
3363
+ try {
3364
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3365
+ if (res.ok) {
3366
+ const body = await res.json();
3367
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3368
+ if (title.length > 0) {
3369
+ this.sessionTitles.set(sessionId, title);
3370
+ return title;
3371
+ }
3372
+ return null;
3373
+ }
3374
+ this.log({
3375
+ level: "debug",
3376
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3377
+ conversation_id: conversationId
3378
+ });
3379
+ } catch (err) {
3380
+ this.log({
3381
+ level: "debug",
3382
+ message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
3383
+ conversation_id: conversationId
3384
+ });
3385
+ }
3386
+ return null;
3387
+ }
3388
+ /**
3389
+ * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3390
+ * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
3391
+ *
3392
+ * The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
3393
+ * runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
3394
+ * completed `finish: "tool-calls"` root reply encountered during re-adoption is
3395
+ * idle by OpenCode's own definition and is re-dispatched. This method exists only
3396
+ * so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
3397
+ * provably in flight at the exact moment of recovery.
3398
+ *
3399
+ * "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
3400
+ * ACTIVELY generating — its LAST message is an assistant still mid-generation
3401
+ * (`completed == null`, via `isSessionActivelyGenerating`). An
3402
+ * INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
3403
+ * completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
3404
+ * is generating once the runner is gone), so it does NOT veto. (This is
3405
+ * deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
3406
+ * shapes and would falsely veto — re-hanging the very turn this path recovers.)
3407
+ *
3408
+ * Return contract (encoded so WI-3 need not re-derive it):
3409
+ * - `true` → a descendant is provably, actively generating (veto re-dispatch).
3410
+ * - `false` → descendants exist but none is actively generating (the restart
3411
+ * case), OR no descendant is found at all.
3412
+ * - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
3413
+ *
3414
+ * ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
3415
+ * as `false` and does NOT veto — a restart guarantees no live runner, so an
3416
+ * indeterminate cross-check almost always means "couldn't reach a child that no
3417
+ * longer exists". The inversion lives in the caller; this method just reports
3418
+ * true/false/null faithfully.
3419
+ *
3420
+ * VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
3421
+ * (already proven by the existing child-session interaction tests, via
3422
+ * `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
3423
+ * terminal state. We do NOT depend on any session-level `busy`/`idle` field —
3424
+ * there is none on `GET /session/:id`; OpenCode's busy state is in-memory
3425
+ * `SessionStatus` only.
3426
+ */
3427
+ async isAnyDescendantSessionAlive(rootSessionId) {
3428
+ const sessions = await listSessions(this.port);
3429
+ if (!sessions) {
3430
+ this.log({
3431
+ level: "warn",
3432
+ message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3433
+ });
3434
+ return null;
3435
+ }
3436
+ for (const candidate of sessions) {
3437
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
3438
+ if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
3439
+ const childMsgs = await getSessionMessages(this.port, candidate.id);
3440
+ if (isSessionActivelyGenerating(childMsgs)) {
3441
+ return true;
3442
+ }
3443
+ }
3444
+ return false;
3445
+ }
3446
+ /**
3447
+ * Cheap decision-telemetry label for a running row's LAST correlated reply
3448
+ * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
3449
+ * - `b1` — the reply itself is still in flight (`time.completed == null`) —
3450
+ * the aborted-in-flight production bug after a restart.
3451
+ * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
3452
+ * (the sub-agent preamble — #253's shape).
3453
+ * - `other` — any other shape (defensive; a running row is normally b1 or b2).
3454
+ * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
3455
+ * shape) directly rather than re-importing the module-private `completedOf`/
3456
+ * `finishOf` — this is a display label only, not a correctness predicate.
3457
+ */
3458
+ replyCompletionShape(reply) {
3459
+ if (!reply) return "other";
3460
+ const completed = reply.info?.time?.completed ?? reply.time?.completed;
3461
+ if (completed == null) return "b1";
3462
+ const finish = reply.info?.finish ?? reply.finish;
3463
+ return finish === "tool-calls" ? "b2" : "other";
2582
3464
  }
2583
3465
  /**
2584
3466
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -2712,13 +3594,18 @@ var ChannelDriver = class {
2712
3594
  * A single attempt (no internal retry): the watcher's per-tick loop is the
2713
3595
  * retry vehicle for the swap-to-running.
2714
3596
  */
2715
- async markProcessing(conversationId, messageId, sessionId) {
3597
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
2716
3598
  const res = await this.fetchImpl(
2717
3599
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2718
3600
  {
2719
3601
  method: "PATCH",
2720
3602
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2721
- body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
3603
+ body: JSON.stringify({
3604
+ status: "processing",
3605
+ opencode_session_id: sessionId,
3606
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3607
+ ...title ? { title } : {}
3608
+ })
2722
3609
  }
2723
3610
  );
2724
3611
  this.assertAuth(res, "marking message as processing");
@@ -2756,13 +3643,18 @@ var ChannelDriver = class {
2756
3643
  * watcher retries next tick within the
2757
3644
  * deadline, Finding 4).
2758
3645
  */
2759
- async markDone(conversationId, messageId, sessionId) {
3646
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
2760
3647
  const res = await this.fetchImpl(
2761
3648
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2762
3649
  {
2763
3650
  method: "PATCH",
2764
3651
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2765
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
3652
+ body: JSON.stringify({
3653
+ status: "done",
3654
+ opencode_session_id: sessionId,
3655
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3656
+ ...title ? { title } : {}
3657
+ })
2766
3658
  }
2767
3659
  );
2768
3660
  this.assertAuth(res, "marking message as done");
@@ -2803,6 +3695,12 @@ var ChannelDriver = class {
2803
3695
  * MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
2804
3696
  * watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
2805
3697
  * context (no silent catch, per development-workflow).
3698
+ *
3699
+ * Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
3700
+ * telemetry), but the `paused` liveness-clear uses it to know whether to
3701
+ * RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
3702
+ * stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
3703
+ * leaves liveness").
2806
3704
  */
2807
3705
  async postSignal(conversationId, messageId, signal, extra) {
2808
3706
  try {
@@ -2816,19 +3714,22 @@ var ChannelDriver = class {
2816
3714
  );
2817
3715
  if (!res.ok) {
2818
3716
  this.log({
2819
- level: "error",
3717
+ level: "warn",
2820
3718
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
2821
3719
  conversation_id: conversationId,
2822
3720
  message_id: messageId
2823
3721
  });
3722
+ return false;
2824
3723
  }
3724
+ return true;
2825
3725
  } catch (err) {
2826
3726
  this.log({
2827
- level: "error",
3727
+ level: "warn",
2828
3728
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
2829
3729
  conversation_id: conversationId,
2830
3730
  message_id: messageId
2831
3731
  });
3732
+ return false;
2832
3733
  }
2833
3734
  }
2834
3735
  async persistSession(conversationId, sessionId) {
@@ -3101,6 +4002,25 @@ async function resolveAgentIdFromKey(authHeader) {
3101
4002
  return { error: `Failed to resolve agent from key: ${message}` };
3102
4003
  }
3103
4004
  }
4005
+ async function notifyAgentDisconnected(agentId, authHeader) {
4006
+ const apiUrl = getApiUrlConfig();
4007
+ try {
4008
+ const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
4009
+ method: "POST",
4010
+ headers: { Authorization: authHeader }
4011
+ });
4012
+ if (!response.ok) {
4013
+ const serverMessage = await readErrorMessage(response);
4014
+ return {
4015
+ ok: false,
4016
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
4017
+ };
4018
+ }
4019
+ return { ok: true };
4020
+ } catch (error2) {
4021
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
4022
+ }
4023
+ }
3104
4024
  async function getAgentInfo(agentId, authHeader) {
3105
4025
  const apiUrl = getApiUrlConfig();
3106
4026
  try {
@@ -3146,23 +4066,55 @@ async function getAgentInfo(agentId, authHeader) {
3146
4066
  // src/commands/run.ts
3147
4067
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
3148
4068
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3149
- function log2(state, message, isError = false) {
4069
+ var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
4070
+ var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
4071
+ function resolveLogLevel(options) {
4072
+ const accepted = Object.keys(LOG_LEVELS);
4073
+ const validate = (value, source) => {
4074
+ const normalized = value.trim().toLowerCase();
4075
+ if (!accepted.includes(normalized)) {
4076
+ throw new Error(
4077
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4078
+ );
4079
+ }
4080
+ return normalized;
4081
+ };
4082
+ if (options.logLevel !== void 0) {
4083
+ return validate(options.logLevel, " (--log-level)");
4084
+ }
4085
+ if (options.verbose) {
4086
+ return "debug";
4087
+ }
4088
+ const env = process.env.EVIDENT_LOG_LEVEL;
4089
+ if (env !== void 0 && env !== "") {
4090
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4091
+ }
4092
+ return "info";
4093
+ }
4094
+ function meetsThreshold(state, level) {
4095
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4096
+ }
4097
+ function log2(state, message, level = "info") {
4098
+ if (!meetsThreshold(state, level)) return;
3150
4099
  if (state.json) {
3151
4100
  console.log(
3152
4101
  JSON.stringify({
3153
4102
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3154
- level: isError ? "error" : "info",
4103
+ level,
3155
4104
  message
3156
4105
  })
3157
4106
  );
3158
4107
  } else if (!state.interactive) {
3159
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4108
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
3160
4109
  console.log(`${prefix} ${message}`);
3161
4110
  }
3162
4111
  }
3163
4112
  function logActivity(state, entry) {
4113
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4114
+ if (!meetsThreshold(state, level)) return;
3164
4115
  const fullEntry = {
3165
4116
  ...entry,
4117
+ level,
3166
4118
  timestamp: /* @__PURE__ */ new Date()
3167
4119
  };
3168
4120
  state.activityLog.push(fullEntry);
@@ -3171,9 +4123,9 @@ function logActivity(state, entry) {
3171
4123
  }
3172
4124
  if (!state.interactive) {
3173
4125
  if (entry.type === "error") {
3174
- log2(state, entry.error ?? "Unknown error", true);
3175
- } else if (entry.type === "info" && entry.message) {
3176
- log2(state, entry.message);
4126
+ log2(state, entry.error ?? "Unknown error", level);
4127
+ } else if (entry.message) {
4128
+ log2(state, entry.message, level);
3177
4129
  }
3178
4130
  }
3179
4131
  }
@@ -3300,7 +4252,7 @@ async function driveChannels(state, driver) {
3300
4252
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
3301
4253
  if (state.interactive) displayStatus(state);
3302
4254
  }
3303
- await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
4255
+ await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
3304
4256
  if (state.idleTimeout !== null && idlePolls >= 2) {
3305
4257
  const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
3306
4258
  if (idleMs > state.idleTimeout * 1e3) {
@@ -3311,8 +4263,122 @@ async function driveChannels(state, driver) {
3311
4263
  }
3312
4264
  }
3313
4265
  }
3314
- async function cleanup(state) {
4266
+ var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
4267
+ async function runSweep(state, driver, config2) {
4268
+ const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
4269
+ try {
4270
+ const sessions = await listSessions(state.port);
4271
+ if (sessions === null) {
4272
+ logActivity(state, {
4273
+ type: "info",
4274
+ message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
4275
+ });
4276
+ return;
4277
+ }
4278
+ const toDelete = selectSessionsToDelete(
4279
+ sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
4280
+ {
4281
+ maxAgeMs: config2.maxAgeMs,
4282
+ maxCount: config2.maxCount,
4283
+ nowMs: Date.now(),
4284
+ protectedIds: driver.protectedSessionIds()
4285
+ }
4286
+ );
4287
+ const protectedNow = driver.protectedSessionIds();
4288
+ let deleted = 0;
4289
+ let failed = 0;
4290
+ let skippedNewlyActive = 0;
4291
+ for (const id of toDelete) {
4292
+ if (protectedNow.has(id)) {
4293
+ skippedNewlyActive++;
4294
+ logActivity(state, {
4295
+ type: "info",
4296
+ message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
4297
+ });
4298
+ continue;
4299
+ }
4300
+ if (await deleteSession(state.port, id)) deleted++;
4301
+ else failed++;
4302
+ }
4303
+ const failedNote = failed > 0 ? `, failed ${failed}` : "";
4304
+ const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
4305
+ logActivity(state, {
4306
+ type: "info",
4307
+ message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
4308
+ });
4309
+ } catch (error2) {
4310
+ const message = error2 instanceof Error ? error2.message : String(error2);
4311
+ logActivity(state, {
4312
+ type: "error",
4313
+ error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
4314
+ });
4315
+ }
4316
+ }
4317
+ function scheduleSessionCleanup(state, driver, options) {
4318
+ const config2 = resolveSessionCleanupConfig(
4319
+ {
4320
+ maxAge: options.sessionCleanupMaxAge,
4321
+ maxCount: options.sessionCleanupMaxCount,
4322
+ interval: options.sessionCleanupInterval
4323
+ },
4324
+ process.env
4325
+ );
4326
+ for (const warning2 of config2.warnings) {
4327
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4328
+ }
4329
+ if (!config2.enabled) return;
4330
+ logActivity(state, {
4331
+ type: "info",
4332
+ message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
4333
+ });
4334
+ const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
4335
+ const firstSweep = setTimeout(
4336
+ () => void runSweep(state, driver, config2),
4337
+ SESSION_CLEANUP_FIRST_SWEEP_MS
4338
+ );
4339
+ state.sessionCleanupTimers.push(interval, firstSweep);
4340
+ }
4341
+ async function notifyOffline(state) {
4342
+ if (!state.agentId || !state.authHeader) return;
4343
+ if (!state.connected) {
4344
+ log2(state, "Skipping offline signal \u2014 this runner does not hold the live tunnel");
4345
+ return;
4346
+ }
4347
+ const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
4348
+ if (result.ok) {
4349
+ log2(state, "Notified Evident the agent is going offline");
4350
+ } else {
4351
+ logActivity(state, {
4352
+ type: "error",
4353
+ error: `Could not notify Evident of offline status (relay will still report it): ${result.error}`
4354
+ });
4355
+ if (state.interactive) displayStatus(state);
4356
+ }
4357
+ }
4358
+ async function cleanup(state, opts = {}) {
3315
4359
  state.running = false;
4360
+ for (const timer of state.sessionCleanupTimers) {
4361
+ clearInterval(timer);
4362
+ clearTimeout(timer);
4363
+ }
4364
+ state.sessionCleanupTimers = [];
4365
+ if (opts.graceful && state.channelDriver) {
4366
+ state.channelDriver.stop();
4367
+ log2(state, "Draining in-flight channel work before shutdown...");
4368
+ if (state.interactive) {
4369
+ logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
4370
+ displayStatus(state);
4371
+ }
4372
+ const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
4373
+ if (!settled) {
4374
+ logActivity(state, {
4375
+ type: "info",
4376
+ message: "Shutdown drain timed out with work still in flight \u2014 leaving it for restart recovery"
4377
+ });
4378
+ if (state.interactive) displayStatus(state);
4379
+ }
4380
+ }
4381
+ await notifyOffline(state);
3316
4382
  if (state.connection) {
3317
4383
  state.connection.close();
3318
4384
  state.connection = null;
@@ -3330,6 +4396,20 @@ async function cleanup(state) {
3330
4396
  }
3331
4397
  async function run(options) {
3332
4398
  const interactive = isInteractive(options.json);
4399
+ let logLevel;
4400
+ try {
4401
+ logLevel = resolveLogLevel(options);
4402
+ } catch (error2) {
4403
+ const message = error2 instanceof Error ? error2.message : String(error2);
4404
+ if (options.json) {
4405
+ console.log(JSON.stringify({ status: "error", error: message }));
4406
+ } else {
4407
+ printError(message);
4408
+ }
4409
+ await shutdownTelemetry();
4410
+ process.exit(1);
4411
+ return;
4412
+ }
3333
4413
  const state = {
3334
4414
  agentId: options.agent || "",
3335
4415
  agentName: null,
@@ -3338,32 +4418,38 @@ async function run(options) {
3338
4418
  idleTimeout: options.idleTimeout ?? null,
3339
4419
  json: options.json ?? false,
3340
4420
  interactive,
4421
+ logLevel,
3341
4422
  connected: false,
3342
4423
  opencodeConnected: false,
3343
4424
  opencodeVersion: null,
3344
4425
  opencodeProcess: null,
3345
4426
  connection: null,
4427
+ channelDriver: null,
3346
4428
  running: true,
4429
+ shuttingDown: false,
3347
4430
  activityLog: [],
3348
4431
  messageCount: 0,
3349
4432
  lastProxiedActivityAt: null,
4433
+ sessionCleanupTimers: [],
3350
4434
  authHeader: ""
3351
4435
  };
3352
4436
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
3353
4437
  log2(
3354
4438
  state,
3355
- "Warning: No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
3356
- false
4439
+ "No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
4440
+ "warn"
3357
4441
  );
3358
4442
  }
3359
4443
  const handleSignal = async () => {
4444
+ if (state.shuttingDown) return;
4445
+ state.shuttingDown = true;
3360
4446
  if (state.interactive) {
3361
4447
  logActivity(state, { type: "info", message: "Shutting down..." });
3362
4448
  displayStatus(state);
3363
4449
  } else {
3364
4450
  log2(state, "Shutting down...");
3365
4451
  }
3366
- await cleanup(state);
4452
+ await cleanup(state, { graceful: true });
3367
4453
  await shutdownTelemetry();
3368
4454
  process.exit(0);
3369
4455
  };
@@ -3467,9 +4553,9 @@ async function run(options) {
3467
4553
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
3468
4554
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
3469
4555
  if (versionWarning) {
3470
- log2(state, versionWarning, false);
4556
+ log2(state, versionWarning, "warn");
3471
4557
  if (state.interactive && !state.json) {
3472
- logActivity(state, { type: "info", message: versionWarning });
4558
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
3473
4559
  }
3474
4560
  }
3475
4561
  } catch (error2) {
@@ -3483,12 +4569,20 @@ async function run(options) {
3483
4569
  apiUrl: getApiUrlConfig(),
3484
4570
  getAuthHeader: () => state.authHeader,
3485
4571
  conversationFilter: state.conversationFilter,
3486
- log: (entry) => logActivity(state, {
3487
- type: entry.level === "error" ? "error" : "info",
3488
- message: entry.message,
3489
- error: entry.level === "error" ? entry.message : void 0
3490
- })
4572
+ stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
4573
+ log: (entry) => (
4574
+ // Thread the driver's real level straight through so `debug`/`warn`
4575
+ // survive the sink filter (they no longer collapse to info). `type`
4576
+ // stays the coarse error/non-error split the activity log renders with.
4577
+ logActivity(state, {
4578
+ type: entry.level === "error" ? "error" : "info",
4579
+ level: entry.level,
4580
+ message: entry.message,
4581
+ error: entry.level === "error" ? entry.message : void 0
4582
+ })
4583
+ )
3491
4584
  });
4585
+ state.channelDriver = channelDriver;
3492
4586
  const connection = new RunnerConnection({
3493
4587
  agentId: state.agentId,
3494
4588
  getAuthHeader: () => state.authHeader,
@@ -3502,7 +4596,11 @@ async function run(options) {
3502
4596
  type: "info",
3503
4597
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
3504
4598
  });
3505
- emitAgentConnected(state.agentId, { port: state.port });
4599
+ emitAgentConnected(state.agentId, {
4600
+ port: state.port,
4601
+ cli_version: getCliVersion(),
4602
+ opencode_version: state.opencodeVersion
4603
+ });
3506
4604
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
3507
4605
  if (state.interactive) displayStatus(state);
3508
4606
  channelDriver.drainPending().then((processed) => {
@@ -3581,10 +4679,12 @@ async function run(options) {
3581
4679
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
3582
4680
  throw error2;
3583
4681
  }
4682
+ scheduleSessionCleanup(state, channelDriver, options);
3584
4683
  if (!interactive || state.json) {
3585
4684
  log2(state, "Driving channel messages...");
3586
4685
  }
3587
4686
  await driveChannels(state, channelDriver);
4687
+ if (state.shuttingDown) return;
3588
4688
  await cleanup(state);
3589
4689
  if (state.json) {
3590
4690
  console.log(
@@ -3599,6 +4699,7 @@ async function run(options) {
3599
4699
  await shutdownTelemetry();
3600
4700
  process.exit(0);
3601
4701
  } catch (error2) {
4702
+ if (state.shuttingDown) return;
3602
4703
  await cleanup(state);
3603
4704
  const message = error2 instanceof Error ? error2.message : String(error2);
3604
4705
  if (state.json) {
@@ -3633,15 +4734,34 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
3633
4734
  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);
3634
4735
  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 }));
3635
4736
  program.command("whoami").description("Show the currently logged in user").action(whoami);
3636
- 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(
4737
+ 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(
4738
+ "--log-level <level>",
4739
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
4740
+ ).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").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(
4741
+ "--session-cleanup-max-age <duration>",
4742
+ "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4743
+ ).option(
4744
+ "--session-cleanup-max-count <n>",
4745
+ "Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
4746
+ ).option(
4747
+ "--session-cleanup-interval <duration>",
4748
+ "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
4749
+ ).action(
3637
4750
  (options) => {
3638
4751
  run({
3639
4752
  agent: options.agent,
3640
4753
  port: parseInt(options.port, 10),
4754
+ // Raw string — validation/precedence is single-sourced in run.ts's
4755
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
4756
+ logLevel: options.logLevel,
3641
4757
  verbose: options.verbose,
3642
4758
  conversation: options.conversation,
3643
4759
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
3644
- json: options.json
4760
+ json: options.json,
4761
+ // Raw strings — the resolver in run.ts single-sources parsing (M1).
4762
+ sessionCleanupMaxAge: options.sessionCleanupMaxAge,
4763
+ sessionCleanupMaxCount: options.sessionCleanupMaxCount,
4764
+ sessionCleanupInterval: options.sessionCleanupInterval
3645
4765
  });
3646
4766
  }
3647
4767
  );