@evident-ai/cli 3.0.1-dev.fffc02d → 3.1.1-dev.186f6ef

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
  }
@@ -645,14 +645,27 @@ var EventTypes = {
645
645
  // CLI lifecycle
646
646
  CLI_STARTED: "cli.started",
647
647
  CLI_COMMAND: "cli.command",
648
- CLI_ERROR: "cli.error"
648
+ CLI_ERROR: "cli.error",
649
+ // Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
650
+ // names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
651
+ DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
652
+ DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
649
653
  };
650
654
 
651
655
  // src/lib/auth.ts
652
656
  async function getAuthCredentials() {
657
+ const runnerKey = process.env.EVIDENT_RUNNER_KEY;
653
658
  const agentKey = process.env.EVIDENT_AGENT_KEY;
659
+ if (runnerKey) {
660
+ return {
661
+ token: runnerKey,
662
+ authType: "agent_key",
663
+ keySource: "runner_key",
664
+ notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
665
+ };
666
+ }
654
667
  if (agentKey) {
655
- return { token: agentKey, authType: "agent_key" };
668
+ return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
656
669
  }
657
670
  const userToken = process.env.EVIDENT_TOKEN;
658
671
  if (userToken) {
@@ -706,7 +719,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
706
719
  if (health.healthy) {
707
720
  return health;
708
721
  }
709
- await new Promise((resolve) => setTimeout(resolve, 1e3));
722
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
710
723
  }
711
724
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
712
725
  }
@@ -1078,6 +1091,84 @@ async function getSessionMessages(port, sessionId) {
1078
1091
  return null;
1079
1092
  }
1080
1093
  }
