@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.120303a

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
@@ -499,6 +499,12 @@ function log(level, event, fields) {
499
499
  );
500
500
  }
501
501
  }
502
+ function errorFields(err) {
503
+ if (err instanceof Error) {
504
+ return { error: err.message, error_name: err.name };
505
+ }
506
+ return { error: String(err) };
507
+ }
502
508
  function stripQuery(url) {
503
509
  try {
504
510
  return new URL(url).pathname;
@@ -645,14 +651,27 @@ var EventTypes = {
645
651
  // CLI lifecycle
646
652
  CLI_STARTED: "cli.started",
647
653
  CLI_COMMAND: "cli.command",
648
- CLI_ERROR: "cli.error"
654
+ CLI_ERROR: "cli.error",
655
+ // Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
656
+ // names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
657
+ DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
658
+ DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
649
659
  };
650
660
 
651
661
  // src/lib/auth.ts
652
662
  async function getAuthCredentials() {
663
+ const runnerKey = process.env.EVIDENT_RUNNER_KEY;
653
664
  const agentKey = process.env.EVIDENT_AGENT_KEY;
665
+ if (runnerKey) {
666
+ return {
667
+ token: runnerKey,
668
+ authType: "agent_key",
669
+ keySource: "runner_key",
670
+ notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
671
+ };
672
+ }
654
673
  if (agentKey) {
655
- return { token: agentKey, authType: "agent_key" };
674
+ return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
656
675
  }
657
676
  const userToken = process.env.EVIDENT_TOKEN;
658
677
  if (userToken) {
@@ -721,7 +740,7 @@ function buildOpenCodeVersionWarning(version2) {
721
740
  if (isQueueValidatedVersion(version2)) return null;
722
741
  const detected = version2 ? `v${version2}` : "unknown";
723
742
  const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
724
- return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
743
+ return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
725
744
  }
726
745
 
727
746
  // src/lib/opencode/process.ts
@@ -1013,6 +1032,12 @@ async function promptOpenCodeInstall(interactive) {
1013
1032
  return action;
1014
1033
  }
1015
1034
 
1035
+ // src/lib/opencode/provider-check.ts
1036
+ function buildNoProviderWarning(hasProvider) {
1037
+ if (hasProvider !== false) return null;
1038
+ return "Warning: opencode has no authenticated model provider configured, so it won't be able to answer prompts. Run `opencode auth login` to set one up (see https://opencode.ai for details).";
1039
+ }
1040
+
1016
1041
  // src/lib/opencode/session.ts
1017
1042
  function opencodeBase(port) {
1018
1043
  return `http://127.0.0.1:${port}`;
@@ -1173,17 +1198,128 @@ async function createOpenCodeSession(port, directory) {
1173
1198
  const data = await response.json();
1174
1199
  return data.id;
1175
1200
  }
1201
+ async function getModelAttachmentCapability(port, model) {
1202
+ try {
1203
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1204
+ if (!res.ok) {
1205
+ console.error(
1206
+ `[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
1207
+ );
1208
+ return null;
1209
+ }
1210
+ const body = await res.json();
1211
+ const providers = Array.isArray(body?.providers) ? body.providers : null;
1212
+ if (!providers) {
1213
+ console.error(
1214
+ `[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
1215
+ );
1216
+ return null;
1217
+ }
1218
+ const slash = model ? model.indexOf("/") : -1;
1219
+ const providerId = slash > 0 ? model.slice(0, slash) : void 0;
1220
+ let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
1221
+ const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
1222
+ let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
1223
+ if (!provider && !providerId) {
1224
+ const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
1225
+ if (defaultProviderIds.length === 1) {
1226
+ provider = providers.find((p) => p?.id === defaultProviderIds[0]);
1227
+ }
1228
+ }
1229
+ if (!provider || !provider.models) return null;
1230
+ if (!modelId && defaults2 && typeof provider.id === "string") {
1231
+ const def = defaults2[provider.id];
1232
+ if (typeof def === "string") modelId = def;
1233
+ }
1234
+ if (!modelId) {
1235
+ if (providerId) {
1236
+ const keys = Object.keys(provider.models);
1237
+ if (keys.length === 1) modelId = keys[0];
1238
+ }
1239
+ if (!modelId) return null;
1240
+ }
1241
+ const entry = provider.models[modelId];
1242
+ if (!entry || typeof entry !== "object") return null;
1243
+ if (entry.capabilities && typeof entry.capabilities === "object") {
1244
+ if (typeof entry.capabilities.attachment === "boolean") {
1245
+ return entry.capabilities.attachment;
1246
+ }
1247
+ }
1248
+ return typeof entry.attachment === "boolean" ? entry.attachment : null;
1249
+ } catch (err) {
1250
+ console.error(
1251
+ `[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1252
+ );
1253
+ return null;
1254
+ }
1255
+ }
1256
+ async function buildFileParts(attachments, capable) {
1257
+ const outcomes = [];
1258
+ const parts = [];
1259
+ const capabilityUnknown = capable === null;
1260
+ if (capable !== true) {
1261
+ for (const a of attachments.inputs) {
1262
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
1263
+ }
1264
+ return { parts, outcomes, capabilityUnknown };
1265
+ }
1266
+ for (const a of attachments.inputs) {
1267
+ let dataUrl = null;
1268
+ try {
1269
+ dataUrl = await attachments.fetchDataUrl(a.index);
1270
+ } catch (err) {
1271
+ console.error(
1272
+ `[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
1273
+ );
1274
+ dataUrl = null;
1275
+ }
1276
+ if (dataUrl !== null && typeof dataUrl === "object") {
1277
+ outcomes.push({
1278
+ index: a.index,
1279
+ mime: a.mime,
1280
+ filename: a.filename,
1281
+ status: "failed",
1282
+ reason: "needs_reauth"
1283
+ });
1284
+ continue;
1285
+ }
1286
+ if (dataUrl == null) {
1287
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
1288
+ continue;
1289
+ }
1290
+ parts.push({
1291
+ type: "file",
1292
+ mime: a.mime,
1293
+ url: dataUrl,
1294
+ ...a.filename ? { filename: a.filename } : {}
1295
+ });
1296
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
1297
+ }
1298
+ return { parts, outcomes, capabilityUnknown };
1299
+ }
1176
1300
  function messageText(m) {
1177
1301
  if (!m || !Array.isArray(m.parts)) return "";
1178
1302
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1179
1303
  }
1180
- async function sendPromptAsync(port, sessionId, content, options) {
1304
+ async function sendPromptAsync(port, sessionId, content, options, attachments) {
1181
1305
  const before = await getSessionMessages(port, sessionId);
1182
1306
  const knownUserIds = new Set(
1183
1307
  (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1184
1308
  );
1309
+ const parts = [{ type: "text", text: content }];
1310
+ let pendingOutcomes = null;
1311
+ if (attachments && attachments.inputs.length > 0) {
1312
+ const capable = await getModelAttachmentCapability(port, options?.model);
1313
+ const {
1314
+ parts: fileParts,
1315
+ outcomes,
1316
+ capabilityUnknown
1317
+ } = await buildFileParts(attachments, capable);
1318
+ parts.push(...fileParts);
1319
+ if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
1320
+ }
1185
1321
  const body = {
1186
- parts: [{ type: "text", text: content }]
1322
+ parts
1187
1323
  };
1188
1324
  if (options?.agent) {
1189
1325
  body.agent = options.agent;
@@ -1222,7 +1358,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
1222
1358
  best = { id, created };
1223
1359
  }
1224
1360
  }
1225
- if (best) return best.id;
1361
+ if (best) {
1362
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
1363
+ return best.id;
1364
+ }
1226
1365
  }
1227
1366
  if (attempt < READ_BACK_ATTEMPTS - 1) {
1228
1367
  await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
@@ -1271,6 +1410,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
1271
1410
  }
1272
1411
  return lastOk ?? last;
1273
1412
  }
1413
+ function messageUsage(messages, userMessageId) {
1414
+ if (!messages || messages.length === 0) return null;
1415
+ const byParentAll = messages.filter(
1416
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1417
+ );
1418
+ const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
1419
+ const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
1420
+ let correlated;
1421
+ if (byParent.length > 0) {
1422
+ correlated = byParent;
1423
+ } else {
1424
+ const reply = findAssistantReplyAfter(messages, userMessageId);
1425
+ correlated = reply ? [reply] : [];
1426
+ }
1427
+ if (correlated.length === 0) return null;
1428
+ let sawAnyUsage = false;
1429
+ let inputSum = 0;
1430
+ let outputSum = 0;
1431
+ let reasoningSum = 0;
1432
+ let cacheReadSum = 0;
1433
+ let cacheWriteSum = 0;
1434
+ let costSum = 0;
1435
+ let sawCost = false;
1436
+ let modelId = null;
1437
+ let providerId = null;
1438
+ for (const m of correlated) {
1439
+ const info = m.info;
1440
+ if (!info) continue;
1441
+ const tokens = info.tokens;
1442
+ if (tokens) {
1443
+ sawAnyUsage = true;
1444
+ inputSum += tokens.input ?? 0;
1445
+ outputSum += tokens.output ?? 0;
1446
+ reasoningSum += tokens.reasoning ?? 0;
1447
+ cacheReadSum += tokens.cache?.read ?? 0;
1448
+ cacheWriteSum += tokens.cache?.write ?? 0;
1449
+ }
1450
+ if (typeof info.cost === "number") {
1451
+ sawAnyUsage = true;
1452
+ sawCost = true;
1453
+ costSum += info.cost;
1454
+ }
1455
+ if (typeof info.modelID === "string") {
1456
+ sawAnyUsage = true;
1457
+ modelId = info.modelID;
1458
+ }
1459
+ if (typeof info.providerID === "string") {
1460
+ sawAnyUsage = true;
1461
+ providerId = info.providerID;
1462
+ }
1463
+ }
1464
+ if (!sawAnyUsage) return null;
1465
+ return {
1466
+ usage_provider_id: providerId,
1467
+ usage_model_id: modelId,
1468
+ usage_tokens_input: inputSum,
1469
+ usage_tokens_output: outputSum,
1470
+ usage_tokens_reasoning: reasoningSum,
1471
+ usage_tokens_cache_read: cacheReadSum,
1472
+ usage_tokens_cache_write: cacheWriteSum,
1473
+ // NULL means "OpenCode never reported a cost" (never inferred from
1474
+ // tokens) — distinct from a genuine 0-cost turn, which would set
1475
+ // `sawCost` true with `costSum === 0`.
1476
+ usage_cost_usd: sawCost ? costSum : null
1477
+ };
1478
+ }
1274
1479
  function messageRunState(messages, userMessageId) {
1275
1480
  if (!messages || messages.length === 0) return "unknown";
1276
1481
  const hasUser = messages.some((m) => idOf(m) === userMessageId);
@@ -1306,6 +1511,37 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1306
1511
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1307
1512
  );
1308
1513
  }
1514
+ async function hasAnyConfiguredProvider(port) {
1515
+ try {
1516
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1517
+ if (!res.ok) {
1518
+ console.error(
1519
+ `[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
1520
+ );
1521
+ return null;
1522
+ }
1523
+ const body = await res.json();
1524
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1525
+ console.error(
1526
+ `[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
1527
+ );
1528
+ return null;
1529
+ }
1530
+ const defaults2 = body.default;
1531
+ if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
1532
+ console.error(
1533
+ `[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
1534
+ );
1535
+ return null;
1536
+ }
1537
+ return Object.keys(defaults2).length > 0;
1538
+ } catch (err) {
1539
+ console.error(
1540
+ `[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1541
+ );
1542
+ return null;
1543
+ }
1544
+ }
1309
1545
 
1310
1546
  // src/lib/opencode/session-cleanup.ts
1311
1547
  var DURATION_UNIT_MS = {
@@ -1464,10 +1700,11 @@ var StreamForwarder = class {
1464
1700
  * Abort every in-flight stream (e.g. on WebSocket close).
1465
1701
  */
1466
1702
  abortAll() {
1467
- for (const stream of this.inflight.values()) {
1703
+ for (const [sid, stream] of this.inflight.entries()) {
1468
1704
  try {
1469
1705
  stream.abort();
1470
- } catch {
1706
+ } catch (err) {
1707
+ log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
1471
1708
  }
1472
1709
  }
1473
1710
  this.inflight.clear();
@@ -1617,7 +1854,6 @@ function connectTunnel(options) {
1617
1854
  onConnected,
1618
1855
  onDisconnected,
1619
1856
  onError,
1620
- onRequest,
1621
1857
  onResponse,
1622
1858
  onInfo,
1623
1859
  onDrainPing
@@ -1630,18 +1866,8 @@ function connectTunnel(options) {
1630
1866
  Authorization: authHeader
1631
1867
  }
1632
1868
  });
1633
- const streamStartTimes = /* @__PURE__ */ new Map();
1634
1869
  const forwarder = new StreamForwarder(ws, port, {
1635
- onOpen: (sid, method, path) => {
1636
- if (path === TUNNEL_DRAIN_PING_PATH) return;
1637
- streamStartTimes.set(sid, Date.now());
1638
- onRequest?.(method, path, sid);
1639
- },
1640
- onHead: (sid, status) => {
1641
- const startedAt = streamStartTimes.get(sid);
1642
- streamStartTimes.delete(sid);
1643
- onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1644
- },
1870
+ onHead: () => onResponse?.(),
1645
1871
  onDrainPing: () => onDrainPing?.()
1646
1872
  });
1647
1873
  const connectionTimeout = setTimeout(() => {
@@ -1717,7 +1943,6 @@ function connectTunnel(options) {
1717
1943
  ws.on("close", (code, reason) => {
1718
1944
  const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1719
1945
  forwarder.abortAll();
1720
- streamStartTimes.clear();
1721
1946
  onDisconnected?.(code, reasonStr);
1722
1947
  });
1723
1948
  });
@@ -1752,7 +1977,11 @@ var RunnerConnection = class {
1752
1977
  if (this.connection) {
1753
1978
  try {
1754
1979
  this.connection.close();
1755
- } catch {
1980
+ } catch (err) {
1981
+ log("error", "runner_connection_close_failed", {
1982
+ agent_id: this.resolvedAgentId,
1983
+ ...errorFields(err)
1984
+ });
1756
1985
  }
1757
1986
  this.connection = null;
1758
1987
  }
@@ -1811,6 +2040,17 @@ function messageIdOf(m) {
1811
2040
  const infoId = m.info?.id;
1812
2041
  return typeof infoId === "string" ? infoId : void 0;
1813
2042
  }
2043
+ function cleanImageMime(contentType) {
2044
+ if (!contentType) return null;
2045
+ const media = contentType.split(";")[0].trim().toLowerCase();
2046
+ return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
2047
+ }
2048
+ var LOG_LEVELS = {
2049
+ debug: 0,
2050
+ info: 1,
2051
+ warn: 2,
2052
+ error: 3
2053
+ };
1814
2054
  var DEFAULT_RETRY_POLICY = {
1815
2055
  maxAttempts: 6,
1816
2056
  baseDelayMs: 500,
@@ -1822,6 +2062,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
1822
2062
  var HEARTBEAT_MS = 6e4;
1823
2063
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
1824
2064
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2065
+ var MAX_SUPERSEDED_CONVERSATIONS = 256;
1825
2066
  var ChannelAuthError = class extends Error {
1826
2067
  constructor(message) {
1827
2068
  super(message);
@@ -1844,7 +2085,7 @@ function backoffDelay(attempt, policy) {
1844
2085
  function isRetryableStatus(status) {
1845
2086
  return status === 429 || status >= 500 && status <= 599;
1846
2087
  }
1847
- var ChannelDriver = class {
2088
+ var ChannelDriver = class _ChannelDriver {
1848
2089
  agentId;
1849
2090
  port;
1850
2091
  apiUrl;
@@ -1860,6 +2101,34 @@ var ChannelDriver = class {
1860
2101
  now;
1861
2102
  /** Cache of conversationId → opencode sessionId. */
1862
2103
  sessions = /* @__PURE__ */ new Map();
2104
+ /**
2105
+ * conversationId → the opencode session this runner has ABANDONED as that
2106
+ * conversation's binding (#553), after a genuine (`sessionExists === true`)
2107
+ * dispatch failure: the session still exists but is wedged, so #485's self-heal
2108
+ * must bind a fresh one.
2109
+ *
2110
+ * Dropping the local binding + clearing the server row is not enough on its own:
2111
+ * a SIBLING message dispatched earlier in the same drain is still in-flight under
2112
+ * the same session, and its watcher's routine status writes carry
2113
+ * `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
2114
+ * and `ensureSession`'s persisted-id fallback then reuses it, defeating the
2115
+ * self-heal. This map makes the runner authoritative instead of racing those
2116
+ * writes: *`ensureSession` never reuses an abandoned id for that conversation,
2117
+ * whatever the server row says* — which holds even when the resurrecting write
2118
+ * is one we deliberately keep (see `markDone`).
2119
+ *
2120
+ * Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
2121
+ * one conversation hold ONE entry (the newest abandonment replaces the older), and
2122
+ * hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
2123
+ * NEWEST abandoned id per conversation is guarded: after a second abandonment a
2124
+ * late sibling of the FIRST session can write that id back and `ensureSession`
2125
+ * will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
2126
+ * NOT dropped when the session's watcher tears down: `markDone` still writes the
2127
+ * abandoned id back (it must, or the reply is lost), so the guard has to outlive
2128
+ * the turn that resurrects it. In-memory only — a restart forgets it, at the same
2129
+ * bounded cost.
2130
+ */
2131
+ supersededSessions = /* @__PURE__ */ new Map();
1863
2132
  /**
1864
2133
  * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1865
2134
  * longer idempotent (no caller-supplied `messageID`), and its read-back picks
@@ -1941,6 +2210,15 @@ var ChannelDriver = class {
1941
2210
  * so the NEXT tick may retry exactly once more).
1942
2211
  */
1943
2212
  awaitingReadopt = /* @__PURE__ */ new Set();
2213
+ /**
2214
+ * "Already signalled `attachments_skipped` for this Evident message id" (#376).
2215
+ * The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
2216
+ * — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
2217
+ * next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
2218
+ * `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
2219
+ * outcome, not the dispatch. Not cleared (a message is signalled once for life).
2220
+ */
2221
+ attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
1944
2222
  /**
1945
2223
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1946
2224
  * first session creation so drain-created sessions are rooted at the project
@@ -1958,6 +2236,19 @@ var ChannelDriver = class {
1958
2236
  * entry = not yet resolved; `null` = resolved root (stop walking).
1959
2237
  */
1960
2238
  sessionParents = /* @__PURE__ */ new Map();
2239
+ /**
2240
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
2241
+ * NON-EMPTY, non-placeholder name is stored (terminal — a real session name
2242
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
2243
+ * excludes OpenCode's synchronous default title (see
2244
+ * `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
2245
+ * as an empty title so it never latches. A missing entry = not yet resolved OR
2246
+ * resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
2247
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
2248
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
2249
+ * no watcher) can resolve the title.
2250
+ */
2251
+ sessionTitles = /* @__PURE__ */ new Map();
1961
2252
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1962
2253
  draining = false;
1963
2254
  /**
@@ -1995,9 +2286,6 @@ var ChannelDriver = class {
1995
2286
  get opencodeBase() {
1996
2287
  return `http://127.0.0.1:${this.port}`;
1997
2288
  }
1998
- // -------------------------------------------------------------------------
1999
- // Public API
2000
- // -------------------------------------------------------------------------
2001
2289
  /**
2002
2290
  * Drain all pending channel conversations once: poll → dispatch → register.
2003
2291
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -2139,9 +2427,7 @@ var ChannelDriver = class {
2139
2427
  if (!stillLive) return;
2140
2428
  }
2141
2429
  }
2142
- // -------------------------------------------------------------------------
2143
2430
  // Conversation processing (WI-3 — async dispatch)
2144
- // -------------------------------------------------------------------------
2145
2431
  /**
2146
2432
  * Dispatch each pending message for a conversation to opencode's native queue
2147
2433
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -2151,10 +2437,15 @@ var ChannelDriver = class {
2151
2437
  * @returns the count of messages NEWLY dispatched (not already in-flight).
2152
2438
  */
2153
2439
  async processConversation(conv) {
2154
- const sessionId = await this.ensureSession(conv);
2440
+ const { sessionId, refusedSessionId } = await this.ensureSession(conv);
2155
2441
  const messages = await this.getPendingMessages(conv.id);
2156
2442
  let dispatched = 0;
2157
2443
  let skippedAlreadyDispatched = 0;
2444
+ if (refusedSessionId && messages.length > 0) {
2445
+ void this.postSignal(conv.id, messages[0].id, "session_superseded", {
2446
+ superseded_session_id: refusedSessionId
2447
+ });
2448
+ }
2158
2449
  for (const message of messages) {
2159
2450
  if (this.stopped) break;
2160
2451
  if (this.dispatched.has(message.id)) {
@@ -2173,36 +2464,62 @@ var ChannelDriver = class {
2173
2464
  conversation_id: conv.id,
2174
2465
  message_id: message.id
2175
2466
  });
2467
+ const sendAttachments = this.buildSendAttachments(conv, message);
2176
2468
  opencodeMessageId = await this.dispatchLocked(
2177
2469
  sessionId,
2178
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2470
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2179
2471
  );
2180
2472
  } catch (err) {
2181
2473
  if (err instanceof ChannelAuthError) throw err;
2182
2474
  this.dispatched.delete(message.id);
2183
- if (await sessionExists(this.port, sessionId) === false) {
2475
+ const exists = await sessionExists(this.port, sessionId);
2476
+ if (exists === false) {
2184
2477
  this.sessions.delete(conv.id);
2185
2478
  this.log({
2186
- level: "info",
2479
+ level: "warn",
2187
2480
  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.`,
2188
2481
  conversation_id: conv.id,
2189
2482
  message_id: message.id
2190
2483
  });
2191
2484
  break;
2192
2485
  }
2193
- await this.markFailed(conv.id, message.id).catch(() => {
2486
+ if (exists === null) {
2487
+ this.log({
2488
+ level: "warn",
2489
+ message: `Message ${message.id.slice(0, 8)} dispatch failed and session (${sessionId.slice(0, 8)}) existence could not be confirmed (opencode momentarily unreachable) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick rather than treating it as a genuine failure.`,
2490
+ conversation_id: conv.id,
2491
+ message_id: message.id
2492
+ });
2493
+ break;
2494
+ }
2495
+ const errorMessage = err instanceof Error ? err.message : String(err);
2496
+ this.sessions.delete(conv.id);
2497
+ this.supersede(conv.id, sessionId);
2498
+ this.log({
2499
+ level: "warn",
2500
+ message: `Abandoning OpenCode session ${sessionId.slice(0, 8)} as the binding for conversation ${conv.id.slice(0, 8)} (it exists but failed to run a turn) \u2014 a fresh session is created on the next tick, whatever the persisted binding says by then.`,
2501
+ conversation_id: conv.id,
2502
+ message_id: message.id
2503
+ });
2504
+ await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
2505
+ this.log({
2506
+ level: "warn",
2507
+ message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
2508
+ conversation_id: conv.id,
2509
+ message_id: message.id
2510
+ });
2194
2511
  });
2195
2512
  this.log({
2196
2513
  level: "error",
2197
- message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
2514
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
2198
2515
  conversation_id: conv.id,
2199
2516
  message_id: message.id
2200
2517
  });
2201
- continue;
2518
+ break;
2202
2519
  }
2203
2520
  if (opencodeMessageId === null) {
2204
2521
  this.log({
2205
- level: "error",
2522
+ level: "warn",
2206
2523
  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`,
2207
2524
  conversation_id: conv.id,
2208
2525
  message_id: message.id
@@ -2216,7 +2533,7 @@ var ChannelDriver = class {
2216
2533
  }
2217
2534
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2218
2535
  this.log({
2219
- level: "error",
2536
+ level: "warn",
2220
2537
  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).`,
2221
2538
  conversation_id: conv.id
2222
2539
  });
@@ -2224,23 +2541,57 @@ var ChannelDriver = class {
2224
2541
  this.ensureWatcherRunning(sessionId);
2225
2542
  return dispatched;
2226
2543
  }
2544
+ /**
2545
+ * Record that `sessionId` is no longer a valid binding for `conversationId`
2546
+ * (#553). Keyed by conversation and hard-capped, so it cannot grow with the
2547
+ * number of failures — see the `supersededSessions` field doc.
2548
+ */
2549
+ supersede(conversationId, sessionId) {
2550
+ this.supersededSessions.delete(conversationId);
2551
+ this.supersededSessions.set(conversationId, sessionId);
2552
+ while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
2553
+ const oldest = this.supersededSessions.keys().next().value;
2554
+ if (oldest === void 0) return;
2555
+ this.supersededSessions.delete(oldest);
2556
+ }
2557
+ }
2558
+ /** Whether `sessionId` is the session this conversation has abandoned (#553). */
2559
+ isSuperseded(conversationId, sessionId) {
2560
+ return this.supersededSessions.get(conversationId) === sessionId;
2561
+ }
2562
+ /**
2563
+ * Resolve the opencode session to run this conversation's turns in.
2564
+ *
2565
+ * `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
2566
+ * binding was an id this runner had abandoned, so a resurrection genuinely
2567
+ * happened and a fresh session was bound instead. The caller reports it.
2568
+ */
2227
2569
  async ensureSession(conv) {
2228
2570
  const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
2571
+ if (bound && this.isSuperseded(conv.id, bound)) {
2572
+ this.log({
2573
+ level: "warn",
2574
+ message: `OpenCode session ${bound.slice(0, 8)} was abandoned for conversation ${conv.id.slice(0, 8)} after a failed dispatch but is still bound to it (the persisted id was written back by a turn already in flight) \u2014 ignoring it and binding a fresh session.`,
2575
+ conversation_id: conv.id
2576
+ });
2577
+ this.sessions.delete(conv.id);
2578
+ return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
2579
+ }
2229
2580
  if (bound) {
2230
2581
  const exists = await sessionExists(this.port, bound);
2231
2582
  if (exists === false) {
2232
2583
  this.log({
2233
- level: "info",
2584
+ level: "debug",
2234
2585
  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.`,
2235
2586
  conversation_id: conv.id
2236
2587
  });
2237
2588
  this.sessions.delete(conv.id);
2238
- return this.createAndBindSession(conv.id);
2589
+ return { sessionId: await this.createAndBindSession(conv.id) };
2239
2590
  }
2240
2591
  this.sessions.set(conv.id, bound);
2241
- return bound;
2592
+ return { sessionId: bound };
2242
2593
  }
2243
- return this.createAndBindSession(conv.id);
2594
+ return { sessionId: await this.createAndBindSession(conv.id) };
2244
2595
  }
2245
2596
  /**
2246
2597
  * Create a fresh OpenCode session for a conversation, cache the binding, and
@@ -2265,15 +2616,13 @@ var ChannelDriver = class {
2265
2616
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2266
2617
  if (!this.opencodeDirectory) {
2267
2618
  this.log({
2268
- level: "info",
2619
+ level: "warn",
2269
2620
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2270
2621
  });
2271
2622
  }
2272
2623
  return this.opencodeDirectory;
2273
2624
  }
2274
- // -------------------------------------------------------------------------
2275
2625
  // Per-session watcher (WI-3)
2276
- // -------------------------------------------------------------------------
2277
2626
  /**
2278
2627
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2279
2628
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2293,6 +2642,130 @@ var ChannelDriver = class {
2293
2642
  );
2294
2643
  return run2;
2295
2644
  }
2645
+ // Inbound image attachments (#255, WI-8)
2646
+ /**
2647
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
2648
+ * `undefined` when the message has none (so a text-only turn is unchanged).
2649
+ *
2650
+ * The driver OWNS the two channel-facing concerns the session module cannot:
2651
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
2652
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
2653
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
2654
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
2655
+ * existing callback surface when any image was skipped/failed.
2656
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
2657
+ * reports outcomes back via `onOutcomes`.
2658
+ */
2659
+ buildSendAttachments(conv, message) {
2660
+ const refs = message.attachments;
2661
+ if (!refs || refs.length === 0) return void 0;
2662
+ return {
2663
+ inputs: refs.map((a, index) => ({
2664
+ index,
2665
+ mime: a.mime,
2666
+ ...a.filename ? { filename: a.filename } : {}
2667
+ })),
2668
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
2669
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
2670
+ };
2671
+ }
2672
+ /**
2673
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
2674
+ * (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
2675
+ * existing authenticated fetch, and base64-encode into a
2676
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
2677
+ *
2678
+ * The endpoint streams the source bytes verbatim (200), or returns 404
2679
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
2680
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
2681
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
2682
+ * A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
2683
+ * a Slack `files:read` scope problem via `files.info`) instead resolves the
2684
+ * `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
2685
+ * user to reconnect Slack instead of a generic "unavailable". Failures are
2686
+ * logged with context (no silent swallow).
2687
+ */
2688
+ async fetchAttachmentDataUrl(messageId, index, mime) {
2689
+ try {
2690
+ const res = await this.fetchImpl(
2691
+ `${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
2692
+ { headers: { Authorization: this.getAuthHeader() } }
2693
+ );
2694
+ if (!res.ok) {
2695
+ let reason;
2696
+ try {
2697
+ const body = await res.json();
2698
+ if (body && typeof body.reason === "string") reason = body.reason;
2699
+ } catch (parseErr) {
2700
+ this.log({
2701
+ level: "debug",
2702
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index}: error body was not JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}) \u2014 treating as a plain failure`,
2703
+ message_id: messageId
2704
+ });
2705
+ }
2706
+ if (reason === "needs_reauth") {
2707
+ this.log({
2708
+ level: "error",
2709
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 server confirmed a Slack reauth/scope problem \u2014 omitting this image (text turn proceeds)`,
2710
+ message_id: messageId
2711
+ });
2712
+ return { needsReauth: true };
2713
+ }
2714
+ this.log({
2715
+ level: "error",
2716
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
2717
+ message_id: messageId
2718
+ });
2719
+ return null;
2720
+ }
2721
+ const buf = await res.arrayBuffer();
2722
+ const base64 = Buffer.from(buf).toString("base64");
2723
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
2724
+ return `data:${dataMime};base64,${base64}`;
2725
+ } catch (err) {
2726
+ this.log({
2727
+ level: "error",
2728
+ 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)}`,
2729
+ message_id: messageId
2730
+ });
2731
+ return null;
2732
+ }
2733
+ }
2734
+ /**
2735
+ * On any skipped/failed image, post an in-thread note to Evident over the
2736
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
2737
+ * Evident routes the note to source via `conversation.deliver`.
2738
+ *
2739
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
2740
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
2741
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
2742
+ * reaches the channel.
2743
+ *
2744
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
2745
+ */
2746
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
2747
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
2748
+ const failed = outcomes.filter((o) => o.status === "failed").length;
2749
+ if (skipped === 0 && failed === 0) return;
2750
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
2751
+ this.attachmentsSkippedSignalled.add(messageId);
2752
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
2753
+ const failedReason = outcomes.some(
2754
+ (o) => o.status === "failed" && o.reason === "needs_reauth"
2755
+ ) ? "needs_reauth" : void 0;
2756
+ this.log({
2757
+ level: "info",
2758
+ 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`,
2759
+ conversation_id: conversationId,
2760
+ message_id: messageId
2761
+ });
2762
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
2763
+ skipped,
2764
+ failed,
2765
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {},
2766
+ ...failedReason ? { failed_reason: failedReason } : {}
2767
+ });
2768
+ }
2296
2769
  /** Register a freshly-dispatched message with its session's watcher state. */
2297
2770
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2298
2771
  let watcher = this.watchers.get(sessionId);
@@ -2530,18 +3003,20 @@ var ChannelDriver = class {
2530
3003
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2531
3004
  const awaitingHuman = observedOpen || latchedPaused;
2532
3005
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
3006
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2533
3007
  let claimed;
2534
3008
  try {
2535
3009
  claimed = await this.markProcessing(
2536
3010
  conv.id,
2537
3011
  inFlight.evidentMessageId,
2538
3012
  sessionId,
2539
- inFlight.opencodeMessageId
3013
+ inFlight.opencodeMessageId,
3014
+ title
2540
3015
  );
2541
3016
  } catch (err) {
2542
3017
  if (err instanceof ChannelAuthError) throw err;
2543
3018
  this.log({
2544
- level: "error",
3019
+ level: "warn",
2545
3020
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2546
3021
  conversation_id: conv.id,
2547
3022
  message_id: inFlight.evidentMessageId
@@ -2551,7 +3026,7 @@ var ChannelDriver = class {
2551
3026
  inFlight.started = true;
2552
3027
  if (!claimed) {
2553
3028
  this.log({
2554
- level: "info",
3029
+ level: "debug",
2555
3030
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2556
3031
  conversation_id: conv.id,
2557
3032
  message_id: inFlight.evidentMessageId
@@ -2567,18 +3042,22 @@ var ChannelDriver = class {
2567
3042
  conversation_id: conv.id,
2568
3043
  message_id: inFlight.evidentMessageId
2569
3044
  });
3045
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3046
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2570
3047
  try {
2571
3048
  await this.markDone(
2572
3049
  conv.id,
2573
3050
  inFlight.evidentMessageId,
2574
3051
  sessionId,
2575
- inFlight.opencodeMessageId
3052
+ inFlight.opencodeMessageId,
3053
+ title,
3054
+ usage
2576
3055
  );
2577
3056
  } catch (err) {
2578
3057
  if (err instanceof ChannelAuthError) throw err;
2579
3058
  if (err instanceof ChannelTerminalError) {
2580
3059
  this.log({
2581
- level: "error",
3060
+ level: "warn",
2582
3061
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2583
3062
  conversation_id: conv.id,
2584
3063
  message_id: inFlight.evidentMessageId
@@ -2588,7 +3067,7 @@ var ChannelDriver = class {
2588
3067
  }
2589
3068
  if (this.now() >= inFlight.deadline) {
2590
3069
  this.log({
2591
- level: "error",
3070
+ level: "warn",
2592
3071
  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)}`,
2593
3072
  conversation_id: conv.id,
2594
3073
  message_id: inFlight.evidentMessageId
@@ -2597,7 +3076,7 @@ var ChannelDriver = class {
2597
3076
  return;
2598
3077
  }
2599
3078
  this.log({
2600
- level: "error",
3079
+ level: "warn",
2601
3080
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2602
3081
  conversation_id: conv.id,
2603
3082
  message_id: inFlight.evidentMessageId
@@ -2619,13 +3098,14 @@ var ChannelDriver = class {
2619
3098
  conversation_id: conv.id,
2620
3099
  message_id: inFlight.evidentMessageId
2621
3100
  });
3101
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2622
3102
  try {
2623
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
3103
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
2624
3104
  } catch (err) {
2625
3105
  if (err instanceof ChannelAuthError) throw err;
2626
3106
  if (err instanceof ChannelTerminalError) {
2627
3107
  this.log({
2628
- level: "error",
3108
+ level: "warn",
2629
3109
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2630
3110
  conversation_id: conv.id,
2631
3111
  message_id: inFlight.evidentMessageId
@@ -2635,7 +3115,7 @@ var ChannelDriver = class {
2635
3115
  }
2636
3116
  if (this.now() >= inFlight.deadline) {
2637
3117
  this.log({
2638
- level: "error",
3118
+ level: "warn",
2639
3119
  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)}`,
2640
3120
  conversation_id: conv.id,
2641
3121
  message_id: inFlight.evidentMessageId
@@ -2644,7 +3124,7 @@ var ChannelDriver = class {
2644
3124
  return;
2645
3125
  }
2646
3126
  this.log({
2647
- level: "error",
3127
+ level: "warn",
2648
3128
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2649
3129
  conversation_id: conv.id,
2650
3130
  message_id: inFlight.evidentMessageId
@@ -2667,7 +3147,7 @@ var ChannelDriver = class {
2667
3147
  const activelyRunning = state === "running" && !awaitingHuman;
2668
3148
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2669
3149
  this.log({
2670
- level: "error",
3150
+ level: "warn",
2671
3151
  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`,
2672
3152
  conversation_id: conv.id,
2673
3153
  message_id: inFlight.evidentMessageId
@@ -2710,7 +3190,7 @@ var ChannelDriver = class {
2710
3190
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2711
3191
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2712
3192
  this.log({
2713
- level: "info",
3193
+ level: "debug",
2714
3194
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2715
3195
  conversation_id: conv.id,
2716
3196
  message_id: inFlight.evidentMessageId
@@ -2721,9 +3201,7 @@ var ChannelDriver = class {
2721
3201
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2722
3202
  }
2723
3203
  }
2724
- // -------------------------------------------------------------------------
2725
3204
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2726
- // -------------------------------------------------------------------------
2727
3205
  /**
2728
3206
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2729
3207
  *
@@ -2753,7 +3231,7 @@ var ChannelDriver = class {
2753
3231
  this.readoptPollUnresolvedSignalled.delete(id);
2754
3232
  if (cleared || clearedUndeliverable) {
2755
3233
  this.log({
2756
- level: "info",
3234
+ level: "debug",
2757
3235
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2758
3236
  message_id: id
2759
3237
  });
@@ -2766,7 +3244,7 @@ var ChannelDriver = class {
2766
3244
  for (const row of rows) {
2767
3245
  if (!row.opencode_session_id) {
2768
3246
  this.log({
2769
- level: "error",
3247
+ level: "warn",
2770
3248
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2771
3249
  conversation_id: row.conversation_id,
2772
3250
  message_id: row.id
@@ -2783,7 +3261,7 @@ var ChannelDriver = class {
2783
3261
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2784
3262
  if (!res.ok) {
2785
3263
  this.log({
2786
- level: "error",
3264
+ level: "warn",
2787
3265
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2788
3266
  });
2789
3267
  continue;
@@ -2791,7 +3269,7 @@ var ChannelDriver = class {
2791
3269
  const body = await res.json();
2792
3270
  if (!Array.isArray(body)) {
2793
3271
  this.log({
2794
- level: "error",
3272
+ level: "warn",
2795
3273
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2796
3274
  });
2797
3275
  continue;
@@ -2799,7 +3277,7 @@ var ChannelDriver = class {
2799
3277
  messages = body;
2800
3278
  } catch (err) {
2801
3279
  this.log({
2802
- level: "error",
3280
+ level: "warn",
2803
3281
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2804
3282
  });
2805
3283
  continue;
@@ -2832,7 +3310,7 @@ var ChannelDriver = class {
2832
3310
  async readoptOne(sessionId, row, messages, sessionOngoing) {
2833
3311
  if (this.isTracked(sessionId, row.id)) {
2834
3312
  this.log({
2835
- level: "info",
3313
+ level: "debug",
2836
3314
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2837
3315
  conversation_id: row.conversation_id,
2838
3316
  message_id: row.id
@@ -2844,7 +3322,7 @@ var ChannelDriver = class {
2844
3322
  if (state === "done") {
2845
3323
  if (this.doneUndeliverable.has(row.id)) {
2846
3324
  this.log({
2847
- level: "info",
3325
+ level: "debug",
2848
3326
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2849
3327
  conversation_id: row.conversation_id,
2850
3328
  message_id: row.id
@@ -2858,13 +3336,15 @@ var ChannelDriver = class {
2858
3336
  message_id: row.id
2859
3337
  });
2860
3338
  try {
2861
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3339
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3340
+ const usage = messageUsage(messages, ocId ?? "");
3341
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
2862
3342
  } catch (err) {
2863
3343
  if (err instanceof ChannelAuthError) throw err;
2864
3344
  if (err instanceof ChannelTerminalError) {
2865
3345
  this.doneUndeliverable.add(row.id);
2866
3346
  this.log({
2867
- level: "error",
3347
+ level: "warn",
2868
3348
  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}`,
2869
3349
  conversation_id: row.conversation_id,
2870
3350
  message_id: row.id
@@ -2873,7 +3353,7 @@ var ChannelDriver = class {
2873
3353
  return;
2874
3354
  }
2875
3355
  this.log({
2876
- level: "error",
3356
+ level: "warn",
2877
3357
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2878
3358
  conversation_id: row.conversation_id,
2879
3359
  message_id: row.id
@@ -2886,6 +3366,7 @@ var ChannelDriver = class {
2886
3366
  }
2887
3367
  if (state === "failed") {
2888
3368
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3369
+ const usage = messageUsage(messages, ocId ?? "");
2889
3370
  this.log({
2890
3371
  level: "error",
2891
3372
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -2893,13 +3374,13 @@ var ChannelDriver = class {
2893
3374
  message_id: row.id
2894
3375
  });
2895
3376
  try {
2896
- await this.markFailed(row.conversation_id, row.id, sessionId, error2);
3377
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
2897
3378
  } catch (err) {
2898
3379
  if (err instanceof ChannelAuthError) throw err;
2899
3380
  if (err instanceof ChannelTerminalError) {
2900
3381
  this.doneUndeliverable.add(row.id);
2901
3382
  this.log({
2902
- level: "error",
3383
+ level: "warn",
2903
3384
  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}`,
2904
3385
  conversation_id: row.conversation_id,
2905
3386
  message_id: row.id
@@ -2908,7 +3389,7 @@ var ChannelDriver = class {
2908
3389
  return;
2909
3390
  }
2910
3391
  this.log({
2911
- level: "error",
3392
+ level: "warn",
2912
3393
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2913
3394
  conversation_id: row.conversation_id,
2914
3395
  message_id: row.id
@@ -2921,7 +3402,7 @@ var ChannelDriver = class {
2921
3402
  }
2922
3403
  if (this.dontRedispatch.has(row.id)) {
2923
3404
  this.log({
2924
- level: "info",
3405
+ level: "debug",
2925
3406
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2926
3407
  conversation_id: row.conversation_id,
2927
3408
  message_id: row.id
@@ -2946,7 +3427,7 @@ var ChannelDriver = class {
2946
3427
  }
2947
3428
  if (ongoing === true) {
2948
3429
  this.log({
2949
- level: "info",
3430
+ level: "debug",
2950
3431
  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)`,
2951
3432
  conversation_id: row.conversation_id,
2952
3433
  message_id: row.id
@@ -2954,7 +3435,7 @@ var ChannelDriver = class {
2954
3435
  } else {
2955
3436
  if (shape === "b1") {
2956
3437
  this.log({
2957
- level: "info",
3438
+ level: "debug",
2958
3439
  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`,
2959
3440
  conversation_id: row.conversation_id,
2960
3441
  message_id: row.id
@@ -2966,7 +3447,7 @@ var ChannelDriver = class {
2966
3447
  return;
2967
3448
  }
2968
3449
  this.log({
2969
- level: "info",
3450
+ level: "debug",
2970
3451
  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`,
2971
3452
  conversation_id: row.conversation_id,
2972
3453
  message_id: row.id
@@ -2977,7 +3458,7 @@ var ChannelDriver = class {
2977
3458
  const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2978
3459
  if (descendantAlive === true) {
2979
3460
  this.log({
2980
- level: "info",
3461
+ level: "debug",
2981
3462
  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)`,
2982
3463
  conversation_id: row.conversation_id,
2983
3464
  message_id: row.id
@@ -3001,7 +3482,7 @@ var ChannelDriver = class {
3001
3482
  this.readopted.add(row.id);
3002
3483
  this.ensureWatcherRunning(sessionId);
3003
3484
  this.log({
3004
- level: "info",
3485
+ level: "debug",
3005
3486
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
3006
3487
  conversation_id: row.conversation_id,
3007
3488
  message_id: row.id
@@ -3035,7 +3516,7 @@ var ChannelDriver = class {
3035
3516
  async forceReadoptRun(sessionId, row) {
3036
3517
  if (this.stopped) {
3037
3518
  this.log({
3038
- level: "info",
3519
+ level: "debug",
3039
3520
  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`,
3040
3521
  conversation_id: row.conversation_id,
3041
3522
  message_id: row.id
@@ -3044,7 +3525,7 @@ var ChannelDriver = class {
3044
3525
  }
3045
3526
  if (this.awaitingReadopt.has(row.id)) {
3046
3527
  this.log({
3047
- level: "info",
3528
+ level: "debug",
3048
3529
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3049
3530
  conversation_id: row.conversation_id,
3050
3531
  message_id: row.id
@@ -3054,7 +3535,7 @@ var ChannelDriver = class {
3054
3535
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
3055
3536
  this.dontRedispatch.add(row.id);
3056
3537
  this.log({
3057
- level: "info",
3538
+ level: "debug",
3058
3539
  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)`,
3059
3540
  conversation_id: row.conversation_id,
3060
3541
  message_id: row.id
@@ -3073,17 +3554,20 @@ var ChannelDriver = class {
3073
3554
  message_id: row.id
3074
3555
  });
3075
3556
  this.awaitingReadopt.add(row.id);
3557
+ const readoptConv = this.convForRow(sessionId, row);
3558
+ const readoptMessage = this.queuedMessageForRow(row);
3559
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
3076
3560
  let ocId;
3077
3561
  try {
3078
3562
  ocId = await this.dispatchLocked(
3079
3563
  sessionId,
3080
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3564
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
3081
3565
  );
3082
3566
  } catch (err) {
3083
3567
  this.awaitingReadopt.delete(row.id);
3084
3568
  if (err instanceof ChannelAuthError) throw err;
3085
3569
  this.log({
3086
- level: "error",
3570
+ level: "warn",
3087
3571
  message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
3088
3572
  conversation_id: row.conversation_id,
3089
3573
  message_id: row.id
@@ -3094,7 +3578,7 @@ var ChannelDriver = class {
3094
3578
  if (ocId === null) {
3095
3579
  this.awaitingReadopt.delete(row.id);
3096
3580
  this.log({
3097
- level: "error",
3581
+ level: "warn",
3098
3582
  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`,
3099
3583
  conversation_id: row.conversation_id,
3100
3584
  message_id: row.id
@@ -3102,9 +3586,7 @@ var ChannelDriver = class {
3102
3586
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3103
3587
  return;
3104
3588
  }
3105
- const conv = this.convForRow(sessionId, row);
3106
- const message = this.queuedMessageForRow(row);
3107
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3589
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
3108
3590
  this.dispatched.add(row.id);
3109
3591
  this.readopted.add(row.id);
3110
3592
  this.awaitingReadopt.delete(row.id);
@@ -3158,7 +3640,8 @@ var ChannelDriver = class {
3158
3640
  opencode_agent: row.opencode_agent,
3159
3641
  opencode_model: row.opencode_model,
3160
3642
  source_message_id: row.source_message_id,
3161
- slack_user_id: row.slack_user_id
3643
+ slack_user_id: row.slack_user_id,
3644
+ attachments: row.attachments ?? null
3162
3645
  };
3163
3646
  }
3164
3647
  /**
@@ -3179,7 +3662,7 @@ var ChannelDriver = class {
3179
3662
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3180
3663
  this.dontRedispatch.add(evidentMessageId);
3181
3664
  this.log({
3182
- level: "info",
3665
+ level: "debug",
3183
3666
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3184
3667
  conversation_id: watcher.conv.id,
3185
3668
  message_id: evidentMessageId
@@ -3319,6 +3802,68 @@ var ChannelDriver = class {
3319
3802
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3320
3803
  return parent;
3321
3804
  }
3805
+ /**
3806
+ * OpenCode's synchronous default session title (e.g.
3807
+ * `"New session - 1737800000000"`), assigned immediately when a session is
3808
+ * created — before OpenCode's async LLM-based auto-titling later renames it
3809
+ * mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
3810
+ * timestamp suffix's exact format is deliberately NOT matched, since the prefix
3811
+ * alone is the stable, cheap signal and over-anchoring on the timestamp
3812
+ * representation risks silently breaking if OpenCode ever changes it. Accepted
3813
+ * trade-off: a genuine LLM-assigned title that happens to literally start with
3814
+ * this prefix would also fail to latch (see `resolveSessionTitle`) —
3815
+ * vanishingly unlikely in practice, and deliberately not engineered around.
3816
+ */
3817
+ static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
3818
+ /**
3819
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3820
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3821
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3822
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3823
+ * Best-effort:
3824
+ * - a resolved NON-EMPTY title that does NOT match
3825
+ * `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
3826
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3827
+ * - while the title is still absent, empty, or matches the OpenCode
3828
+ * placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
3829
+ * asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
3830
+ * the cache unresolved and re-fetch on the next need so a later call (e.g. at
3831
+ * `done`) picks up the name assigned in the meantime. Such a call returns
3832
+ * `null` (omit the title on THIS PATCH) without caching. If a session is
3833
+ * never renamed, the title is omitted forever rather than ever persisting
3834
+ * the placeholder as a last resort;
3835
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3836
+ * and returns `null` — it must NEVER throw or block completion.
3837
+ * A failure is logged with agent/session context (no silent catch).
3838
+ */
3839
+ async resolveSessionTitle(sessionId, conversationId) {
3840
+ const cached = this.sessionTitles.get(sessionId);
3841
+ if (cached != null) return cached;
3842
+ try {
3843
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3844
+ if (res.ok) {
3845
+ const body = await res.json();
3846
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3847
+ if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
3848
+ this.sessionTitles.set(sessionId, title);
3849
+ return title;
3850
+ }
3851
+ return null;
3852
+ }
3853
+ this.log({
3854
+ level: "debug",
3855
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3856
+ conversation_id: conversationId
3857
+ });
3858
+ } catch (err) {
3859
+ this.log({
3860
+ level: "debug",
3861
+ 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)}`,
3862
+ conversation_id: conversationId
3863
+ });
3864
+ }
3865
+ return null;
3866
+ }
3322
3867
  /**
3323
3868
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3324
3869
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -3362,7 +3907,7 @@ var ChannelDriver = class {
3362
3907
  const sessions = await listSessions(this.port);
3363
3908
  if (!sessions) {
3364
3909
  this.log({
3365
- level: "error",
3910
+ level: "warn",
3366
3911
  message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3367
3912
  });
3368
3913
  return null;
@@ -3446,12 +3991,10 @@ var ChannelDriver = class {
3446
3991
  }
3447
3992
  return inFlight.sort(byOldest)[0];
3448
3993
  }
3449
- // -------------------------------------------------------------------------
3450
3994
  // Evident API calls (combinedAuth thread routes)
3451
- // -------------------------------------------------------------------------
3452
3995
  async getPendingConversations() {
3453
3996
  const res = await this.fetchImpl(
3454
- `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
3997
+ `${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
3455
3998
  {
3456
3999
  headers: { Authorization: this.getAuthHeader() }
3457
4000
  }
@@ -3469,7 +4012,7 @@ var ChannelDriver = class {
3469
4012
  }
3470
4013
  async getPendingMessages(conversationId) {
3471
4014
  const res = await this.fetchImpl(
3472
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
4015
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
3473
4016
  { headers: { Authorization: this.getAuthHeader() } }
3474
4017
  );
3475
4018
  this.assertAuth(res, "fetching pending messages");
@@ -3493,7 +4036,7 @@ var ChannelDriver = class {
3493
4036
  */
3494
4037
  async getProcessingMessages() {
3495
4038
  const res = await this.fetchImpl(
3496
- `${this.apiUrl}/agents/${this.agentId}/conversations/processing`,
4039
+ `${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
3497
4040
  { headers: { Authorization: this.getAuthHeader() } }
3498
4041
  );
3499
4042
  this.assertAuth(res, "fetching processing messages");
@@ -3507,6 +4050,32 @@ var ChannelDriver = class {
3507
4050
  }
3508
4051
  return messages;
3509
4052
  }
4053
+ /**
4054
+ * The `opencode_session_id` fragment of a status PATCH body — `{}` when this
4055
+ * conversation has ABANDONED that session (#553). The field is optional
4056
+ * server-side and an absent one leaves the persisted binding untouched, so
4057
+ * omitting it is how a routine status write stops resurrecting it.
4058
+ *
4059
+ * ONLY for writes whose sole cost is a lost deep link. The `processing` notice
4060
+ * degrades to no "View in Evident" link (the reaction swap still fires) and the
4061
+ * turn-failure notice is built from the PATCH's own `error` text with a link off
4062
+ * the persisted row — neither loses content the user came for. `markDone`
4063
+ * deliberately does NOT use this helper: the server fetches the reply text
4064
+ * THROUGH the session id it is given, so suppressing there would replace the
4065
+ * agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
4066
+ * `ensureSession` guard, not this suppression, is what makes the self-heal
4067
+ * stick.
4068
+ */
4069
+ sessionIdBody(sessionId, conversationId, messageId, status) {
4070
+ if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
4071
+ this.log({
4072
+ level: "debug",
4073
+ message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
4074
+ conversation_id: conversationId,
4075
+ message_id: messageId
4076
+ });
4077
+ return {};
4078
+ }
3510
4079
  /**
3511
4080
  * EXISTING combinedAuth route — now fired by the watcher on queued→running
3512
4081
  * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
@@ -3528,16 +4097,17 @@ var ChannelDriver = class {
3528
4097
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3529
4098
  * retry vehicle for the swap-to-running.
3530
4099
  */
3531
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
4100
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3532
4101
  const res = await this.fetchImpl(
3533
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4102
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3534
4103
  {
3535
4104
  method: "PATCH",
3536
4105
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
3537
4106
  body: JSON.stringify({
3538
4107
  status: "processing",
3539
- opencode_session_id: sessionId,
3540
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
4108
+ ...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
4109
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
4110
+ ...title ? { title } : {}
3541
4111
  })
3542
4112
  }
3543
4113
  );
@@ -3576,16 +4146,23 @@ var ChannelDriver = class {
3576
4146
  * watcher retries next tick within the
3577
4147
  * deadline, Finding 4).
3578
4148
  */
3579
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
4149
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
3580
4150
  const res = await this.fetchImpl(
3581
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4151
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3582
4152
  {
3583
4153
  method: "PATCH",
3584
4154
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
3585
4155
  body: JSON.stringify({
3586
4156
  status: "done",
4157
+ // ALWAYS sent, even for a session this conversation has abandoned
4158
+ // (#553): the server reads the reply text back out of THIS session id
4159
+ // to deliver it. Omitting it would leave the user with "✅ Done!"
4160
+ // instead of the answer — a worse regression than the resurrection it
4161
+ // would prevent, which `ensureSession`'s guard handles anyway.
3587
4162
  opencode_session_id: sessionId,
3588
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
4163
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
4164
+ ...title ? { title } : {},
4165
+ ...usage ? usage : {}
3589
4166
  })
3590
4167
  }
3591
4168
  );
@@ -3598,19 +4175,29 @@ var ChannelDriver = class {
3598
4175
  }
3599
4176
  /**
3600
4177
  * Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
3601
- * when provided (issue #182): a bare `markFailed(conv, msg)` sends
3602
- * `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
3603
- * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3604
- * failure reason reaches the channel.
4178
+ * when provided (issue #182). Three states for `sessionId`:
4179
+ * - omitted (`undefined`) → don't send the field, leave the persisted
4180
+ * session untouched (unused today; kept for API symmetry).
4181
+ * - a real id (`string`) → send it, update the persisted session (the
4182
+ * turn-failure call sites: an errored OpenCode turn).
4183
+ * - explicit `null` → send it, CLEAR the persisted session (issue
4184
+ * #485's dispatch-handoff-failure call site: the session id still
4185
+ * exists but is wedged, so the next attempt must get a fresh one
4186
+ * instead of reusing it — see WI-1's server-side null-clearing PATCH).
3605
4187
  */
3606
- async markFailed(conversationId, messageId, sessionId, error2) {
4188
+ async markFailed(conversationId, messageId, sessionId, error2, usage) {
3607
4189
  const body = { status: "failed" };
3608
- if (sessionId !== void 0) body.opencode_session_id = sessionId;
4190
+ if (sessionId === null) {
4191
+ body.opencode_session_id = null;
4192
+ } else if (sessionId !== void 0) {
4193
+ Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
4194
+ }
3609
4195
  if (error2 !== void 0) body.error = error2;
4196
+ if (usage) Object.assign(body, usage);
3610
4197
  await this.callWithRetry(
3611
4198
  "marking message as failed",
3612
4199
  () => this.fetchImpl(
3613
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4200
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3614
4201
  {
3615
4202
  method: "PATCH",
3616
4203
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3637,7 +4224,7 @@ var ChannelDriver = class {
3637
4224
  async postSignal(conversationId, messageId, signal, extra) {
3638
4225
  try {
3639
4226
  const res = await this.fetchImpl(
3640
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
4227
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
3641
4228
  {
3642
4229
  method: "POST",
3643
4230
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3646,7 +4233,7 @@ var ChannelDriver = class {
3646
4233
  );
3647
4234
  if (!res.ok) {
3648
4235
  this.log({
3649
- level: "error",
4236
+ level: "warn",
3650
4237
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3651
4238
  conversation_id: conversationId,
3652
4239
  message_id: messageId
@@ -3656,7 +4243,7 @@ var ChannelDriver = class {
3656
4243
  return true;
3657
4244
  } catch (err) {
3658
4245
  this.log({
3659
- level: "error",
4246
+ level: "warn",
3660
4247
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3661
4248
  conversation_id: conversationId,
3662
4249
  message_id: messageId
@@ -3666,7 +4253,7 @@ var ChannelDriver = class {
3666
4253
  }
3667
4254
  async persistSession(conversationId, sessionId) {
3668
4255
  const res = await this.fetchImpl(
3669
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
4256
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
3670
4257
  {
3671
4258
  method: "PATCH",
3672
4259
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3692,7 +4279,7 @@ var ChannelDriver = class {
3692
4279
  await this.callWithRetry(
3693
4280
  "reporting interactive event",
3694
4281
  () => this.fetchImpl(
3695
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
4282
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
3696
4283
  {
3697
4284
  method: "POST",
3698
4285
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3718,9 +4305,7 @@ var ChannelDriver = class {
3718
4305
  return false;
3719
4306
  }
3720
4307
  }
3721
- // -------------------------------------------------------------------------
3722
4308
  // Retry wrapper
3723
- // -------------------------------------------------------------------------
3724
4309
  /**
3725
4310
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3726
4311
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3919,7 +4504,7 @@ async function resolveAgentIdFromKey(authHeader) {
3919
4504
  if (!response.ok) {
3920
4505
  const serverMessage = await readErrorMessage(response);
3921
4506
  return {
3922
- error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
4507
+ error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
3923
4508
  };
3924
4509
  }
3925
4510
  const data = await response.json();
@@ -3927,17 +4512,17 @@ async function resolveAgentIdFromKey(authHeader) {
3927
4512
  return { agent_id: data.agent_id };
3928
4513
  }
3929
4514
  return {
3930
- error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
4515
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
3931
4516
  };
3932
4517
  } catch (error2) {
3933
4518
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3934
- return { error: `Failed to resolve agent from key: ${message}` };
4519
+ return { error: `Failed to resolve runner from key: ${message}` };
3935
4520
  }
3936
4521
  }
3937
4522
  async function notifyAgentDisconnected(agentId, authHeader) {
3938
4523
  const apiUrl = getApiUrlConfig();
3939
4524
  try {
3940
- const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
4525
+ const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
3941
4526
  method: "POST",
3942
4527
  headers: { Authorization: authHeader }
3943
4528
  });
@@ -3956,7 +4541,7 @@ async function notifyAgentDisconnected(agentId, authHeader) {
3956
4541
  async function getAgentInfo(agentId, authHeader) {
3957
4542
  const apiUrl = getApiUrlConfig();
3958
4543
  try {
3959
- const response = await fetch(`${apiUrl}/agents/${agentId}`, {
4544
+ const response = await fetch(`${apiUrl}/runners/${agentId}`, {
3960
4545
  headers: { Authorization: authHeader }
3961
4546
  });
3962
4547
  if (response.status === 401) {
@@ -3967,12 +4552,12 @@ async function getAgentInfo(agentId, authHeader) {
3967
4552
  const serverMessage = await readErrorMessage(response);
3968
4553
  return {
3969
4554
  valid: false,
3970
- error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
4555
+ error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
3971
4556
  };
3972
4557
  }
3973
4558
  if (response.status === 404) {
3974
4559
  const serverMessage = await readErrorMessage(response);
3975
- return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
4560
+ return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
3976
4561
  }
3977
4562
  if (!response.ok) {
3978
4563
  const serverMessage = await readErrorMessage(response);
@@ -3985,13 +4570,13 @@ async function getAgentInfo(agentId, authHeader) {
3985
4570
  if (agent.agent_type !== "local") {
3986
4571
  return {
3987
4572
  valid: false,
3988
- error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
4573
+ error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
3989
4574
  };
3990
4575
  }
3991
4576
  return { valid: true, agent };
3992
4577
  } catch (error2) {
3993
4578
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3994
- return { valid: false, error: `Failed to validate agent: ${message}` };
4579
+ return { valid: false, error: `Failed to validate runner: ${message}` };
3995
4580
  }
3996
4581
  }
3997
4582
 
@@ -4000,23 +4585,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
4000
4585
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
4001
4586
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
4002
4587
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
4003
- function log2(state, message, isError = false) {
4588
+ function resolveLogLevel(options) {
4589
+ const accepted = Object.keys(LOG_LEVELS);
4590
+ const validate = (value, source) => {
4591
+ const normalized = value.trim().toLowerCase();
4592
+ if (!accepted.includes(normalized)) {
4593
+ throw new Error(
4594
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4595
+ );
4596
+ }
4597
+ return normalized;
4598
+ };
4599
+ if (options.logLevel !== void 0) {
4600
+ return validate(options.logLevel, " (--log-level)");
4601
+ }
4602
+ if (options.verbose) {
4603
+ return "debug";
4604
+ }
4605
+ const env = process.env.EVIDENT_LOG_LEVEL;
4606
+ if (env !== void 0 && env !== "") {
4607
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4608
+ }
4609
+ return "info";
4610
+ }
4611
+ function meetsThreshold(state, level) {
4612
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4613
+ }
4614
+ function log2(state, message, level = "info") {
4615
+ if (!meetsThreshold(state, level)) return;
4004
4616
  if (state.json) {
4005
4617
  console.log(
4006
4618
  JSON.stringify({
4007
4619
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4008
- level: isError ? "error" : "info",
4620
+ level,
4009
4621
  message
4010
4622
  })
4011
4623
  );
4012
4624
  } else if (!state.interactive) {
4013
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4625
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
4014
4626
  console.log(`${prefix} ${message}`);
4015
4627
  }
4016
4628
  }
4017
4629
  function logActivity(state, entry) {
4630
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4631
+ if (!meetsThreshold(state, level)) return;
4018
4632
  const fullEntry = {
4019
4633
  ...entry,
4634
+ level,
4020
4635
  timestamp: /* @__PURE__ */ new Date()
4021
4636
  };
4022
4637
  state.activityLog.push(fullEntry);
@@ -4025,9 +4640,9 @@ function logActivity(state, entry) {
4025
4640
  }
4026
4641
  if (!state.interactive) {
4027
4642
  if (entry.type === "error") {
4028
- log2(state, entry.error ?? "Unknown error", true);
4029
- } else if (entry.type === "info" && entry.message) {
4030
- log2(state, entry.message);
4643
+ log2(state, entry.error ?? "Unknown error", level);
4644
+ } else if (entry.message) {
4645
+ log2(state, entry.message, level);
4031
4646
  }
4032
4647
  }
4033
4648
  }
@@ -4226,7 +4841,7 @@ function scheduleSessionCleanup(state, driver, options) {
4226
4841
  process.env
4227
4842
  );
4228
4843
  for (const warning2 of config2.warnings) {
4229
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4844
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4230
4845
  }
4231
4846
  if (!config2.enabled) return;
4232
4847
  logActivity(state, {
@@ -4248,7 +4863,7 @@ async function notifyOffline(state) {
4248
4863
  }
4249
4864
  const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
4250
4865
  if (result.ok) {
4251
- log2(state, "Notified Evident the agent is going offline");
4866
+ log2(state, "Notified Evident the runner is going offline");
4252
4867
  } else {
4253
4868
  logActivity(state, {
4254
4869
  type: "error",
@@ -4298,14 +4913,29 @@ async function cleanup(state, opts = {}) {
4298
4913
  }
4299
4914
  async function run(options) {
4300
4915
  const interactive = isInteractive(options.json);
4916
+ let logLevel;
4917
+ try {
4918
+ logLevel = resolveLogLevel(options);
4919
+ } catch (error2) {
4920
+ const message = error2 instanceof Error ? error2.message : String(error2);
4921
+ if (options.json) {
4922
+ console.log(JSON.stringify({ status: "error", error: message }));
4923
+ } else {
4924
+ printError(message);
4925
+ }
4926
+ await shutdownTelemetry();
4927
+ process.exit(1);
4928
+ return;
4929
+ }
4301
4930
  const state = {
4302
- agentId: options.agent || "",
4931
+ agentId: options.runner || options.agent || "",
4303
4932
  agentName: null,
4304
4933
  port: options.port ?? 4096,
4305
4934
  conversationFilter: options.conversation ?? null,
4306
4935
  idleTimeout: options.idleTimeout ?? null,
4307
4936
  json: options.json ?? false,
4308
4937
  interactive,
4938
+ logLevel,
4309
4939
  connected: false,
4310
4940
  opencodeConnected: false,
4311
4941
  opencodeVersion: null,
@@ -4320,11 +4950,24 @@ async function run(options) {
4320
4950
  sessionCleanupTimers: [],
4321
4951
  authHeader: ""
4322
4952
  };
4953
+ if (!options.runner && options.agent) {
4954
+ telemetry.info(
4955
+ EventTypes.DEPRECATED_AGENT_FLAG_USED,
4956
+ "Deprecated --agent flag used instead of --runner",
4957
+ { command: "run" },
4958
+ state.agentId
4959
+ );
4960
+ const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
4961
+ log2(state, agentFlagNotice, "warn");
4962
+ if (state.interactive && !state.json) {
4963
+ logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
4964
+ }
4965
+ }
4323
4966
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4324
4967
  log2(
4325
4968
  state,
4326
- "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.",
4327
- false
4969
+ "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.",
4970
+ "warn"
4328
4971
  );
4329
4972
  }
4330
4973
  const handleSignal = async () => {
@@ -4348,7 +4991,9 @@ async function run(options) {
4348
4991
  if (!interactive) {
4349
4992
  printError("Authentication required");
4350
4993
  blank();
4351
- console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
4994
+ console.log(
4995
+ chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
4996
+ );
4352
4997
  console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
4353
4998
  blank();
4354
4999
  process.exit(1);
@@ -4362,26 +5007,51 @@ async function run(options) {
4362
5007
  );
4363
5008
  }
4364
5009
  state.authHeader = getAuthHeader(credentials2);
5010
+ if (credentials2.notice) {
5011
+ log2(state, credentials2.notice, "warn");
5012
+ if (state.interactive && !state.json) {
5013
+ logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
5014
+ }
5015
+ }
5016
+ if (credentials2.keySource === "agent_key") {
5017
+ telemetry.info(
5018
+ EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
5019
+ "Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
5020
+ { command: "run" },
5021
+ state.agentId
5022
+ );
5023
+ const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
5024
+ log2(state, agentKeyNotice, "warn");
5025
+ if (state.interactive && !state.json) {
5026
+ logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
5027
+ }
5028
+ }
4365
5029
  if (!state.agentId) {
4366
5030
  if (credentials2.authType === "agent_key") {
4367
5031
  const resolved = await resolveAgentIdFromKey(state.authHeader);
4368
5032
  if (resolved.agent_id) {
4369
5033
  state.agentId = resolved.agent_id;
4370
- log2(state, `Resolved agent ID from key: ${state.agentId}`);
5034
+ log2(state, `Resolved runner ID from key: ${state.agentId}`);
4371
5035
  if (state.interactive && !state.json) {
4372
5036
  logActivity(state, {
4373
5037
  type: "info",
4374
- message: `Agent ID resolved from key: ${state.agentId}`
5038
+ message: `Runner ID resolved from key: ${state.agentId}`
4375
5039
  });
4376
5040
  }
4377
5041
  } else {
4378
- printError(resolved.error || "Failed to resolve agent ID from key");
5042
+ printError(resolved.error || "Failed to resolve runner ID from key");
4379
5043
  process.exit(1);
4380
5044
  }
4381
5045
  } else {
4382
- printError("--agent is required when not using EVIDENT_AGENT_KEY");
5046
+ printError(
5047
+ "--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
5048
+ );
4383
5049
  blank();
4384
- console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
5050
+ console.log(
5051
+ chalk6.dim(
5052
+ "Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
5053
+ )
5054
+ );
4385
5055
  blank();
4386
5056
  process.exit(1);
4387
5057
  }
@@ -4403,7 +5073,7 @@ async function run(options) {
4403
5073
  console.log(chalk6.bold("Evident Run"));
4404
5074
  console.log(chalk6.dim("-".repeat(40)));
4405
5075
  }
4406
- const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
5076
+ const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
4407
5077
  let validation = await getAgentInfo(state.agentId, state.authHeader);
4408
5078
  if (!validation.valid && validation.authFailed && interactive) {
4409
5079
  spinner?.fail("Authentication failed");
@@ -4415,14 +5085,14 @@ async function run(options) {
4415
5085
  "Login successful! Retrying..."
4416
5086
  );
4417
5087
  state.authHeader = getAuthHeader(credentials2);
4418
- spinner?.start("Validating agent...");
5088
+ spinner?.start("Validating runner...");
4419
5089
  validation = await getAgentInfo(state.agentId, state.authHeader);
4420
5090
  }
4421
5091
  if (!validation.valid) {
4422
- spinner?.fail(`Agent validation failed: ${validation.error}`);
5092
+ spinner?.fail(`Runner validation failed: ${validation.error}`);
4423
5093
  throw new Error(validation.error);
4424
5094
  }
4425
- spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
5095
+ spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
4426
5096
  state.agentName = validation.agent.name;
4427
5097
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
4428
5098
  try {
@@ -4440,9 +5110,24 @@ async function run(options) {
4440
5110
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4441
5111
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4442
5112
  if (versionWarning) {
4443
- log2(state, versionWarning, false);
5113
+ log2(state, versionWarning, "warn");
5114
+ if (state.interactive && !state.json) {
5115
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
5116
+ }
5117
+ }
5118
+ const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
5119
+ if (noProviderWarning) {
5120
+ log2(state, noProviderWarning, "warn");
4444
5121
  if (state.interactive && !state.json) {
4445
- logActivity(state, { type: "info", message: versionWarning });
5122
+ logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
5123
+ blank();
5124
+ console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
5125
+ console.log(
5126
+ chalk6.dim(
5127
+ `Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
5128
+ )
5129
+ );
5130
+ blank();
4446
5131
  }
4447
5132
  }
4448
5133
  } catch (error2) {
@@ -4457,11 +5142,17 @@ async function run(options) {
4457
5142
  getAuthHeader: () => state.authHeader,
4458
5143
  conversationFilter: state.conversationFilter,
4459
5144
  stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
4460
- log: (entry) => logActivity(state, {
4461
- type: entry.level === "error" ? "error" : "info",
4462
- message: entry.message,
4463
- error: entry.level === "error" ? entry.message : void 0
4464
- })
5145
+ log: (entry) => (
5146
+ // Thread the driver's real level straight through so `debug`/`warn`
5147
+ // survive the sink filter (they no longer collapse to info). `type`
5148
+ // stays the coarse error/non-error split the activity log renders with.
5149
+ logActivity(state, {
5150
+ type: entry.level === "error" ? "error" : "info",
5151
+ level: entry.level,
5152
+ message: entry.message,
5153
+ error: entry.level === "error" ? entry.message : void 0
5154
+ })
5155
+ )
4465
5156
  });
4466
5157
  state.channelDriver = channelDriver;
4467
5158
  const connection = new RunnerConnection({
@@ -4475,7 +5166,7 @@ async function run(options) {
4475
5166
  state.agentId = agentId;
4476
5167
  logActivity(state, {
4477
5168
  type: "info",
4478
- message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
5169
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
4479
5170
  });
4480
5171
  emitAgentConnected(state.agentId, {
4481
5172
  port: state.port,
@@ -4590,7 +5281,7 @@ async function run(options) {
4590
5281
  }
4591
5282
  telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
4592
5283
  command: "run",
4593
- agentId: options.agent
5284
+ agentId: options.runner || options.agent
4594
5285
  });
4595
5286
  await shutdownTelemetry();
4596
5287
  process.exit(1);
@@ -4615,7 +5306,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4615
5306
  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);
4616
5307
  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 }));
4617
5308
  program.command("whoami").description("Show the currently logged in user").action(whoami);
4618
- program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
5309
+ 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(
5310
+ "--log-level <level>",
5311
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
5312
+ ).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(
4619
5313
  "--session-cleanup-max-age <duration>",
4620
5314
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4621
5315
  ).option(
@@ -4628,7 +5322,11 @@ program.command("run").description("Connect to Evident and process messages").op
4628
5322
  (options) => {
4629
5323
  run({
4630
5324
  agent: options.agent,
5325
+ runner: options.runner,
4631
5326
  port: parseInt(options.port, 10),
5327
+ // Raw string — validation/precedence is single-sourced in run.ts's
5328
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
5329
+ logLevel: options.logLevel,
4632
5330
  verbose: options.verbose,
4633
5331
  conversation: options.conversation,
4634
5332
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,