1094
+ function isSessionActivelyGenerating(messages) {
1095
+ if (!messages || messages.length === 0) return false;
1096
+ const last = messages[messages.length - 1];
1097
+ if (roleOf(last) !== "assistant") return false;
1098
+ return completedOf(last) == null;
1099
+ }
1100
+ function sessionLastActivityMs(session) {
1101
+ const candidates = [
1102
+ session.time?.updated,
1103
+ session.time?.created,
1104
+ session.time_updated,
1105
+ session.time_created,
1106
+ session.updated,
1107
+ session.created
1108
+ ];
1109
+ for (const c of candidates) {
1110
+ if (typeof c === "number" && Number.isFinite(c)) return c;
1111
+ }
1112
+ return null;
1113
+ }
1114
+ async function listSessions(port) {
1115
+ try {
1116
+ const res = await fetch(`${opencodeBase(port)}/session`);
1117
+ if (!res.ok) return null;
1118
+ const body = await res.json();
1119
+ return Array.isArray(body) ? body : null;
1120
+ } catch {
1121
+ return null;
1122
+ }
1123
+ }
1124
+ async function deleteSession(port, id) {
1125
+ try {
1126
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`, { method: "DELETE" });
1127
+ return res.status >= 200 && res.status < 300;
1128
+ } catch {
1129
+ return false;
1130
+ }
1131
+ }
1132
+ async function sessionExists(port, id) {
1133
+ try {
1134
+ const res = await fetch(`${opencodeBase(port)}/session/${id}`);
1135
+ if (res.status >= 200 && res.status < 300) return true;
1136
+ if (res.status === 404) return false;
1137
+ return null;
1138
+ } catch {
1139
+ return null;
1140
+ }
1141
+ }
1142
+ async function getSessionStatuses(port) {
1143
+ try {
1144
+ const res = await fetch(`${opencodeBase(port)}/session/status`);
1145
+ if (!res.ok) {
1146
+ console.error(
1147
+ `[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
1148
+ );
1149
+ return null;
1150
+ }
1151
+ const body = await res.json();
1152
+ if (body == null || typeof body !== "object" || Array.isArray(body)) {
1153
+ console.error(
1154
+ `[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
1155
+ );
1156
+ return null;
1157
+ }
1158
+ return body;
1159
+ } catch (err) {
1160
+ console.error(
1161
+ `[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1162
+ );
1163
+ return null;
1164
+ }
1165
+ }
1166
+ async function isSessionOngoing(port, id) {
1167
+ const map = await getSessionStatuses(port);
1168
+ if (map == null) return null;
1169
+ const entry = map[id];
1170
+ return entry != null && entry.type !== "idle";
1171
+ }
1081
1172
  async function createOpenCodeSession(port, directory) {
1082
1173
  const url = new URL(`${opencodeBase(port)}/session`);
1083
1174
  if (directory && directory.trim()) {
@@ -1095,17 +1186,113 @@ async function createOpenCodeSession(port, directory) {
1095
1186
  const data = await response.json();
1096
1187
  return data.id;
1097
1188
  }
1189
+ async function getModelAttachmentCapability(port, model) {
1190
+ try {
1191
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1192
+ if (!res.ok) {
1193
+ console.error(
1194
+ `[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
1195
+ );
1196
+ return null;
1197
+ }
1198
+ const body = await res.json();
1199
+ const providers = Array.isArray(body?.providers) ? body.providers : null;
1200
+ if (!providers) {
1201
+ console.error(
1202
+ `[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
1203
+ );
1204
+ return null;
1205
+ }
1206
+ const slash = model ? model.indexOf("/") : -1;
1207
+ const providerId = slash > 0 ? model.slice(0, slash) : void 0;
1208
+ let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
1209
+ const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
1210
+ let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
1211
+ if (!provider && !providerId) {
1212
+ const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
1213
+ if (defaultProviderIds.length === 1) {
1214
+ provider = providers.find((p) => p?.id === defaultProviderIds[0]);
1215
+ }
1216
+ }
1217
+ if (!provider || !provider.models) return null;
1218
+ if (!modelId && defaults2 && typeof provider.id === "string") {
1219
+ const def = defaults2[provider.id];
1220
+ if (typeof def === "string") modelId = def;
1221
+ }
1222
+ if (!modelId) {
1223
+ if (providerId) {
1224
+ const keys = Object.keys(provider.models);
1225
+ if (keys.length === 1) modelId = keys[0];
1226
+ }
1227
+ if (!modelId) return null;
1228
+ }
1229
+ const entry = provider.models[modelId];
1230
+ if (!entry || typeof entry !== "object") return null;
1231
+ return typeof entry.attachment === "boolean" ? entry.attachment : null;
1232
+ } catch (err) {
1233
+ console.error(
1234
+ `[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1235
+ );
1236
+ return null;
1237
+ }
1238
+ }
1239
+ async function buildFileParts(attachments, capable) {
1240
+ const outcomes = [];
1241
+ const parts = [];
1242
+ const capabilityUnknown = capable === null;
1243
+ if (capable !== true) {
1244
+ for (const a of attachments.inputs) {
1245
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
1246
+ }
1247
+ return { parts, outcomes, capabilityUnknown };
1248
+ }
1249
+ for (const a of attachments.inputs) {
1250
+ let dataUrl = null;
1251
+ try {
1252
+ dataUrl = await attachments.fetchDataUrl(a.index);
1253
+ } catch (err) {
1254
+ console.error(
1255
+ `[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
1256
+ );
1257
+ dataUrl = null;
1258
+ }
1259
+ if (dataUrl == null) {
1260
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
1261
+ continue;
1262
+ }
1263
+ parts.push({
1264
+ type: "file",
1265
+ mime: a.mime,
1266
+ url: dataUrl,
1267
+ ...a.filename ? { filename: a.filename } : {}
1268
+ });
1269
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
1270
+ }
1271
+ return { parts, outcomes, capabilityUnknown };
1272
+ }
1098
1273
  function messageText(m) {
1099
1274
  if (!m || !Array.isArray(m.parts)) return "";
1100
1275
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1101
1276
  }
1102
- async function sendPromptAsync(port, sessionId, content, options) {
1277
+ async function sendPromptAsync(port, sessionId, content, options, attachments) {
1103
1278
  const before = await getSessionMessages(port, sessionId);
1104
1279
  const knownUserIds = new Set(
1105
1280
  (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1106
1281
  );
1282
+ const parts = [{ type: "text", text: content }];
1283
+ let pendingOutcomes = null;
1284
+ if (attachments && attachments.inputs.length > 0) {
1285
+ const capable = await getModelAttachmentCapability(port, options?.model);
1286
+ const {
1287
+ parts: fileParts,
1288
+ outcomes,
1289
+ capabilityUnknown
1290
+ } = await buildFileParts(attachments, capable);
1291
+ parts.push(...fileParts);
1292
+ if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
1293
+ }
1107
1294
  const body = {
1108
- parts: [{ type: "text", text: content }]
1295
+ parts
1109
1296
  };
1110
1297
  if (options?.agent) {
1111
1298
  body.agent = options.agent;
@@ -1144,10 +1331,13 @@ async function sendPromptAsync(port, sessionId, content, options) {
1144
1331
  best = { id, created };
1145
1332
  }
1146
1333
  }
1147
- if (best) return best.id;
1334
+ if (best) {
1335
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
1336
+ return best.id;
1337
+ }
1148
1338
  }
1149
1339
  if (attempt < READ_BACK_ATTEMPTS - 1) {
1150
- await new Promise((resolve) => setTimeout(resolve, READ_BACK_DELAY_MS));
1340
+ await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
1151
1341
  }
1152
1342
  }
1153
1343
  return null;
@@ -1193,6 +1383,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
1193
1383
  }
1194
1384
  return lastOk ?? last;
1195
1385
  }
1386
+ function messageUsage(messages, userMessageId) {
1387
+ if (!messages || messages.length === 0) return null;
1388
+ const byParentAll = messages.filter(
1389
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1390
+ );
1391
+ const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
1392
+ const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
1393
+ let correlated;
1394
+ if (byParent.length > 0) {
1395
+ correlated = byParent;
1396
+ } else {
1397
+ const reply = findAssistantReplyAfter(messages, userMessageId);
1398
+ correlated = reply ? [reply] : [];
1399
+ }
1400
+ if (correlated.length === 0) return null;
1401
+ let sawAnyUsage = false;
1402
+ let inputSum = 0;
1403
+ let outputSum = 0;
1404
+ let reasoningSum = 0;
1405
+ let cacheReadSum = 0;
1406
+ let cacheWriteSum = 0;
1407
+ let costSum = 0;
1408
+ let sawCost = false;
1409
+ let modelId = null;
1410
+ let providerId = null;
1411
+ for (const m of correlated) {
1412
+ const info = m.info;
1413
+ if (!info) continue;
1414
+ const tokens = info.tokens;
1415
+ if (tokens) {
1416
+ sawAnyUsage = true;
1417
+ inputSum += tokens.input ?? 0;
1418
+ outputSum += tokens.output ?? 0;
1419
+ reasoningSum += tokens.reasoning ?? 0;
1420
+ cacheReadSum += tokens.cache?.read ?? 0;
1421
+ cacheWriteSum += tokens.cache?.write ?? 0;
1422
+ }
1423
+ if (typeof info.cost === "number") {
1424
+ sawAnyUsage = true;
1425
+ sawCost = true;
1426
+ costSum += info.cost;
1427
+ }
1428
+ if (typeof info.modelID === "string") {
1429
+ sawAnyUsage = true;
1430
+ modelId = info.modelID;
1431
+ }
1432
+ if (typeof info.providerID === "string") {
1433
+ sawAnyUsage = true;
1434
+ providerId = info.providerID;
1435
+ }
1436
+ }
1437
+ if (!sawAnyUsage) return null;
1438
+ return {
1439
+ usage_provider_id: providerId,
1440
+ usage_model_id: modelId,
1441
+ usage_tokens_input: inputSum,
1442
+ usage_tokens_output: outputSum,
1443
+ usage_tokens_reasoning: reasoningSum,
1444
+ usage_tokens_cache_read: cacheReadSum,
1445
+ usage_tokens_cache_write: cacheWriteSum,
1446
+ // NULL means "OpenCode never reported a cost" (never inferred from
1447
+ // tokens) — distinct from a genuine 0-cost turn, which would set
1448
+ // `sawCost` true with `costSum === 0`.
1449
+ usage_cost_usd: sawCost ? costSum : null
1450
+ };
1451
+ }
1196
1452
  function messageRunState(messages, userMessageId) {
1197
1453
  if (!messages || messages.length === 0) return "unknown";
1198
1454
  const hasUser = messages.some((m) => idOf(m) === userMessageId);
@@ -1204,6 +1460,11 @@ function messageRunState(messages, userMessageId) {
1204
1460
  if (isAssistantInFlight(reply)) return "running";
1205
1461
  return errorOf(reply) != null ? "failed" : "done";
1206
1462
  }
1463
+ function isPreamblePinnedRunning(messages, userMessageId) {
1464
+ if (messageRunState(messages, userMessageId) !== "running") return false;
1465
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1466
+ return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1467
+ }
1207
1468
  function messageError(messages, userMessageId) {
1208
1469
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1209
1470
  const error2 = errorOf(reply);
@@ -1224,6 +1485,110 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1224
1485
  );
1225
1486
  }
1226
1487
 
1488
+ // src/lib/opencode/session-cleanup.ts
1489
+ var DURATION_UNIT_MS = {
1490
+ s: 1e3,
1491
+ m: 60 * 1e3,
1492
+ h: 60 * 60 * 1e3,
1493
+ d: 24 * 60 * 60 * 1e3
1494
+ };
1495
+ function parseDurationMs(input) {
1496
+ const trimmed = input.trim();
1497
+ const match = /^(\d+)([smhd])$/.exec(trimmed);
1498
+ if (!match) {
1499
+ throw new Error(
1500
+ `Invalid duration "${input}": expected <number><unit> where unit is one of s, m, h, d (e.g. "7d", "24h", "30m", "90s").`
1501
+ );
1502
+ }
1503
+ const value = Number(match[1]);
1504
+ if (value <= 0) {
1505
+ throw new Error(`Invalid duration "${input}": must be a positive value.`);
1506
+ }
1507
+ return value * DURATION_UNIT_MS[match[2]];
1508
+ }
1509
+ function selectSessionsToDelete(sessions, opts) {
1510
+ const { maxAgeMs, maxCount, nowMs, protectedIds } = opts;
1511
+ if (maxAgeMs === void 0 && maxCount === void 0) return [];
1512
+ const ageEligible = (s) => {
1513
+ if (maxAgeMs === void 0) return false;
1514
+ if (s.lastActivityMs === null) return true;
1515
+ return nowMs - s.lastActivityMs > maxAgeMs;
1516
+ };
1517
+ const countEligibleIds = /* @__PURE__ */ new Set();
1518
+ if (maxCount !== void 0) {
1519
+ const byActivityDesc = [...sessions].sort(
1520
+ (a, b) => (b.lastActivityMs ?? -Infinity) - (a.lastActivityMs ?? -Infinity)
1521
+ );
1522
+ for (const s of byActivityDesc.slice(maxCount)) {
1523
+ countEligibleIds.add(s.id);
1524
+ }
1525
+ }
1526
+ const toDelete = [];
1527
+ for (const s of sessions) {
1528
+ if (protectedIds.has(s.id)) continue;
1529
+ if (ageEligible(s) || countEligibleIds.has(s.id)) {
1530
+ toDelete.push(s.id);
1531
+ }
1532
+ }
1533
+ return toDelete;
1534
+ }
1535
+ var DEFAULT_INTERVAL = "1h";
1536
+ function resolve(flag, envValue, fallback) {
1537
+ return flag ?? envValue ?? fallback;
1538
+ }
1539
+ function parseMaxCount(input) {
1540
+ const trimmed = input.trim();
1541
+ if (!/^\d+$/.test(trimmed)) {
1542
+ throw new Error(`Invalid max-count "${input}": expected a positive integer.`);
1543
+ }
1544
+ const value = Number(trimmed);
1545
+ if (value <= 0) {
1546
+ throw new Error(`Invalid max-count "${input}": must be greater than 0.`);
1547
+ }
1548
+ return value;
1549
+ }
1550
+ function resolveSessionCleanupConfig(flags, env = process.env) {
1551
+ const warnings = [];
1552
+ const maxAgeRaw = resolve(flags.maxAge, env.EVIDENT_SESSION_CLEANUP_MAX_AGE);
1553
+ const maxCountRaw = resolve(flags.maxCount, env.EVIDENT_SESSION_CLEANUP_MAX_COUNT);
1554
+ const intervalRaw = resolve(
1555
+ flags.interval,
1556
+ env.EVIDENT_SESSION_CLEANUP_INTERVAL,
1557
+ DEFAULT_INTERVAL
1558
+ );
1559
+ let maxAgeMs;
1560
+ if (maxAgeRaw !== void 0) {
1561
+ try {
1562
+ maxAgeMs = parseDurationMs(maxAgeRaw);
1563
+ } catch (err) {
1564
+ warnings.push(
1565
+ `Ignoring invalid --session-cleanup-max-age: ${err instanceof Error ? err.message : String(err)}`
1566
+ );
1567
+ }
1568
+ }
1569
+ let maxCount;
1570
+ if (maxCountRaw !== void 0) {
1571
+ try {
1572
+ maxCount = parseMaxCount(maxCountRaw);
1573
+ } catch (err) {
1574
+ warnings.push(
1575
+ `Ignoring invalid --session-cleanup-max-count: ${err instanceof Error ? err.message : String(err)}`
1576
+ );
1577
+ }
1578
+ }
1579
+ let intervalMs;
1580
+ try {
1581
+ intervalMs = parseDurationMs(intervalRaw ?? DEFAULT_INTERVAL);
1582
+ } catch (err) {
1583
+ warnings.push(
1584
+ `Ignoring invalid --session-cleanup-interval, using default ${DEFAULT_INTERVAL}: ${err instanceof Error ? err.message : String(err)}`
1585
+ );
1586
+ intervalMs = parseDurationMs(DEFAULT_INTERVAL);
1587
+ }
1588
+ const enabled = maxAgeMs !== void 0 || maxCount !== void 0;
1589
+ return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
1590
+ }
1591
+
1227
1592
  // src/lib/tunnel/connection.ts
1228
1593
  import WebSocket2 from "ws";
1229
1594
 
@@ -1314,12 +1679,12 @@ var StreamForwarder = class {
1314
1679
  let endBody;
1315
1680
  if (has_body) {
1316
1681
  const chunks = [];
1317
- bodyPromise = new Promise((resolve) => {
1682
+ bodyPromise = new Promise((resolve2) => {
1318
1683
  pushBody = (buf) => {
1319
1684
  chunks.push(buf);
1320
1685
  };
1321
1686
  endBody = () => {
1322
- resolve(Buffer.concat(chunks));
1687
+ resolve2(Buffer.concat(chunks));
1323
1688
  };
1324
1689
  });
1325
1690
  }
@@ -1437,7 +1802,7 @@ function connectTunnel(options) {
1437
1802
  } = options;
1438
1803
  const tunnelUrl = getTunnelUrlConfig();
1439
1804
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1440
- return new Promise((resolve, reject) => {
1805
+ return new Promise((resolve2, reject) => {
1441
1806
  const ws = new WebSocket2(url, {
1442
1807
  headers: {
1443
1808
  Authorization: authHeader
@@ -1502,7 +1867,7 @@ function connectTunnel(options) {
1502
1867
  clearTimeout(connectionTimeout);
1503
1868
  const connectedAgentId = message.agent_id ?? agentId;
1504
1869
  onConnected?.(connectedAgentId);
1505
- resolve({
1870
+ resolve2({
1506
1871
  ws,
1507
1872
  close: () => ws.close(1e3, "CLI shutdown")
1508
1873
  });
@@ -1624,6 +1989,17 @@ function messageIdOf(m) {
1624
1989
  const infoId = m.info?.id;
1625
1990
  return typeof infoId === "string" ? infoId : void 0;
1626
1991
  }
1992
+ function cleanImageMime(contentType) {
1993
+ if (!contentType) return null;
1994
+ const media = contentType.split(";")[0].trim().toLowerCase();
1995
+ return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
1996
+ }
1997
+ var LOG_LEVELS = {
1998
+ debug: 0,
1999
+ info: 1,
2000
+ warn: 2,
2001
+ error: 3
2002
+ };
1627
2003
  var DEFAULT_RETRY_POLICY = {
1628
2004
  maxAttempts: 6,
1629
2005
  baseDelayMs: 500,
@@ -1632,6 +2008,9 @@ var DEFAULT_RETRY_POLICY = {
1632
2008
  var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1633
2009
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1634
2010
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2011
+ var HEARTBEAT_MS = 6e4;
2012
+ var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2013
+ var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
1635
2014
  var ChannelAuthError = class extends Error {
1636
2015
  constructor(message) {
1637
2016
  super(message);
@@ -1728,6 +2107,15 @@ var ChannelDriver = class {
1728
2107
  * the row leaves the processing list, exactly like `dontRedispatch`.
1729
2108
  */
1730
2109
  doneUndeliverable = /* @__PURE__ */ new Set();
2110
+ /**
2111
+ * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
2112
+ * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
2113
+ * every ~2s drain until the status map becomes readable — but the server-visible
2114
+ * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
2115
+ * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
2116
+ * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
2117
+ */
2118
+ readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
1731
2119
  /**
1732
2120
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1733
2121
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -1742,6 +2130,15 @@ var ChannelDriver = class {
1742
2130
  * so the NEXT tick may retry exactly once more).
1743
2131
  */
1744
2132
  awaitingReadopt = /* @__PURE__ */ new Set();
2133
+ /**
2134
+ * "Already signalled `attachments_skipped` for this Evident message id" (#376).
2135
+ * The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
2136
+ * — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
2137
+ * next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
2138
+ * `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
2139
+ * outcome, not the dispatch. Not cleared (a message is signalled once for life).
2140
+ */
2141
+ attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
1745
2142
  /**
1746
2143
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1747
2144
  * first session creation so drain-created sessions are rooted at the project
@@ -1759,6 +2156,16 @@ var ChannelDriver = class {
1759
2156
  * entry = not yet resolved; `null` = resolved root (stop walking).
1760
2157
  */
1761
2158
  sessionParents = /* @__PURE__ */ new Map();
2159
+ /**
2160
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
2161
+ * NON-EMPTY name is stored (terminal — a real session name won't later un-name),
2162
+ * so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
2163
+ * resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
2164
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
2165
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
2166
+ * no watcher) can resolve the title.
2167
+ */
2168
+ sessionTitles = /* @__PURE__ */ new Map();
1762
2169
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1763
2170
  draining = false;
1764
2171
  /**
@@ -1796,9 +2203,6 @@ var ChannelDriver = class {
1796
2203
  get opencodeBase() {
1797
2204
  return `http://127.0.0.1:${this.port}`;
1798
2205
  }
1799
- // -------------------------------------------------------------------------
1800
- // Public API
1801
- // -------------------------------------------------------------------------
1802
2206
  /**
1803
2207
  * Drain all pending channel conversations once: poll → dispatch → register.
1804
2208
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -1854,6 +2258,28 @@ var ChannelDriver = class {
1854
2258
  }
1855
2259
  return false;
1856
2260
  }
2261
+ /**
2262
+ * OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
2263
+ * exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
2264
+ * `watchers` entry whose `inFlight` set is non-empty — the same predicate
2265
+ * `hasInFlightWatchers()` uses, lifted to return the ids.
2266
+ *
2267
+ * Deliberately does NOT include `this.sessions` (the permanent, never-pruned
2268
+ * conversation→session cache). Protecting every bound-but-idle session there
2269
+ * would shield nearly every session and defeat cleanup — AND it is unnecessary:
2270
+ * `ensureSession` is self-healing (it recreates a session whose id no longer
2271
+ * exists), so deleting an idle bound session is harmless — the conversation's
2272
+ * next turn transparently rebinds a fresh one. The only thing worth protecting
2273
+ * is a session with a turn ACTIVELY in flight right now: tearing that down
2274
+ * mid-turn would strand the running `prompt_async`. Idle sessions are fair game.
2275
+ */
2276
+ protectedSessionIds() {
2277
+ const ids = /* @__PURE__ */ new Set();
2278
+ for (const [sessionId, watcher] of this.watchers) {
2279
+ if (watcher.inFlight.size > 0) ids.add(sessionId);
2280
+ }
2281
+ return ids;
2282
+ }
1857
2283
  /**
1858
2284
  * Begin a graceful stop: stop accepting NEW channel work. Idempotent. After
1859
2285
  * this, `drainPending()` is a no-op (returns 0), so no new message is dispatched
@@ -1918,9 +2344,7 @@ var ChannelDriver = class {
1918
2344
  if (!stillLive) return;
1919
2345
  }
1920
2346
  }
1921
- // -------------------------------------------------------------------------
1922
2347
  // Conversation processing (WI-3 — async dispatch)
1923
- // -------------------------------------------------------------------------
1924
2348
  /**
1925
2349
  * Dispatch each pending message for a conversation to opencode's native queue
1926
2350
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -1952,13 +2376,24 @@ var ChannelDriver = class {
1952
2376
  conversation_id: conv.id,
1953
2377
  message_id: message.id
1954
2378
  });
2379
+ const sendAttachments = this.buildSendAttachments(conv, message);
1955
2380
  opencodeMessageId = await this.dispatchLocked(
1956
2381
  sessionId,
1957
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2382
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
1958
2383
  );
1959
2384
  } catch (err) {
1960
2385
  if (err instanceof ChannelAuthError) throw err;
1961
2386
  this.dispatched.delete(message.id);
2387
+ if (await sessionExists(this.port, sessionId) === false) {
2388
+ this.sessions.delete(conv.id);
2389
+ this.log({
2390
+ level: "warn",
2391
+ 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.`,
2392
+ conversation_id: conv.id,
2393
+ message_id: message.id
2394
+ });
2395
+ break;
2396
+ }
1962
2397
  await this.markFailed(conv.id, message.id).catch(() => {
1963
2398
  });
1964
2399
  this.log({
@@ -1971,7 +2406,7 @@ var ChannelDriver = class {
1971
2406
  }
1972
2407
  if (opencodeMessageId === null) {
1973
2408
  this.log({
1974
- level: "error",
2409
+ level: "warn",
1975
2410
  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`,
1976
2411
  conversation_id: conv.id,
1977
2412
  message_id: message.id
@@ -1985,7 +2420,7 @@ var ChannelDriver = class {
1985
2420
  }
1986
2421
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
1987
2422
  this.log({
1988
- level: "error",
2423
+ level: "warn",
1989
2424
  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).`,
1990
2425
  conversation_id: conv.id
1991
2426
  });
@@ -1994,16 +2429,33 @@ var ChannelDriver = class {
1994
2429
  return dispatched;
1995
2430
  }
1996
2431
  async ensureSession(conv) {
1997
- const cached = this.sessions.get(conv.id);
1998
- if (cached) return cached;
1999
- if (conv.opencode_session_id) {
2000
- this.sessions.set(conv.id, conv.opencode_session_id);
2001
- return conv.opencode_session_id;
2432
+ const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
2433
+ if (bound) {
2434
+ const exists = await sessionExists(this.port, bound);
2435
+ if (exists === false) {
2436
+ this.log({
2437
+ level: "debug",
2438
+ 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.`,
2439
+ conversation_id: conv.id
2440
+ });
2441
+ this.sessions.delete(conv.id);
2442
+ return this.createAndBindSession(conv.id);
2443
+ }
2444
+ this.sessions.set(conv.id, bound);
2445
+ return bound;
2002
2446
  }
2447
+ return this.createAndBindSession(conv.id);
2448
+ }
2449
+ /**
2450
+ * Create a fresh OpenCode session for a conversation, cache the binding, and
2451
+ * best-effort persist it server-side. Shared by the first-ever bind and the
2452
+ * self-heal recreate path in `ensureSession`.
2453
+ */
2454
+ async createAndBindSession(conversationId) {
2003
2455
  const directory = await this.resolveOpenCodeDirectory();
2004
2456
  const sessionId = await createOpenCodeSession(this.port, directory);
2005
- this.sessions.set(conv.id, sessionId);
2006
- await this.persistSession(conv.id, sessionId).catch(() => {
2457
+ this.sessions.set(conversationId, sessionId);
2458
+ await this.persistSession(conversationId, sessionId).catch(() => {
2007
2459
  });
2008
2460
  return sessionId;
2009
2461
  }
@@ -2017,15 +2469,13 @@ var ChannelDriver = class {
2017
2469
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2018
2470
  if (!this.opencodeDirectory) {
2019
2471
  this.log({
2020
- level: "info",
2472
+ level: "warn",
2021
2473
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2022
2474
  });
2023
2475
  }
2024
2476
  return this.opencodeDirectory;
2025
2477
  }
2026
- // -------------------------------------------------------------------------
2027
2478
  // Per-session watcher (WI-3)
2028
- // -------------------------------------------------------------------------
2029
2479
  /**
2030
2480
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2031
2481
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2045,6 +2495,103 @@ var ChannelDriver = class {
2045
2495
  );
2046
2496
  return run2;
2047
2497
  }
2498
+ // Inbound image attachments (#255, WI-8)
2499
+ /**
2500
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
2501
+ * `undefined` when the message has none (so a text-only turn is unchanged).
2502
+ *
2503
+ * The driver OWNS the two channel-facing concerns the session module cannot:
2504
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
2505
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
2506
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
2507
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
2508
+ * existing callback surface when any image was skipped/failed.
2509
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
2510
+ * reports outcomes back via `onOutcomes`.
2511
+ */
2512
+ buildSendAttachments(conv, message) {
2513
+ const refs = message.attachments;
2514
+ if (!refs || refs.length === 0) return void 0;
2515
+ return {
2516
+ inputs: refs.map((a, index) => ({
2517
+ index,
2518
+ mime: a.mime,
2519
+ ...a.filename ? { filename: a.filename } : {}
2520
+ })),
2521
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
2522
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
2523
+ };
2524
+ }
2525
+ /**
2526
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
2527
+ * (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
2528
+ * existing authenticated fetch, and base64-encode into a
2529
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
2530
+ *
2531
+ * The endpoint streams the source bytes verbatim (200), or returns 404
2532
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
2533
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
2534
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
2535
+ * Failures are logged with context (no silent swallow).
2536
+ */
2537
+ async fetchAttachmentDataUrl(messageId, index, mime) {
2538
+ try {
2539
+ const res = await this.fetchImpl(
2540
+ `${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
2541
+ { headers: { Authorization: this.getAuthHeader() } }
2542
+ );
2543
+ if (!res.ok) {
2544
+ this.log({
2545
+ level: "error",
2546
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
2547
+ message_id: messageId
2548
+ });
2549
+ return null;
2550
+ }
2551
+ const buf = await res.arrayBuffer();
2552
+ const base64 = Buffer.from(buf).toString("base64");
2553
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
2554
+ return `data:${dataMime};base64,${base64}`;
2555
+ } catch (err) {
2556
+ this.log({
2557
+ level: "error",
2558
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} failed \u2014 omitting this image (text turn proceeds): ${err instanceof Error ? err.message : String(err)}`,
2559
+ message_id: messageId
2560
+ });
2561
+ return null;
2562
+ }
2563
+ }
2564
+ /**
2565
+ * On any skipped/failed image, post an in-thread note to Evident over the
2566
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
2567
+ * Evident routes the note to source via `conversation.deliver`.
2568
+ *
2569
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
2570
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
2571
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
2572
+ * reaches the channel.
2573
+ *
2574
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
2575
+ */
2576
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
2577
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
2578
+ const failed = outcomes.filter((o) => o.status === "failed").length;
2579
+ if (skipped === 0 && failed === 0) return;
2580
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
2581
+ this.attachmentsSkippedSignalled.add(messageId);
2582
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
2583
+ this.log({
2584
+ level: "info",
2585
+ message: `Message ${messageId.slice(0, 8)}: ${skipped} image(s) skipped (${capabilityUnknown ? "capability was unreadable \u2014 failed open to text-only" : "model not attachment-capable"}), ${failed} image(s) unavailable (deleted-at-source or fetch failure) \u2014 noting to Evident`,
2586
+ conversation_id: conversationId,
2587
+ message_id: messageId
2588
+ });
2589
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
2590
+ skipped,
2591
+ failed,
2592
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {}
2593
+ });
2594
+ }
2048
2595
  /** Register a freshly-dispatched message with its session's watcher state. */
2049
2596
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2050
2597
  let watcher = this.watchers.get(sessionId);
@@ -2054,7 +2601,9 @@ var ChannelDriver = class {
2054
2601
  inFlight: /* @__PURE__ */ new Map(),
2055
2602
  loop: null,
2056
2603
  reportedQuestions: /* @__PURE__ */ new Set(),
2057
- reportedPermissions: /* @__PURE__ */ new Set()
2604
+ reportedPermissions: /* @__PURE__ */ new Set(),
2605
+ lastGoodPollAt: this.now(),
2606
+ hadUsablePoll: false
2058
2607
  };
2059
2608
  this.watchers.set(sessionId, watcher);
2060
2609
  }
@@ -2064,20 +2613,37 @@ var ChannelDriver = class {
2064
2613
  opencodeMessageId,
2065
2614
  message,
2066
2615
  dispatchedAt: now,
2616
+ processingAnchorMs: now,
2067
2617
  deadline: now + this.pausedMaxWaitMs,
2068
2618
  started: false,
2069
2619
  done: false,
2070
- stuckReported: false
2620
+ stuckReported: false,
2621
+ lastAliveAt: 0,
2622
+ aliveInFlight: false,
2623
+ awaitingHumanLatched: false,
2624
+ pausedOnQuestion: false,
2625
+ pausedOnPermission: false,
2626
+ pausedClearConfirmed: false,
2627
+ pausedInFlight: false,
2628
+ deliveryDeadlineAnchored: false
2071
2629
  });
2072
2630
  }
2073
2631
  /**
2074
2632
  * Register a RE-ADOPTED `processing` message with its session watcher
2075
2633
  * (ADR-0046, WI-4). Mirrors `registerInFlight` but anchors the give-up
2076
2634
  * `deadline` to the row's SERVER-SIDE `processed_at` (Invariant 1), NEVER to
2077
- * `now`: a row already `processing` for e.g. 5 min must give up ~5 min from now
2078
- * (10 min after `processed_at`), not 10 min from now — otherwise its deadline
2079
- * lands ~15 min after `processed_at`, coinciding with the cron reset →
2080
- * double-drive race. `dispatchedAt` stays `now` (only the appear-guard uses it).
2635
+ * `now`, so the paused/queued/unreachable cases settle on the same wall-clock a
2636
+ * fresh dispatch would (10 min after `processed_at`, not 10 min from now).
2637
+ *
2638
+ * This re-attaches into the SAME watcher, so the ADR-0047 progressing-vs-paused
2639
+ * give-up (`serviceInFlightMessage`) applies unchanged: a re-adopted turn
2640
+ * opencode reports ACTIVELY `running` is watched to completion (its liveness
2641
+ * heartbeat keeps the cron off its row), while a re-adopted turn that is paused
2642
+ * awaiting a human — or queued/unreachable — is still bounded by `deadline` and
2643
+ * handed to the cron. The old "the `deadline` must settle before the ~15-min
2644
+ * cron or they double-drive" reasoning is superseded: liveness now settles the
2645
+ * actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
2646
+ * (only the appear-guard uses it).
2081
2647
  *
2082
2648
  * `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
2083
2649
  * `opencodeMessageId` is the id the watcher polls for a reply — for the orphan
@@ -2095,7 +2661,9 @@ var ChannelDriver = class {
2095
2661
  inFlight: /* @__PURE__ */ new Map(),
2096
2662
  loop: null,
2097
2663
  reportedQuestions: /* @__PURE__ */ new Set(),
2098
- reportedPermissions: /* @__PURE__ */ new Set()
2664
+ reportedPermissions: /* @__PURE__ */ new Set(),
2665
+ lastGoodPollAt: this.now(),
2666
+ hadUsablePoll: false
2099
2667
  };
2100
2668
  this.watchers.set(sessionId, watcher);
2101
2669
  }
@@ -2104,6 +2672,10 @@ var ChannelDriver = class {
2104
2672
  opencodeMessageId,
2105
2673
  message,
2106
2674
  dispatchedAt: this.now(),
2675
+ // Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
2676
+ // value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
2677
+ // reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
2678
+ processingAnchorMs: processedAtMs,
2107
2679
  deadline: processedAtMs + this.pausedMaxWaitMs,
2108
2680
  // The server row is ALREADY `processing`; do not re-fire markProcessing.
2109
2681
  started: true,
@@ -2113,7 +2685,20 @@ var ChannelDriver = class {
2113
2685
  // on `state === 'queued'` (turn produced no reply), not on `started`, so a
2114
2686
  // re-adopted row left wedged in `queued` still emits the signal once
2115
2687
  // (#210/#220 observability).
2116
- stuckReported: false
2688
+ stuckReported: false,
2689
+ // Task 5.2: a re-adopted actively-running row re-attaches into the SAME
2690
+ // watcher and so hits the SAME actively-running heartbeat branch in
2691
+ // `serviceInFlightMessage` as a fresh dispatch — monitoring observes "runner
2692
+ // re-adopted and is confirming this row alive" via that `alive` heartbeat,
2693
+ // with no extra `re_adopted` signal needed (folds old WI-6).
2694
+ lastAliveAt: 0,
2695
+ aliveInFlight: false,
2696
+ awaitingHumanLatched: false,
2697
+ pausedOnQuestion: false,
2698
+ pausedOnPermission: false,
2699
+ pausedClearConfirmed: false,
2700
+ pausedInFlight: false,
2701
+ deliveryDeadlineAnchored: false
2117
2702
  });
2118
2703
  }
2119
2704
  /**
@@ -2164,12 +2749,30 @@ var ChannelDriver = class {
2164
2749
  messages = Array.isArray(body) ? body : null;
2165
2750
  }
2166
2751
  } catch {
2167
- continue;
2168
2752
  }
2753
+ if (messages != null && messages.length > 0) {
2754
+ watcher.lastGoodPollAt = this.now();
2755
+ watcher.hadUsablePoll = true;
2756
+ } else {
2757
+ const emptyButReachable = messages != null;
2758
+ const graceApplies = !emptyButReachable || watcher.hadUsablePoll;
2759
+ if (graceApplies && this.now() - watcher.lastGoodPollAt < POLL_MISS_GRACE_MS) {
2760
+ continue;
2761
+ }
2762
+ }
2763
+ const { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk } = await this.pollInteractions(sessionId, watcher, messages);
2169
2764
  for (const inFlight of [...watcher.inFlight.values()]) {
2170
- await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
2765
+ await this.serviceInFlightMessage(
2766
+ sessionId,
2767
+ watcher,
2768
+ inFlight,
2769
+ messages,
2770
+ openQuestions,
2771
+ openPermissions,
2772
+ questionsPolledOk,
2773
+ permissionsPolledOk
2774
+ );
2171
2775
  }
2172
- await this.pollInteractions(sessionId, watcher, messages);
2173
2776
  }
2174
2777
  } catch (err) {
2175
2778
  if (err instanceof ChannelAuthError) {
@@ -2191,28 +2794,55 @@ var ChannelDriver = class {
2191
2794
  });
2192
2795
  }
2193
2796
  }
2797
+ /**
2798
+ * On FIRST observing a terminal (done/failed) state, ensure the delivery
2799
+ * (markDone/markFailed) transient-retry path has a real window. A long
2800
+ * ACTIVELY-running turn is kept past its original `deadline`, so by completion
2801
+ * `now >= deadline` already holds and the retry bound below would fire on the
2802
+ * first transient PATCH failure — dropping the message before its reply lands
2803
+ * (Bugbot "Stale deadline aborts long-turn delivery"). Re-anchor once (latched)
2804
+ * to a fresh `pausedMaxWaitMs` window; only extend if the current deadline is at
2805
+ * or past now, so a still-ample window is left untouched.
2806
+ */
2807
+ anchorDeliveryDeadline(inFlight) {
2808
+ if (inFlight.deliveryDeadlineAnchored) return;
2809
+ inFlight.deliveryDeadlineAnchored = true;
2810
+ if (this.now() >= inFlight.deadline) {
2811
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2812
+ }
2813
+ }
2194
2814
  /**
2195
2815
  * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
2196
2816
  * Fires markProcessing on queued→running and markDone on done (each once),
2197
2817
  * applies the idle-path re-dispatch guard, and removes the message from the
2198
2818
  * in-flight set on completion or timeout.
2199
2819
  */
2200
- async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
2820
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages, openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk) {
2201
2821
  const conv = watcher.conv;
2202
2822
  const state = messageRunState(messages, inFlight.opencodeMessageId);
2823
+ const id = inFlight.evidentMessageId;
2824
+ if (openQuestions.has(id)) inFlight.pausedOnQuestion = true;
2825
+ else if (questionsPolledOk) inFlight.pausedOnQuestion = false;
2826
+ if (openPermissions.has(id)) inFlight.pausedOnPermission = true;
2827
+ else if (permissionsPolledOk) inFlight.pausedOnPermission = false;
2828
+ const observedOpen = openQuestions.has(id) || openPermissions.has(id);
2829
+ const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2830
+ const awaitingHuman = observedOpen || latchedPaused;
2203
2831
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2832
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2204
2833
  let claimed;
2205
2834
  try {
2206
2835
  claimed = await this.markProcessing(
2207
2836
  conv.id,
2208
2837
  inFlight.evidentMessageId,
2209
2838
  sessionId,
2210
- inFlight.opencodeMessageId
2839
+ inFlight.opencodeMessageId,
2840
+ title
2211
2841
  );
2212
2842
  } catch (err) {
2213
2843
  if (err instanceof ChannelAuthError) throw err;
2214
2844
  this.log({
2215
- level: "error",
2845
+ level: "warn",
2216
2846
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2217
2847
  conversation_id: conv.id,
2218
2848
  message_id: inFlight.evidentMessageId
@@ -2222,7 +2852,7 @@ var ChannelDriver = class {
2222
2852
  inFlight.started = true;
2223
2853
  if (!claimed) {
2224
2854
  this.log({
2225
- level: "info",
2855
+ level: "debug",
2226
2856
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2227
2857
  conversation_id: conv.id,
2228
2858
  message_id: inFlight.evidentMessageId
@@ -2230,6 +2860,7 @@ var ChannelDriver = class {
2230
2860
  }
2231
2861
  }
2232
2862
  if (state === "done") {
2863
+ this.anchorDeliveryDeadline(inFlight);
2233
2864
  if (!inFlight.done) {
2234
2865
  this.log({
2235
2866
  level: "info",
@@ -2237,18 +2868,22 @@ var ChannelDriver = class {
2237
2868
  conversation_id: conv.id,
2238
2869
  message_id: inFlight.evidentMessageId
2239
2870
  });
2871
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2872
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2240
2873
  try {
2241
2874
  await this.markDone(
2242
2875
  conv.id,
2243
2876
  inFlight.evidentMessageId,
2244
2877
  sessionId,
2245
- inFlight.opencodeMessageId
2878
+ inFlight.opencodeMessageId,
2879
+ title,
2880
+ usage
2246
2881
  );
2247
2882
  } catch (err) {
2248
2883
  if (err instanceof ChannelAuthError) throw err;
2249
2884
  if (err instanceof ChannelTerminalError) {
2250
2885
  this.log({
2251
- level: "error",
2886
+ level: "warn",
2252
2887
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2253
2888
  conversation_id: conv.id,
2254
2889
  message_id: inFlight.evidentMessageId
@@ -2258,7 +2893,7 @@ var ChannelDriver = class {
2258
2893
  }
2259
2894
  if (this.now() >= inFlight.deadline) {
2260
2895
  this.log({
2261
- level: "error",
2896
+ level: "warn",
2262
2897
  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)}`,
2263
2898
  conversation_id: conv.id,
2264
2899
  message_id: inFlight.evidentMessageId
@@ -2267,7 +2902,7 @@ var ChannelDriver = class {
2267
2902
  return;
2268
2903
  }
2269
2904
  this.log({
2270
- level: "error",
2905
+ level: "warn",
2271
2906
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2272
2907
  conversation_id: conv.id,
2273
2908
  message_id: inFlight.evidentMessageId
@@ -2280,6 +2915,7 @@ var ChannelDriver = class {
2280
2915
  return;
2281
2916
  }
2282
2917
  if (state === "failed") {
2918
+ this.anchorDeliveryDeadline(inFlight);
2283
2919
  if (!inFlight.done) {
2284
2920
  const error2 = messageError(messages, inFlight.opencodeMessageId) ?? void 0;
2285
2921
  this.log({
@@ -2288,13 +2924,14 @@ var ChannelDriver = class {
2288
2924
  conversation_id: conv.id,
2289
2925
  message_id: inFlight.evidentMessageId
2290
2926
  });
2927
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2291
2928
  try {
2292
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
2929
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
2293
2930
  } catch (err) {
2294
2931
  if (err instanceof ChannelAuthError) throw err;
2295
2932
  if (err instanceof ChannelTerminalError) {
2296
2933
  this.log({
2297
- level: "error",
2934
+ level: "warn",
2298
2935
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2299
2936
  conversation_id: conv.id,
2300
2937
  message_id: inFlight.evidentMessageId
@@ -2304,7 +2941,7 @@ var ChannelDriver = class {
2304
2941
  }
2305
2942
  if (this.now() >= inFlight.deadline) {
2306
2943
  this.log({
2307
- level: "error",
2944
+ level: "warn",
2308
2945
  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)}`,
2309
2946
  conversation_id: conv.id,
2310
2947
  message_id: inFlight.evidentMessageId
@@ -2313,7 +2950,7 @@ var ChannelDriver = class {
2313
2950
  return;
2314
2951
  }
2315
2952
  this.log({
2316
- level: "error",
2953
+ level: "warn",
2317
2954
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2318
2955
  conversation_id: conv.id,
2319
2956
  message_id: inFlight.evidentMessageId
@@ -2333,9 +2970,53 @@ var ChannelDriver = class {
2333
2970
  stuck_for_ms: this.now() - inFlight.dispatchedAt
2334
2971
  });
2335
2972
  }
2336
- if (this.now() >= inFlight.deadline) {
2973
+ const activelyRunning = state === "running" && !awaitingHuman;
2974
+ if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2337
2975
  this.log({
2338
- level: "info",
2976
+ level: "warn",
2977
+ 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`,
2978
+ conversation_id: conv.id,
2979
+ message_id: inFlight.evidentMessageId
2980
+ });
2981
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2982
+ watched_for_ms: this.now() - inFlight.processingAnchorMs
2983
+ });
2984
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2985
+ return;
2986
+ }
2987
+ if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
2988
+ inFlight.aliveInFlight = true;
2989
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
2990
+ inFlight.aliveInFlight = false;
2991
+ if (ok) inFlight.lastAliveAt = this.now();
2992
+ });
2993
+ }
2994
+ if (awaitingHuman) {
2995
+ if (!inFlight.awaitingHumanLatched) {
2996
+ inFlight.deadline = this.now() + this.pausedMaxWaitMs;
2997
+ inFlight.awaitingHumanLatched = true;
2998
+ }
2999
+ if (!inFlight.pausedClearConfirmed && !inFlight.pausedInFlight) {
3000
+ inFlight.pausedInFlight = true;
3001
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "paused").then((ok) => {
3002
+ inFlight.pausedInFlight = false;
3003
+ if (ok && inFlight.awaitingHumanLatched) inFlight.pausedClearConfirmed = true;
3004
+ });
3005
+ }
3006
+ } else if (inFlight.awaitingHumanLatched) {
3007
+ inFlight.awaitingHumanLatched = false;
3008
+ inFlight.pausedOnQuestion = false;
3009
+ inFlight.pausedOnPermission = false;
3010
+ inFlight.pausedClearConfirmed = false;
3011
+ }
3012
+ const siblingPaused = (sib) => openQuestions.has(sib.evidentMessageId) || openPermissions.has(sib.evidentMessageId) || sib.awaitingHumanLatched || sib.pausedOnQuestion || sib.pausedOnPermission;
3013
+ const hasActivelyRunningSibling = [...watcher.inFlight.values()].some(
3014
+ (sib) => sib.evidentMessageId !== inFlight.evidentMessageId && messageRunState(messages, sib.opencodeMessageId) === "running" && !siblingPaused(sib)
3015
+ );
3016
+ const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
3017
+ if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
3018
+ this.log({
3019
+ level: "debug",
2339
3020
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2340
3021
  conversation_id: conv.id,
2341
3022
  message_id: inFlight.evidentMessageId
@@ -2346,9 +3027,7 @@ var ChannelDriver = class {
2346
3027
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2347
3028
  }
2348
3029
  }
2349
- // -------------------------------------------------------------------------
2350
3030
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2351
- // -------------------------------------------------------------------------
2352
3031
  /**
2353
3032
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2354
3033
  *
@@ -2365,15 +3044,20 @@ var ChannelDriver = class {
2365
3044
  */
2366
3045
  async readoptProcessing() {
2367
3046
  const rows = await this.getProcessingMessages();
2368
- if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
3047
+ if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
2369
3048
  const stillProcessing = new Set(rows.map((r) => r.id));
2370
- for (const id of [...this.dontRedispatch, ...this.doneUndeliverable]) {
3049
+ for (const id of [
3050
+ ...this.dontRedispatch,
3051
+ ...this.doneUndeliverable,
3052
+ ...this.readoptPollUnresolvedSignalled
3053
+ ]) {
2371
3054
  if (!stillProcessing.has(id)) {
2372
3055
  const cleared = this.dontRedispatch.delete(id);
2373
3056
  const clearedUndeliverable = this.doneUndeliverable.delete(id);
3057
+ this.readoptPollUnresolvedSignalled.delete(id);
2374
3058
  if (cleared || clearedUndeliverable) {
2375
3059
  this.log({
2376
- level: "info",
3060
+ level: "debug",
2377
3061
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2378
3062
  message_id: id
2379
3063
  });
@@ -2386,7 +3070,7 @@ var ChannelDriver = class {
2386
3070
  for (const row of rows) {
2387
3071
  if (!row.opencode_session_id) {
2388
3072
  this.log({
2389
- level: "error",
3073
+ level: "warn",
2390
3074
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2391
3075
  conversation_id: row.conversation_id,
2392
3076
  message_id: row.id
@@ -2403,7 +3087,7 @@ var ChannelDriver = class {
2403
3087
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2404
3088
  if (!res.ok) {
2405
3089
  this.log({
2406
- level: "error",
3090
+ level: "warn",
2407
3091
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2408
3092
  });
2409
3093
  continue;
@@ -2411,7 +3095,7 @@ var ChannelDriver = class {
2411
3095
  const body = await res.json();
2412
3096
  if (!Array.isArray(body)) {
2413
3097
  this.log({
2414
- level: "error",
3098
+ level: "warn",
2415
3099
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2416
3100
  });
2417
3101
  continue;
@@ -2419,13 +3103,15 @@ var ChannelDriver = class {
2419
3103
  messages = body;
2420
3104
  } catch (err) {
2421
3105
  this.log({
2422
- level: "error",
3106
+ level: "warn",
2423
3107
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2424
3108
  });
2425
3109
  continue;
2426
3110
  }
3111
+ const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
3112
+ const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
2427
3113
  for (const row of sessionRows) {
2428
- await this.readoptOne(sessionId, row, messages);
3114
+ await this.readoptOne(sessionId, row, messages, sessionOngoing);
2429
3115
  }
2430
3116
  }
2431
3117
  }
@@ -2447,10 +3133,10 @@ var ChannelDriver = class {
2447
3133
  *
2448
3134
  * Only `ChannelAuthError` propagates.
2449
3135
  */
2450
- async readoptOne(sessionId, row, messages) {
3136
+ async readoptOne(sessionId, row, messages, sessionOngoing) {
2451
3137
  if (this.isTracked(sessionId, row.id)) {
2452
3138
  this.log({
2453
- level: "info",
3139
+ level: "debug",
2454
3140
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2455
3141
  conversation_id: row.conversation_id,
2456
3142
  message_id: row.id
@@ -2462,7 +3148,7 @@ var ChannelDriver = class {
2462
3148
  if (state === "done") {
2463
3149
  if (this.doneUndeliverable.has(row.id)) {
2464
3150
  this.log({
2465
- level: "info",
3151
+ level: "debug",
2466
3152
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2467
3153
  conversation_id: row.conversation_id,
2468
3154
  message_id: row.id
@@ -2476,21 +3162,24 @@ var ChannelDriver = class {
2476
3162
  message_id: row.id
2477
3163
  });
2478
3164
  try {
2479
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3165
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3166
+ const usage = messageUsage(messages, ocId ?? "");
3167
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
2480
3168
  } catch (err) {
2481
3169
  if (err instanceof ChannelAuthError) throw err;
2482
3170
  if (err instanceof ChannelTerminalError) {
2483
3171
  this.doneUndeliverable.add(row.id);
2484
3172
  this.log({
2485
- level: "error",
3173
+ level: "warn",
2486
3174
  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}`,
2487
3175
  conversation_id: row.conversation_id,
2488
3176
  message_id: row.id
2489
3177
  });
3178
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2490
3179
  return;
2491
3180
  }
2492
3181
  this.log({
2493
- level: "error",
3182
+ level: "warn",
2494
3183
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2495
3184
  conversation_id: row.conversation_id,
2496
3185
  message_id: row.id
@@ -2498,10 +3187,12 @@ var ChannelDriver = class {
2498
3187
  return;
2499
3188
  }
2500
3189
  this.dontRedispatch.delete(row.id);
3190
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
2501
3191
  return;
2502
3192
  }
2503
3193
  if (state === "failed") {
2504
3194
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3195
+ const usage = messageUsage(messages, ocId ?? "");
2505
3196
  this.log({
2506
3197
  level: "error",
2507
3198
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -2509,21 +3200,22 @@ var ChannelDriver = class {
2509
3200
  message_id: row.id
2510
3201
  });
2511
3202
  try {
2512
- await this.markFailed(row.conversation_id, row.id, sessionId, error2);
3203
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
2513
3204
  } catch (err) {
2514
3205
  if (err instanceof ChannelAuthError) throw err;
2515
3206
  if (err instanceof ChannelTerminalError) {
2516
3207
  this.doneUndeliverable.add(row.id);
2517
3208
  this.log({
2518
- level: "error",
3209
+ level: "warn",
2519
3210
  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}`,
2520
3211
  conversation_id: row.conversation_id,
2521
3212
  message_id: row.id
2522
3213
  });
3214
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2523
3215
  return;
2524
3216
  }
2525
3217
  this.log({
2526
- level: "error",
3218
+ level: "warn",
2527
3219
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2528
3220
  conversation_id: row.conversation_id,
2529
3221
  message_id: row.id
@@ -2531,17 +3223,83 @@ var ChannelDriver = class {
2531
3223
  return;
2532
3224
  }
2533
3225
  this.dontRedispatch.delete(row.id);
3226
+ void this.postSignal(row.conversation_id, row.id, "readopt_failed");
2534
3227
  return;
2535
3228
  }
2536
3229
  if (this.dontRedispatch.has(row.id)) {
2537
3230
  this.log({
2538
- level: "info",
3231
+ level: "debug",
2539
3232
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2540
3233
  conversation_id: row.conversation_id,
2541
3234
  message_id: row.id
2542
3235
  });
2543
3236
  return;
2544
3237
  }
3238
+ let statusReadableOngoing = null;
3239
+ if (state === "running" && ocId) {
3240
+ const reply = findLastAssistantReplyFor(messages, ocId);
3241
+ const shape = this.replyCompletionShape(reply);
3242
+ const ongoing = sessionOngoing;
3243
+ statusReadableOngoing = ongoing;
3244
+ if (ongoing === false) {
3245
+ this.log({
3246
+ level: "info",
3247
+ 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)`,
3248
+ conversation_id: row.conversation_id,
3249
+ message_id: row.id
3250
+ });
3251
+ await this.forceReadoptRun(sessionId, row);
3252
+ return;
3253
+ }
3254
+ if (ongoing === true) {
3255
+ this.log({
3256
+ level: "debug",
3257
+ 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)`,
3258
+ conversation_id: row.conversation_id,
3259
+ message_id: row.id
3260
+ });
3261
+ } else {
3262
+ if (shape === "b1") {
3263
+ this.log({
3264
+ level: "debug",
3265
+ 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`,
3266
+ conversation_id: row.conversation_id,
3267
+ message_id: row.id
3268
+ });
3269
+ if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
3270
+ this.readoptPollUnresolvedSignalled.add(row.id);
3271
+ void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
3272
+ }
3273
+ return;
3274
+ }
3275
+ this.log({
3276
+ level: "debug",
3277
+ 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`,
3278
+ conversation_id: row.conversation_id,
3279
+ message_id: row.id
3280
+ });
3281
+ }
3282
+ }
3283
+ if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
3284
+ const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
3285
+ if (descendantAlive === true) {
3286
+ this.log({
3287
+ level: "debug",
3288
+ 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)`,
3289
+ conversation_id: row.conversation_id,
3290
+ message_id: row.id
3291
+ });
3292
+ } else {
3293
+ this.log({
3294
+ level: "info",
3295
+ 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)" : ""}`,
3296
+ conversation_id: row.conversation_id,
3297
+ message_id: row.id
3298
+ });
3299
+ await this.forceReadoptRun(sessionId, row);
3300
+ return;
3301
+ }
3302
+ }
2545
3303
  if ((state === "running" || state === "queued") && ocId) {
2546
3304
  const conv = this.convForRow(sessionId, row);
2547
3305
  const message = this.queuedMessageForRow(row);
@@ -2550,11 +3308,12 @@ var ChannelDriver = class {
2550
3308
  this.readopted.add(row.id);
2551
3309
  this.ensureWatcherRunning(sessionId);
2552
3310
  this.log({
2553
- level: "info",
3311
+ level: "debug",
2554
3312
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2555
3313
  conversation_id: row.conversation_id,
2556
3314
  message_id: row.id
2557
3315
  });
3316
+ void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
2558
3317
  return;
2559
3318
  }
2560
3319
  await this.forceReadoptRun(sessionId, row);
@@ -2583,7 +3342,7 @@ var ChannelDriver = class {
2583
3342
  async forceReadoptRun(sessionId, row) {
2584
3343
  if (this.stopped) {
2585
3344
  this.log({
2586
- level: "info",
3345
+ level: "debug",
2587
3346
  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`,
2588
3347
  conversation_id: row.conversation_id,
2589
3348
  message_id: row.id
@@ -2592,7 +3351,7 @@ var ChannelDriver = class {
2592
3351
  }
2593
3352
  if (this.awaitingReadopt.has(row.id)) {
2594
3353
  this.log({
2595
- level: "info",
3354
+ level: "debug",
2596
3355
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
2597
3356
  conversation_id: row.conversation_id,
2598
3357
  message_id: row.id
@@ -2602,11 +3361,12 @@ var ChannelDriver = class {
2602
3361
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2603
3362
  this.dontRedispatch.add(row.id);
2604
3363
  this.log({
2605
- level: "info",
3364
+ level: "debug",
2606
3365
  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)`,
2607
3366
  conversation_id: row.conversation_id,
2608
3367
  message_id: row.id
2609
3368
  });
3369
+ void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
2610
3370
  return;
2611
3371
  }
2612
3372
  const options = {
@@ -2620,40 +3380,44 @@ var ChannelDriver = class {
2620
3380
  message_id: row.id
2621
3381
  });
2622
3382
  this.awaitingReadopt.add(row.id);
3383
+ const readoptConv = this.convForRow(sessionId, row);
3384
+ const readoptMessage = this.queuedMessageForRow(row);
3385
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
2623
3386
  let ocId;
2624
3387
  try {
2625
3388
  ocId = await this.dispatchLocked(
2626
3389
  sessionId,
2627
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3390
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
2628
3391
  );
2629
3392
  } catch (err) {
2630
3393
  this.awaitingReadopt.delete(row.id);
2631
3394
  if (err instanceof ChannelAuthError) throw err;
2632
3395
  this.log({
2633
- level: "error",
3396
+ level: "warn",
2634
3397
  message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2635
3398
  conversation_id: row.conversation_id,
2636
3399
  message_id: row.id
2637
3400
  });
3401
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2638
3402
  return;
2639
3403
  }
2640
3404
  if (ocId === null) {
2641
3405
  this.awaitingReadopt.delete(row.id);
2642
3406
  this.log({
2643
- level: "error",
3407
+ level: "warn",
2644
3408
  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`,
2645
3409
  conversation_id: row.conversation_id,
2646
3410
  message_id: row.id
2647
3411
  });
3412
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2648
3413
  return;
2649
3414
  }
2650
- const conv = this.convForRow(sessionId, row);
2651
- const message = this.queuedMessageForRow(row);
2652
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3415
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
2653
3416
  this.dispatched.add(row.id);
2654
3417
  this.readopted.add(row.id);
2655
3418
  this.awaitingReadopt.delete(row.id);
2656
3419
  this.ensureWatcherRunning(sessionId);
3420
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
2657
3421
  }
2658
3422
  /**
2659
3423
  * True if `evidentMessageId` is already being driven — either in the
@@ -2702,7 +3466,8 @@ var ChannelDriver = class {
2702
3466
  opencode_agent: row.opencode_agent,
2703
3467
  opencode_model: row.opencode_model,
2704
3468
  source_message_id: row.source_message_id,
2705
- slack_user_id: row.slack_user_id
3469
+ slack_user_id: row.slack_user_id,
3470
+ attachments: row.attachments ?? null
2706
3471
  };
2707
3472
  }
2708
3473
  /**
@@ -2723,7 +3488,7 @@ var ChannelDriver = class {
2723
3488
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
2724
3489
  this.dontRedispatch.add(evidentMessageId);
2725
3490
  this.log({
2726
- level: "info",
3491
+ level: "debug",
2727
3492
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
2728
3493
  conversation_id: watcher.conv.id,
2729
3494
  message_id: evidentMessageId
@@ -2745,21 +3510,41 @@ var ChannelDriver = class {
2745
3510
  * RUNNING (not done) is the one that paused. With one running message that is
2746
3511
  * unambiguous; with several we prefer an explicit messageID match, else the
2747
3512
  * oldest running message.
3513
+ *
3514
+ * Returns the set of in-flight Evident message ids that are paused awaiting a
3515
+ * human — an outstanding (still-open) question/permission is attributed to them.
3516
+ * `serviceInFlightMessage` uses this to keep an actively-running turn watched
3517
+ * forever (ADR-0047) while still bounding a turn merely blocked on a person who
3518
+ * may never answer. Attribution here covers ALL open interactions, not just
3519
+ * NEW (un-deduped) ones — a question stays "awaiting a human" until answered,
3520
+ * even after it was already surfaced to the channel.
2748
3521
  */
2749
3522
  async pollInteractions(sessionId, watcher, messages) {
3523
+ const openQuestions = /* @__PURE__ */ new Set();
3524
+ const openPermissions = /* @__PURE__ */ new Set();
3525
+ let questionsPolledOk = true;
3526
+ let permissionsPolledOk = true;
2750
3527
  let questions = [];
2751
3528
  try {
2752
3529
  const res = await this.fetchImpl(`${this.opencodeBase}/question`);
2753
3530
  if (res.ok) {
2754
3531
  const body = await res.json();
2755
- questions = Array.isArray(body) ? body : [];
3532
+ if (Array.isArray(body)) {
3533
+ questions = body;
3534
+ } else {
3535
+ questionsPolledOk = false;
3536
+ }
3537
+ } else {
3538
+ questionsPolledOk = false;
2756
3539
  }
2757
3540
  } catch {
3541
+ questionsPolledOk = false;
2758
3542
  }
2759
3543
  for (const q of questions) {
2760
- if (watcher.reportedQuestions.has(q.id)) continue;
2761
3544
  if (!await this.sessionBelongsTo(q.sessionID, sessionId)) continue;
2762
3545
  const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
3546
+ if (paused) openQuestions.add(paused.evidentMessageId);
3547
+ if (watcher.reportedQuestions.has(q.id)) continue;
2763
3548
  const reported = await this.reportInteraction(
2764
3549
  watcher.conv.id,
2765
3550
  "question",
@@ -2773,14 +3558,22 @@ var ChannelDriver = class {
2773
3558
  const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2774
3559
  if (res.ok) {
2775
3560
  const body = await res.json();
2776
- permissions = Array.isArray(body) ? body : [];
3561
+ if (Array.isArray(body)) {
3562
+ permissions = body;
3563
+ } else {
3564
+ permissionsPolledOk = false;
3565
+ }
3566
+ } else {
3567
+ permissionsPolledOk = false;
2777
3568
  }
2778
3569
  } catch {
3570
+ permissionsPolledOk = false;
2779
3571
  }
2780
3572
  for (const p of permissions) {
2781
- if (watcher.reportedPermissions.has(p.id)) continue;
2782
3573
  if (!await this.sessionBelongsTo(p.sessionID, sessionId)) continue;
2783
3574
  const paused = this.attributeInteraction(watcher, p.messageID, messages);
3575
+ if (paused) openPermissions.add(paused.evidentMessageId);
3576
+ if (watcher.reportedPermissions.has(p.id)) continue;
2784
3577
  const reported = await this.reportInteraction(
2785
3578
  watcher.conv.id,
2786
3579
  "permission",
@@ -2789,6 +3582,7 @@ var ChannelDriver = class {
2789
3582
  );
2790
3583
  if (reported) watcher.reportedPermissions.add(p.id);
2791
3584
  }
3585
+ return { openQuestions, openPermissions, questionsPolledOk, permissionsPolledOk };
2792
3586
  }
2793
3587
  /**
2794
3588
  * True when `sessionId` is the `rootSessionId` itself OR a descendant of it —
@@ -2834,6 +3628,128 @@ var ChannelDriver = class {
2834
3628
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
2835
3629
  return parent;
2836
3630
  }
3631
+ /**
3632
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3633
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3634
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3635
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3636
+ * Best-effort:
3637
+ * - a resolved NON-EMPTY title is cached and terminal (a real session name
3638
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3639
+ * - while the title is still absent/empty we do NOT latch it — OpenCode names
3640
+ * sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
3641
+ * must leave the cache unresolved and re-fetch on the next need so a later
3642
+ * call (e.g. at `done`) picks up the name assigned in the meantime. Such a
3643
+ * call returns `null` (omit the title on THIS PATCH) without caching;
3644
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3645
+ * and returns `null` — it must NEVER throw or block completion.
3646
+ * A failure is logged with agent/session context (no silent catch).
3647
+ */
3648
+ async resolveSessionTitle(sessionId, conversationId) {
3649
+ const cached = this.sessionTitles.get(sessionId);
3650
+ if (cached != null) return cached;
3651
+ try {
3652
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3653
+ if (res.ok) {
3654
+ const body = await res.json();
3655
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3656
+ if (title.length > 0) {
3657
+ this.sessionTitles.set(sessionId, title);
3658
+ return title;
3659
+ }
3660
+ return null;
3661
+ }
3662
+ this.log({
3663
+ level: "debug",
3664
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3665
+ conversation_id: conversationId
3666
+ });
3667
+ } catch (err) {
3668
+ this.log({
3669
+ level: "debug",
3670
+ 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)}`,
3671
+ conversation_id: conversationId
3672
+ });
3673
+ }
3674
+ return null;
3675
+ }
3676
+ /**
3677
+ * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3678
+ * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
3679
+ *
3680
+ * The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
3681
+ * runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
3682
+ * completed `finish: "tool-calls"` root reply encountered during re-adoption is
3683
+ * idle by OpenCode's own definition and is re-dispatched. This method exists only
3684
+ * so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
3685
+ * provably in flight at the exact moment of recovery.
3686
+ *
3687
+ * "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
3688
+ * ACTIVELY generating — its LAST message is an assistant still mid-generation
3689
+ * (`completed == null`, via `isSessionActivelyGenerating`). An
3690
+ * INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
3691
+ * completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
3692
+ * is generating once the runner is gone), so it does NOT veto. (This is
3693
+ * deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
3694
+ * shapes and would falsely veto — re-hanging the very turn this path recovers.)
3695
+ *
3696
+ * Return contract (encoded so WI-3 need not re-derive it):
3697
+ * - `true` → a descendant is provably, actively generating (veto re-dispatch).
3698
+ * - `false` → descendants exist but none is actively generating (the restart
3699
+ * case), OR no descendant is found at all.
3700
+ * - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
3701
+ *
3702
+ * ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
3703
+ * as `false` and does NOT veto — a restart guarantees no live runner, so an
3704
+ * indeterminate cross-check almost always means "couldn't reach a child that no
3705
+ * longer exists". The inversion lives in the caller; this method just reports
3706
+ * true/false/null faithfully.
3707
+ *
3708
+ * VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
3709
+ * (already proven by the existing child-session interaction tests, via
3710
+ * `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
3711
+ * terminal state. We do NOT depend on any session-level `busy`/`idle` field —
3712
+ * there is none on `GET /session/:id`; OpenCode's busy state is in-memory
3713
+ * `SessionStatus` only.
3714
+ */
3715
+ async isAnyDescendantSessionAlive(rootSessionId) {
3716
+ const sessions = await listSessions(this.port);
3717
+ if (!sessions) {
3718
+ this.log({
3719
+ level: "warn",
3720
+ message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3721
+ });
3722
+ return null;
3723
+ }
3724
+ for (const candidate of sessions) {
3725
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
3726
+ if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
3727
+ const childMsgs = await getSessionMessages(this.port, candidate.id);
3728
+ if (isSessionActivelyGenerating(childMsgs)) {
3729
+ return true;
3730
+ }
3731
+ }
3732
+ return false;
3733
+ }
3734
+ /**
3735
+ * Cheap decision-telemetry label for a running row's LAST correlated reply
3736
+ * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
3737
+ * - `b1` — the reply itself is still in flight (`time.completed == null`) —
3738
+ * the aborted-in-flight production bug after a restart.
3739
+ * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
3740
+ * (the sub-agent preamble — #253's shape).
3741
+ * - `other` — any other shape (defensive; a running row is normally b1 or b2).
3742
+ * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
3743
+ * shape) directly rather than re-importing the module-private `completedOf`/
3744
+ * `finishOf` — this is a display label only, not a correctness predicate.
3745
+ */
3746
+ replyCompletionShape(reply) {
3747
+ if (!reply) return "other";
3748
+ const completed = reply.info?.time?.completed ?? reply.time?.completed;
3749
+ if (completed == null) return "b1";
3750
+ const finish = reply.info?.finish ?? reply.finish;
3751
+ return finish === "tool-calls" ? "b2" : "other";
3752
+ }
2837
3753
  /**
2838
3754
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2839
3755
  *
@@ -2884,12 +3800,10 @@ var ChannelDriver = class {
2884
3800
  }
2885
3801
  return inFlight.sort(byOldest)[0];
2886
3802
  }
2887
- // -------------------------------------------------------------------------
2888
3803
  // Evident API calls (combinedAuth thread routes)
2889
- // -------------------------------------------------------------------------
2890
3804
  async getPendingConversations() {
2891
3805
  const res = await this.fetchImpl(
2892
- `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
3806
+ `${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
2893
3807
  {
2894
3808
  headers: { Authorization: this.getAuthHeader() }
2895
3809
  }
@@ -2907,7 +3821,7 @@ var ChannelDriver = class {
2907
3821
  }
2908
3822
  async getPendingMessages(conversationId) {
2909
3823
  const res = await this.fetchImpl(
2910
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
3824
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
2911
3825
  { headers: { Authorization: this.getAuthHeader() } }
2912
3826
  );
2913
3827
  this.assertAuth(res, "fetching pending messages");
@@ -2931,7 +3845,7 @@ var ChannelDriver = class {
2931
3845
  */
2932
3846
  async getProcessingMessages() {
2933
3847
  const res = await this.fetchImpl(
2934
- `${this.apiUrl}/agents/${this.agentId}/conversations/processing`,
3848
+ `${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
2935
3849
  { headers: { Authorization: this.getAuthHeader() } }
2936
3850
  );
2937
3851
  this.assertAuth(res, "fetching processing messages");
@@ -2966,16 +3880,17 @@ var ChannelDriver = class {
2966
3880
  * A single attempt (no internal retry): the watcher's per-tick loop is the
2967
3881
  * retry vehicle for the swap-to-running.
2968
3882
  */
2969
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
3883
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
2970
3884
  const res = await this.fetchImpl(
2971
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3885
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2972
3886
  {
2973
3887
  method: "PATCH",
2974
3888
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2975
3889
  body: JSON.stringify({
2976
3890
  status: "processing",
2977
3891
  opencode_session_id: sessionId,
2978
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3892
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3893
+ ...title ? { title } : {}
2979
3894
  })
2980
3895
  }
2981
3896
  );
@@ -3014,16 +3929,18 @@ var ChannelDriver = class {
3014
3929
  * watcher retries next tick within the
3015
3930
  * deadline, Finding 4).
3016
3931
  */
3017
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
3932
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
3018
3933
  const res = await this.fetchImpl(
3019
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3934
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3020
3935
  {
3021
3936
  method: "PATCH",
3022
3937
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
3023
3938
  body: JSON.stringify({
3024
3939
  status: "done",
3025
3940
  opencode_session_id: sessionId,
3026
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3941
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3942
+ ...title ? { title } : {},
3943
+ ...usage ? usage : {}
3027
3944
  })
3028
3945
  }
3029
3946
  );
@@ -3041,14 +3958,15 @@ var ChannelDriver = class {
3041
3958
  * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3042
3959
  * failure reason reaches the channel.
3043
3960
  */
3044
- async markFailed(conversationId, messageId, sessionId, error2) {
3961
+ async markFailed(conversationId, messageId, sessionId, error2, usage) {
3045
3962
  const body = { status: "failed" };
3046
3963
  if (sessionId !== void 0) body.opencode_session_id = sessionId;
3047
3964
  if (error2 !== void 0) body.error = error2;
3965
+ if (usage) Object.assign(body, usage);
3048
3966
  await this.callWithRetry(
3049
3967
  "marking message as failed",
3050
3968
  () => this.fetchImpl(
3051
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3969
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3052
3970
  {
3053
3971
  method: "PATCH",
3054
3972
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3065,11 +3983,17 @@ var ChannelDriver = class {
3065
3983
  * MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
3066
3984
  * watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
3067
3985
  * context (no silent catch, per development-workflow).
3986
+ *
3987
+ * Returns whether the POST SUCCEEDED (2xx). Most callers ignore this (pure
3988
+ * telemetry), but the `paused` liveness-clear uses it to know whether to
3989
+ * RE-ASSERT on a later tick — a single dropped `paused` POST must not leave a
3990
+ * stale `last_seen_alive_at` on a still-paused row (Bugbot "Failed paused signal
3991
+ * leaves liveness").
3068
3992
  */
3069
3993
  async postSignal(conversationId, messageId, signal, extra) {
3070
3994
  try {
3071
3995
  const res = await this.fetchImpl(
3072
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
3996
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
3073
3997
  {
3074
3998
  method: "POST",
3075
3999
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3078,24 +4002,27 @@ var ChannelDriver = class {
3078
4002
  );
3079
4003
  if (!res.ok) {
3080
4004
  this.log({
3081
- level: "error",
4005
+ level: "warn",
3082
4006
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3083
4007
  conversation_id: conversationId,
3084
4008
  message_id: messageId
3085
4009
  });
4010
+ return false;
3086
4011
  }
4012
+ return true;
3087
4013
  } catch (err) {
3088
4014
  this.log({
3089
- level: "error",
4015
+ level: "warn",
3090
4016
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3091
4017
  conversation_id: conversationId,
3092
4018
  message_id: messageId
3093
4019
  });
4020
+ return false;
3094
4021
  }
3095
4022
  }
3096
4023
  async persistSession(conversationId, sessionId) {
3097
4024
  const res = await this.fetchImpl(
3098
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
4025
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
3099
4026
  {
3100
4027
  method: "PATCH",
3101
4028
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3121,7 +4048,7 @@ var ChannelDriver = class {
3121
4048
  await this.callWithRetry(
3122
4049
  "reporting interactive event",
3123
4050
  () => this.fetchImpl(
3124
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
4051
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
3125
4052
  {
3126
4053
  method: "POST",
3127
4054
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3147,9 +4074,7 @@ var ChannelDriver = class {
3147
4074
  return false;
3148
4075
  }
3149
4076
  }
3150
- // -------------------------------------------------------------------------
3151
4077
  // Retry wrapper
3152
- // -------------------------------------------------------------------------
3153
4078
  /**
3154
4079
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3155
4080
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3348,7 +4273,7 @@ async function resolveAgentIdFromKey(authHeader) {
3348
4273
  if (!response.ok) {
3349
4274
  const serverMessage = await readErrorMessage(response);
3350
4275
  return {
3351
- error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
4276
+ error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
3352
4277
  };
3353
4278
  }
3354
4279
  const data = await response.json();
@@ -3356,17 +4281,17 @@ async function resolveAgentIdFromKey(authHeader) {
3356
4281
  return { agent_id: data.agent_id };
3357
4282
  }
3358
4283
  return {
3359
- error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
4284
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
3360
4285
  };
3361
4286
  } catch (error2) {
3362
4287
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3363
- return { error: `Failed to resolve agent from key: ${message}` };
4288
+ return { error: `Failed to resolve runner from key: ${message}` };
3364
4289
  }
3365
4290
  }
3366
4291
  async function notifyAgentDisconnected(agentId, authHeader) {
3367
4292
  const apiUrl = getApiUrlConfig();
3368
4293
  try {
3369
- const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
4294
+ const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
3370
4295
  method: "POST",
3371
4296
  headers: { Authorization: authHeader }
3372
4297
  });
@@ -3385,7 +4310,7 @@ async function notifyAgentDisconnected(agentId, authHeader) {
3385
4310
  async function getAgentInfo(agentId, authHeader) {
3386
4311
  const apiUrl = getApiUrlConfig();
3387
4312
  try {
3388
- const response = await fetch(`${apiUrl}/agents/${agentId}`, {
4313
+ const response = await fetch(`${apiUrl}/runners/${agentId}`, {
3389
4314
  headers: { Authorization: authHeader }
3390
4315
  });
3391
4316
  if (response.status === 401) {
@@ -3396,12 +4321,12 @@ async function getAgentInfo(agentId, authHeader) {
3396
4321
  const serverMessage = await readErrorMessage(response);
3397
4322
  return {
3398
4323
  valid: false,
3399
- error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
4324
+ error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
3400
4325
  };
3401
4326
  }
3402
4327
  if (response.status === 404) {
3403
4328
  const serverMessage = await readErrorMessage(response);
3404
- return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
4329
+ return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
3405
4330
  }
3406
4331
  if (!response.ok) {
3407
4332
  const serverMessage = await readErrorMessage(response);
@@ -3414,13 +4339,13 @@ async function getAgentInfo(agentId, authHeader) {
3414
4339
  if (agent.agent_type !== "local") {
3415
4340
  return {
3416
4341
  valid: false,
3417
- error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
4342
+ error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
3418
4343
  };
3419
4344
  }
3420
4345
  return { valid: true, agent };
3421
4346
  } catch (error2) {
3422
4347
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3423
- return { valid: false, error: `Failed to validate agent: ${message}` };
4348
+ return { valid: false, error: `Failed to validate runner: ${message}` };
3424
4349
  }
3425
4350
  }
3426
4351
 
@@ -3429,23 +4354,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
3429
4354
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3430
4355
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3431
4356
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
3432
- function log2(state, message, isError = false) {
4357
+ function resolveLogLevel(options) {
4358
+ const accepted = Object.keys(LOG_LEVELS);
4359
+ const validate = (value, source) => {
4360
+ const normalized = value.trim().toLowerCase();
4361
+ if (!accepted.includes(normalized)) {
4362
+ throw new Error(
4363
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4364
+ );
4365
+ }
4366
+ return normalized;
4367
+ };
4368
+ if (options.logLevel !== void 0) {
4369
+ return validate(options.logLevel, " (--log-level)");
4370
+ }
4371
+ if (options.verbose) {
4372
+ return "debug";
4373
+ }
4374
+ const env = process.env.EVIDENT_LOG_LEVEL;
4375
+ if (env !== void 0 && env !== "") {
4376
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4377
+ }
4378
+ return "info";
4379
+ }
4380
+ function meetsThreshold(state, level) {
4381
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4382
+ }
4383
+ function log2(state, message, level = "info") {
4384
+ if (!meetsThreshold(state, level)) return;
3433
4385
  if (state.json) {
3434
4386
  console.log(
3435
4387
  JSON.stringify({
3436
4388
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3437
- level: isError ? "error" : "info",
4389
+ level,
3438
4390
  message
3439
4391
  })
3440
4392
  );
3441
4393
  } else if (!state.interactive) {
3442
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4394
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
3443
4395
  console.log(`${prefix} ${message}`);
3444
4396
  }
3445
4397
  }
3446
4398
  function logActivity(state, entry) {
4399
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4400
+ if (!meetsThreshold(state, level)) return;
3447
4401
  const fullEntry = {
3448
4402
  ...entry,
4403
+ level,
3449
4404
  timestamp: /* @__PURE__ */ new Date()
3450
4405
  };
3451
4406
  state.activityLog.push(fullEntry);
@@ -3454,9 +4409,9 @@ function logActivity(state, entry) {
3454
4409
  }
3455
4410
  if (!state.interactive) {
3456
4411
  if (entry.type === "error") {
3457
- log2(state, entry.error ?? "Unknown error", true);
3458
- } else if (entry.type === "info" && entry.message) {
3459
- log2(state, entry.message);
4412
+ log2(state, entry.error ?? "Unknown error", level);
4413
+ } else if (entry.message) {
4414
+ log2(state, entry.message, level);
3460
4415
  }
3461
4416
  }
3462
4417
  }
@@ -3583,7 +4538,7 @@ async function driveChannels(state, driver) {
3583
4538
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
3584
4539
  if (state.interactive) displayStatus(state);
3585
4540
  }
3586
- await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
4541
+ await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
3587
4542
  if (state.idleTimeout !== null && idlePolls >= 2) {
3588
4543
  const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
3589
4544
  if (idleMs > state.idleTimeout * 1e3) {
@@ -3594,6 +4549,81 @@ async function driveChannels(state, driver) {
3594
4549
  }
3595
4550
  }
3596
4551
  }
4552
+ var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
4553
+ async function runSweep(state, driver, config2) {
4554
+ const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
4555
+ try {
4556
+ const sessions = await listSessions(state.port);
4557
+ if (sessions === null) {
4558
+ logActivity(state, {
4559
+ type: "info",
4560
+ message: `Session cleanup: could not list sessions (opencode unreachable); skipping this sweep (${mode})`
4561
+ });
4562
+ return;
4563
+ }
4564
+ const toDelete = selectSessionsToDelete(
4565
+ sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
4566
+ {
4567
+ maxAgeMs: config2.maxAgeMs,
4568
+ maxCount: config2.maxCount,
4569
+ nowMs: Date.now(),
4570
+ protectedIds: driver.protectedSessionIds()
4571
+ }
4572
+ );
4573
+ const protectedNow = driver.protectedSessionIds();
4574
+ let deleted = 0;
4575
+ let failed = 0;
4576
+ let skippedNewlyActive = 0;
4577
+ for (const id of toDelete) {
4578
+ if (protectedNow.has(id)) {
4579
+ skippedNewlyActive++;
4580
+ logActivity(state, {
4581
+ type: "info",
4582
+ message: `Session cleanup: skipping ${id} \u2014 became active/bound after selection (${mode})`
4583
+ });
4584
+ continue;
4585
+ }
4586
+ if (await deleteSession(state.port, id)) deleted++;
4587
+ else failed++;
4588
+ }
4589
+ const failedNote = failed > 0 ? `, failed ${failed}` : "";
4590
+ const skippedNote = skippedNewlyActive > 0 ? `, skipped ${skippedNewlyActive} newly-active` : "";
4591
+ logActivity(state, {
4592
+ type: "info",
4593
+ message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
4594
+ });
4595
+ } catch (error2) {
4596
+ const message = error2 instanceof Error ? error2.message : String(error2);
4597
+ logActivity(state, {
4598
+ type: "error",
4599
+ error: `Session cleanup sweep failed (non-fatal, ${mode}): ${message}`
4600
+ });
4601
+ }
4602
+ }
4603
+ function scheduleSessionCleanup(state, driver, options) {
4604
+ const config2 = resolveSessionCleanupConfig(
4605
+ {
4606
+ maxAge: options.sessionCleanupMaxAge,
4607
+ maxCount: options.sessionCleanupMaxCount,
4608
+ interval: options.sessionCleanupInterval
4609
+ },
4610
+ process.env
4611
+ );
4612
+ for (const warning2 of config2.warnings) {
4613
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4614
+ }
4615
+ if (!config2.enabled) return;
4616
+ logActivity(state, {
4617
+ type: "info",
4618
+ message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
4619
+ });
4620
+ const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
4621
+ const firstSweep = setTimeout(
4622
+ () => void runSweep(state, driver, config2),
4623
+ SESSION_CLEANUP_FIRST_SWEEP_MS
4624
+ );
4625
+ state.sessionCleanupTimers.push(interval, firstSweep);
4626
+ }
3597
4627
  async function notifyOffline(state) {
3598
4628
  if (!state.agentId || !state.authHeader) return;
3599
4629
  if (!state.connected) {
@@ -3602,7 +4632,7 @@ async function notifyOffline(state) {
3602
4632
  }
3603
4633
  const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
3604
4634
  if (result.ok) {
3605
- log2(state, "Notified Evident the agent is going offline");
4635
+ log2(state, "Notified Evident the runner is going offline");
3606
4636
  } else {
3607
4637
  logActivity(state, {
3608
4638
  type: "error",
@@ -3613,6 +4643,11 @@ async function notifyOffline(state) {
3613
4643
  }
3614
4644
  async function cleanup(state, opts = {}) {
3615
4645
  state.running = false;
4646
+ for (const timer of state.sessionCleanupTimers) {
4647
+ clearInterval(timer);
4648
+ clearTimeout(timer);
4649
+ }
4650
+ state.sessionCleanupTimers = [];
3616
4651
  if (opts.graceful && state.channelDriver) {
3617
4652
  state.channelDriver.stop();
3618
4653
  log2(state, "Draining in-flight channel work before shutdown...");
@@ -3647,14 +4682,29 @@ async function cleanup(state, opts = {}) {
3647
4682
  }
3648
4683
  async function run(options) {
3649
4684
  const interactive = isInteractive(options.json);
4685
+ let logLevel;
4686
+ try {
4687
+ logLevel = resolveLogLevel(options);
4688
+ } catch (error2) {
4689
+ const message = error2 instanceof Error ? error2.message : String(error2);
4690
+ if (options.json) {
4691
+ console.log(JSON.stringify({ status: "error", error: message }));
4692
+ } else {
4693
+ printError(message);
4694
+ }
4695
+ await shutdownTelemetry();
4696
+ process.exit(1);
4697
+ return;
4698
+ }
3650
4699
  const state = {
3651
- agentId: options.agent || "",
4700
+ agentId: options.runner || options.agent || "",
3652
4701
  agentName: null,
3653
4702
  port: options.port ?? 4096,
3654
4703
  conversationFilter: options.conversation ?? null,
3655
4704
  idleTimeout: options.idleTimeout ?? null,
3656
4705
  json: options.json ?? false,
3657
4706
  interactive,
4707
+ logLevel,
3658
4708
  connected: false,
3659
4709
  opencodeConnected: false,
3660
4710
  opencodeVersion: null,
@@ -3666,13 +4716,22 @@ async function run(options) {
3666
4716
  activityLog: [],
3667
4717
  messageCount: 0,
3668
4718
  lastProxiedActivityAt: null,
4719
+ sessionCleanupTimers: [],
3669
4720
  authHeader: ""
3670
4721
  };
4722
+ if (!options.runner && options.agent) {
4723
+ telemetry.info(
4724
+ EventTypes.DEPRECATED_AGENT_FLAG_USED,
4725
+ "Deprecated --agent flag used instead of --runner",
4726
+ { command: "run" },
4727
+ state.agentId
4728
+ );
4729
+ }
3671
4730
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
3672
4731
  log2(
3673
4732
  state,
3674
- "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.",
3675
- false
4733
+ "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.",
4734
+ "warn"
3676
4735
  );
3677
4736
  }
3678
4737
  const handleSignal = async () => {
@@ -3696,7 +4755,9 @@ async function run(options) {
3696
4755
  if (!interactive) {
3697
4756
  printError("Authentication required");
3698
4757
  blank();
3699
- console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
4758
+ console.log(
4759
+ chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
4760
+ );
3700
4761
  console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
3701
4762
  blank();
3702
4763
  process.exit(1);
@@ -3710,26 +4771,46 @@ async function run(options) {
3710
4771
  );
3711
4772
  }
3712
4773
  state.authHeader = getAuthHeader(credentials2);
4774
+ if (credentials2.notice) {
4775
+ log2(state, credentials2.notice, "warn");
4776
+ if (state.interactive && !state.json) {
4777
+ logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
4778
+ }
4779
+ }
4780
+ if (credentials2.keySource === "agent_key") {
4781
+ telemetry.info(
4782
+ EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
4783
+ "Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
4784
+ { command: "run" },
4785
+ state.agentId
4786
+ );
4787
+ }
3713
4788
  if (!state.agentId) {
3714
4789
  if (credentials2.authType === "agent_key") {
3715
4790
  const resolved = await resolveAgentIdFromKey(state.authHeader);
3716
4791
  if (resolved.agent_id) {
3717
4792
  state.agentId = resolved.agent_id;
3718
- log2(state, `Resolved agent ID from key: ${state.agentId}`);
4793
+ log2(state, `Resolved runner ID from key: ${state.agentId}`);
3719
4794
  if (state.interactive && !state.json) {
3720
4795
  logActivity(state, {
3721
4796
  type: "info",
3722
- message: `Agent ID resolved from key: ${state.agentId}`
4797
+ message: `Runner ID resolved from key: ${state.agentId}`
3723
4798
  });
3724
4799
  }
3725
4800
  } else {
3726
- printError(resolved.error || "Failed to resolve agent ID from key");
4801
+ printError(resolved.error || "Failed to resolve runner ID from key");
3727
4802
  process.exit(1);
3728
4803
  }
3729
4804
  } else {
3730
- printError("--agent is required when not using EVIDENT_AGENT_KEY");
4805
+ printError(
4806
+ "--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
4807
+ );
3731
4808
  blank();
3732
- console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
4809
+ console.log(
4810
+ chalk6.dim(
4811
+ "Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
4812
+ )
4813
+ );
3733
4814
  blank();
3734
4815
  process.exit(1);
3735
4816
  }
@@ -3751,7 +4832,7 @@ async function run(options) {
3751
4832
  console.log(chalk6.bold("Evident Run"));
3752
4833
  console.log(chalk6.dim("-".repeat(40)));
3753
4834
  }
3754
- const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
4835
+ const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
3755
4836
  let validation = await getAgentInfo(state.agentId, state.authHeader);
3756
4837
  if (!validation.valid && validation.authFailed && interactive) {
3757
4838
  spinner?.fail("Authentication failed");
@@ -3763,14 +4844,14 @@ async function run(options) {
3763
4844
  "Login successful! Retrying..."
3764
4845
  );
3765
4846
  state.authHeader = getAuthHeader(credentials2);
3766
- spinner?.start("Validating agent...");
4847
+ spinner?.start("Validating runner...");
3767
4848
  validation = await getAgentInfo(state.agentId, state.authHeader);
3768
4849
  }
3769
4850
  if (!validation.valid) {
3770
- spinner?.fail(`Agent validation failed: ${validation.error}`);
4851
+ spinner?.fail(`Runner validation failed: ${validation.error}`);
3771
4852
  throw new Error(validation.error);
3772
4853
  }
3773
- spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
4854
+ spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
3774
4855
  state.agentName = validation.agent.name;
3775
4856
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
3776
4857
  try {
@@ -3788,9 +4869,9 @@ async function run(options) {
3788
4869
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
3789
4870
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
3790
4871
  if (versionWarning) {
3791
- log2(state, versionWarning, false);
4872
+ log2(state, versionWarning, "warn");
3792
4873
  if (state.interactive && !state.json) {
3793
- logActivity(state, { type: "info", message: versionWarning });
4874
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
3794
4875
  }
3795
4876
  }
3796
4877
  } catch (error2) {
@@ -3805,11 +4886,17 @@ async function run(options) {
3805
4886
  getAuthHeader: () => state.authHeader,
3806
4887
  conversationFilter: state.conversationFilter,
3807
4888
  stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
3808
- log: (entry) => logActivity(state, {
3809
- type: entry.level === "error" ? "error" : "info",
3810
- message: entry.message,
3811
- error: entry.level === "error" ? entry.message : void 0
3812
- })
4889
+ log: (entry) => (
4890
+ // Thread the driver's real level straight through so `debug`/`warn`
4891
+ // survive the sink filter (they no longer collapse to info). `type`
4892
+ // stays the coarse error/non-error split the activity log renders with.
4893
+ logActivity(state, {
4894
+ type: entry.level === "error" ? "error" : "info",
4895
+ level: entry.level,
4896
+ message: entry.message,
4897
+ error: entry.level === "error" ? entry.message : void 0
4898
+ })
4899
+ )
3813
4900
  });
3814
4901
  state.channelDriver = channelDriver;
3815
4902
  const connection = new RunnerConnection({
@@ -3823,7 +4910,7 @@ async function run(options) {
3823
4910
  state.agentId = agentId;
3824
4911
  logActivity(state, {
3825
4912
  type: "info",
3826
- message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
4913
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
3827
4914
  });
3828
4915
  emitAgentConnected(state.agentId, {
3829
4916
  port: state.port,
@@ -3908,6 +4995,7 @@ async function run(options) {
3908
4995
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
3909
4996
  throw error2;
3910
4997
  }
4998
+ scheduleSessionCleanup(state, channelDriver, options);
3911
4999
  if (!interactive || state.json) {
3912
5000
  log2(state, "Driving channel messages...");
3913
5001
  }
@@ -3937,7 +5025,7 @@ async function run(options) {
3937
5025
  }
3938
5026
  telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
3939
5027
  command: "run",
3940
- agentId: options.agent
5028
+ agentId: options.runner || options.agent
3941
5029
  });
3942
5030
  await shutdownTelemetry();
3943
5031
  process.exit(1);
@@ -3962,15 +5050,35 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
3962
5050
  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);
3963
5051
  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 }));
3964
5052
  program.command("whoami").description("Show the currently logged in user").action(whoami);
3965
- 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(
5053
+ program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Runner ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("--runner [id]", "Alias for --agent (preferred name; wins if both are given)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
5054
+ "--log-level <level>",
5055
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
5056
+ ).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(
5057
+ "--session-cleanup-max-age <duration>",
5058
+ "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
5059
+ ).option(
5060
+ "--session-cleanup-max-count <n>",
5061
+ "Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
5062
+ ).option(
5063
+ "--session-cleanup-interval <duration>",
5064
+ "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
5065
+ ).action(
3966
5066
  (options) => {
3967
5067
  run({
3968
5068
  agent: options.agent,
5069
+ runner: options.runner,
3969
5070
  port: parseInt(options.port, 10),
5071
+ // Raw string — validation/precedence is single-sourced in run.ts's
5072
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
5073
+ logLevel: options.logLevel,
3970
5074
  verbose: options.verbose,
3971
5075
  conversation: options.conversation,
3972
5076
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
3973
- json: options.json
5077
+ json: options.json,
5078
+ // Raw strings — the resolver in run.ts single-sources parsing (M1).
5079
+ sessionCleanupMaxAge: options.sessionCleanupMaxAge,
5080
+ sessionCleanupMaxCount: options.sessionCleanupMaxCount,
5081
+ sessionCleanupInterval: options.sessionCleanupInterval
3974
5082
  });
3975
5083
  }
3976
5084
  );