@evident-ai/cli 3.0.1-dev.39d9b5f → 3.0.1-dev.3bffad2

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
@@ -1078,6 +1078,12 @@ async function getSessionMessages(port, sessionId) {
1078
1078
  return null;
1079
1079
  }
1080
1080
  }
1081
+ function isSessionActivelyGenerating(messages) {
1082
+ if (!messages || messages.length === 0) return false;
1083
+ const last = messages[messages.length - 1];
1084
+ if (roleOf(last) !== "assistant") return false;
1085
+ return completedOf(last) == null;
1086
+ }
1081
1087
  function sessionLastActivityMs(session) {
1082
1088
  const candidates = [
1083
1089
  session.time?.updated,
@@ -1120,6 +1126,36 @@ async function sessionExists(port, id) {
1120
1126
  return null;
1121
1127
  }
1122
1128
  }
1129
+ async function getSessionStatuses(port) {
1130
+ try {
1131
+ const res = await fetch(`${opencodeBase(port)}/session/status`);
1132
+ if (!res.ok) {
1133
+ console.error(
1134
+ `[getSessionStatuses] GET /session/status returned HTTP ${res.status} (port ${port})`
1135
+ );
1136
+ return null;
1137
+ }
1138
+ const body = await res.json();
1139
+ if (body == null || typeof body !== "object" || Array.isArray(body)) {
1140
+ console.error(
1141
+ `[getSessionStatuses] GET /session/status body was not a plain object (port ${port})`
1142
+ );
1143
+ return null;
1144
+ }
1145
+ return body;
1146
+ } catch (err) {
1147
+ console.error(
1148
+ `[getSessionStatuses] GET /session/status failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1149
+ );
1150
+ return null;
1151
+ }
1152
+ }
1153
+ async function isSessionOngoing(port, id) {
1154
+ const map = await getSessionStatuses(port);
1155
+ if (map == null) return null;
1156
+ const entry = map[id];
1157
+ return entry != null && entry.type !== "idle";
1158
+ }
1123
1159
  async function createOpenCodeSession(port, directory) {
1124
1160
  const url = new URL(`${opencodeBase(port)}/session`);
1125
1161
  if (directory && directory.trim()) {
@@ -1137,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
1137
1173
  const data = await response.json();
1138
1174
  return data.id;
1139
1175
  }
1176
+ async function getModelAttachmentCapability(port, model) {
1177
+ try {
1178
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1179
+ if (!res.ok) {
1180
+ console.error(
1181
+ `[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
1182
+ );
1183
+ return null;
1184
+ }
1185
+ const body = await res.json();
1186
+ const providers = Array.isArray(body?.providers) ? body.providers : null;
1187
+ if (!providers) {
1188
+ console.error(
1189
+ `[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
1190
+ );
1191
+ return null;
1192
+ }
1193
+ const slash = model ? model.indexOf("/") : -1;
1194
+ const providerId = slash > 0 ? model.slice(0, slash) : void 0;
1195
+ let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
1196
+ const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
1197
+ let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
1198
+ if (!provider && !providerId) {
1199
+ const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
1200
+ if (defaultProviderIds.length === 1) {
1201
+ provider = providers.find((p) => p?.id === defaultProviderIds[0]);
1202
+ }
1203
+ }
1204
+ if (!provider || !provider.models) return null;
1205
+ if (!modelId && defaults2 && typeof provider.id === "string") {
1206
+ const def = defaults2[provider.id];
1207
+ if (typeof def === "string") modelId = def;
1208
+ }
1209
+ if (!modelId) {
1210
+ if (providerId) {
1211
+ const keys = Object.keys(provider.models);
1212
+ if (keys.length === 1) modelId = keys[0];
1213
+ }
1214
+ if (!modelId) return null;
1215
+ }
1216
+ const entry = provider.models[modelId];
1217
+ if (!entry || typeof entry !== "object") return null;
1218
+ return typeof entry.attachment === "boolean" ? entry.attachment : null;
1219
+ } catch (err) {
1220
+ console.error(
1221
+ `[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1222
+ );
1223
+ return null;
1224
+ }
1225
+ }
1226
+ async function buildFileParts(attachments, capable) {
1227
+ const outcomes = [];
1228
+ const parts = [];
1229
+ const capabilityUnknown = capable === null;
1230
+ if (capable !== true) {
1231
+ for (const a of attachments.inputs) {
1232
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
1233
+ }
1234
+ return { parts, outcomes, capabilityUnknown };
1235
+ }
1236
+ for (const a of attachments.inputs) {
1237
+ let dataUrl = null;
1238
+ try {
1239
+ dataUrl = await attachments.fetchDataUrl(a.index);
1240
+ } catch (err) {
1241
+ console.error(
1242
+ `[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
1243
+ );
1244
+ dataUrl = null;
1245
+ }
1246
+ if (dataUrl == null) {
1247
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
1248
+ continue;
1249
+ }
1250
+ parts.push({
1251
+ type: "file",
1252
+ mime: a.mime,
1253
+ url: dataUrl,
1254
+ ...a.filename ? { filename: a.filename } : {}
1255
+ });
1256
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
1257
+ }
1258
+ return { parts, outcomes, capabilityUnknown };
1259
+ }
1140
1260
  function messageText(m) {
1141
1261
  if (!m || !Array.isArray(m.parts)) return "";
1142
1262
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1143
1263
  }
1144
- async function sendPromptAsync(port, sessionId, content, options) {
1264
+ async function sendPromptAsync(port, sessionId, content, options, attachments) {
1145
1265
  const before = await getSessionMessages(port, sessionId);
1146
1266
  const knownUserIds = new Set(
1147
1267
  (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1148
1268
  );
1269
+ const parts = [{ type: "text", text: content }];
1270
+ let pendingOutcomes = null;
1271
+ if (attachments && attachments.inputs.length > 0) {
1272
+ const capable = await getModelAttachmentCapability(port, options?.model);
1273
+ const {
1274
+ parts: fileParts,
1275
+ outcomes,
1276
+ capabilityUnknown
1277
+ } = await buildFileParts(attachments, capable);
1278
+ parts.push(...fileParts);
1279
+ if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
1280
+ }
1149
1281
  const body = {
1150
- parts: [{ type: "text", text: content }]
1282
+ parts
1151
1283
  };
1152
1284
  if (options?.agent) {
1153
1285
  body.agent = options.agent;
@@ -1186,7 +1318,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
1186
1318
  best = { id, created };
1187
1319
  }
1188
1320
  }
1189
- if (best) return best.id;
1321
+ if (best) {
1322
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
1323
+ return best.id;
1324
+ }
1190
1325
  }
1191
1326
  if (attempt < READ_BACK_ATTEMPTS - 1) {
1192
1327
  await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
@@ -1246,6 +1381,11 @@ function messageRunState(messages, userMessageId) {
1246
1381
  if (isAssistantInFlight(reply)) return "running";
1247
1382
  return errorOf(reply) != null ? "failed" : "done";
1248
1383
  }
1384
+ function isPreamblePinnedRunning(messages, userMessageId) {
1385
+ if (messageRunState(messages, userMessageId) !== "running") return false;
1386
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1387
+ return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1388
+ }
1249
1389
  function messageError(messages, userMessageId) {
1250
1390
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1251
1391
  const error2 = errorOf(reply);
@@ -1770,6 +1910,17 @@ function messageIdOf(m) {
1770
1910
  const infoId = m.info?.id;
1771
1911
  return typeof infoId === "string" ? infoId : void 0;
1772
1912
  }
1913
+ function cleanImageMime(contentType) {
1914
+ if (!contentType) return null;
1915
+ const media = contentType.split(";")[0].trim().toLowerCase();
1916
+ return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
1917
+ }
1918
+ var LOG_LEVELS = {
1919
+ debug: 0,
1920
+ info: 1,
1921
+ warn: 2,
1922
+ error: 3
1923
+ };
1773
1924
  var DEFAULT_RETRY_POLICY = {
1774
1925
  maxAttempts: 6,
1775
1926
  baseDelayMs: 500,
@@ -1779,6 +1930,7 @@ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1779
1930
  var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1780
1931
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
1781
1932
  var HEARTBEAT_MS = 6e4;
1933
+ var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
1782
1934
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
1783
1935
  var ChannelAuthError = class extends Error {
1784
1936
  constructor(message) {
@@ -1876,6 +2028,15 @@ var ChannelDriver = class {
1876
2028
  * the row leaves the processing list, exactly like `dontRedispatch`.
1877
2029
  */
1878
2030
  doneUndeliverable = /* @__PURE__ */ new Set();
2031
+ /**
2032
+ * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
2033
+ * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
2034
+ * every ~2s drain until the status map becomes readable — but the server-visible
2035
+ * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
2036
+ * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
2037
+ * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
2038
+ */
2039
+ readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
1879
2040
  /**
1880
2041
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1881
2042
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -1890,6 +2051,15 @@ var ChannelDriver = class {
1890
2051
  * so the NEXT tick may retry exactly once more).
1891
2052
  */
1892
2053
  awaitingReadopt = /* @__PURE__ */ new Set();
2054
+ /**
2055
+ * "Already signalled `attachments_skipped` for this Evident message id" (#376).
2056
+ * The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
2057
+ * — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
2058
+ * next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
2059
+ * `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
2060
+ * outcome, not the dispatch. Not cleared (a message is signalled once for life).
2061
+ */
2062
+ attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
1893
2063
  /**
1894
2064
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1895
2065
  * first session creation so drain-created sessions are rooted at the project
@@ -1907,6 +2077,16 @@ var ChannelDriver = class {
1907
2077
  * entry = not yet resolved; `null` = resolved root (stop walking).
1908
2078
  */
1909
2079
  sessionParents = /* @__PURE__ */ new Map();
2080
+ /**
2081
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
2082
+ * NON-EMPTY name is stored (terminal — a real session name won't later un-name),
2083
+ * so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
2084
+ * resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
2085
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
2086
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
2087
+ * no watcher) can resolve the title.
2088
+ */
2089
+ sessionTitles = /* @__PURE__ */ new Map();
1910
2090
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1911
2091
  draining = false;
1912
2092
  /**
@@ -1944,9 +2124,6 @@ var ChannelDriver = class {
1944
2124
  get opencodeBase() {
1945
2125
  return `http://127.0.0.1:${this.port}`;
1946
2126
  }
1947
- // -------------------------------------------------------------------------
1948
- // Public API
1949
- // -------------------------------------------------------------------------
1950
2127
  /**
1951
2128
  * Drain all pending channel conversations once: poll → dispatch → register.
1952
2129
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -2088,9 +2265,7 @@ var ChannelDriver = class {
2088
2265
  if (!stillLive) return;
2089
2266
  }
2090
2267
  }
2091
- // -------------------------------------------------------------------------
2092
2268
  // Conversation processing (WI-3 — async dispatch)
2093
- // -------------------------------------------------------------------------
2094
2269
  /**
2095
2270
  * Dispatch each pending message for a conversation to opencode's native queue
2096
2271
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -2122,9 +2297,10 @@ var ChannelDriver = class {
2122
2297
  conversation_id: conv.id,
2123
2298
  message_id: message.id
2124
2299
  });
2300
+ const sendAttachments = this.buildSendAttachments(conv, message);
2125
2301
  opencodeMessageId = await this.dispatchLocked(
2126
2302
  sessionId,
2127
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2303
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2128
2304
  );
2129
2305
  } catch (err) {
2130
2306
  if (err instanceof ChannelAuthError) throw err;
@@ -2132,7 +2308,7 @@ var ChannelDriver = class {
2132
2308
  if (await sessionExists(this.port, sessionId) === false) {
2133
2309
  this.sessions.delete(conv.id);
2134
2310
  this.log({
2135
- level: "info",
2311
+ level: "warn",
2136
2312
  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.`,
2137
2313
  conversation_id: conv.id,
2138
2314
  message_id: message.id
@@ -2151,7 +2327,7 @@ var ChannelDriver = class {
2151
2327
  }
2152
2328
  if (opencodeMessageId === null) {
2153
2329
  this.log({
2154
- level: "error",
2330
+ level: "warn",
2155
2331
  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`,
2156
2332
  conversation_id: conv.id,
2157
2333
  message_id: message.id
@@ -2165,7 +2341,7 @@ var ChannelDriver = class {
2165
2341
  }
2166
2342
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2167
2343
  this.log({
2168
- level: "error",
2344
+ level: "warn",
2169
2345
  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).`,
2170
2346
  conversation_id: conv.id
2171
2347
  });
@@ -2179,7 +2355,7 @@ var ChannelDriver = class {
2179
2355
  const exists = await sessionExists(this.port, bound);
2180
2356
  if (exists === false) {
2181
2357
  this.log({
2182
- level: "info",
2358
+ level: "debug",
2183
2359
  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.`,
2184
2360
  conversation_id: conv.id
2185
2361
  });
@@ -2214,15 +2390,13 @@ var ChannelDriver = class {
2214
2390
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2215
2391
  if (!this.opencodeDirectory) {
2216
2392
  this.log({
2217
- level: "info",
2393
+ level: "warn",
2218
2394
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2219
2395
  });
2220
2396
  }
2221
2397
  return this.opencodeDirectory;
2222
2398
  }
2223
- // -------------------------------------------------------------------------
2224
2399
  // Per-session watcher (WI-3)
2225
- // -------------------------------------------------------------------------
2226
2400
  /**
2227
2401
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2228
2402
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2242,6 +2416,103 @@ var ChannelDriver = class {
2242
2416
  );
2243
2417
  return run2;
2244
2418
  }
2419
+ // Inbound image attachments (#255, WI-8)
2420
+ /**
2421
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
2422
+ * `undefined` when the message has none (so a text-only turn is unchanged).
2423
+ *
2424
+ * The driver OWNS the two channel-facing concerns the session module cannot:
2425
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
2426
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
2427
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
2428
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
2429
+ * existing callback surface when any image was skipped/failed.
2430
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
2431
+ * reports outcomes back via `onOutcomes`.
2432
+ */
2433
+ buildSendAttachments(conv, message) {
2434
+ const refs = message.attachments;
2435
+ if (!refs || refs.length === 0) return void 0;
2436
+ return {
2437
+ inputs: refs.map((a, index) => ({
2438
+ index,
2439
+ mime: a.mime,
2440
+ ...a.filename ? { filename: a.filename } : {}
2441
+ })),
2442
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
2443
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
2444
+ };
2445
+ }
2446
+ /**
2447
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
2448
+ * (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
2449
+ * existing authenticated fetch, and base64-encode into a
2450
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
2451
+ *
2452
+ * The endpoint streams the source bytes verbatim (200), or returns 404
2453
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
2454
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
2455
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
2456
+ * Failures are logged with context (no silent swallow).
2457
+ */
2458
+ async fetchAttachmentDataUrl(messageId, index, mime) {
2459
+ try {
2460
+ const res = await this.fetchImpl(
2461
+ `${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
2462
+ { headers: { Authorization: this.getAuthHeader() } }
2463
+ );
2464
+ if (!res.ok) {
2465
+ this.log({
2466
+ level: "error",
2467
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
2468
+ message_id: messageId
2469
+ });
2470
+ return null;
2471
+ }
2472
+ const buf = await res.arrayBuffer();
2473
+ const base64 = Buffer.from(buf).toString("base64");
2474
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
2475
+ return `data:${dataMime};base64,${base64}`;
2476
+ } catch (err) {
2477
+ this.log({
2478
+ level: "error",
2479
+ 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)}`,
2480
+ message_id: messageId
2481
+ });
2482
+ return null;
2483
+ }
2484
+ }
2485
+ /**
2486
+ * On any skipped/failed image, post an in-thread note to Evident over the
2487
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
2488
+ * Evident routes the note to source via `conversation.deliver`.
2489
+ *
2490
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
2491
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
2492
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
2493
+ * reaches the channel.
2494
+ *
2495
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
2496
+ */
2497
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
2498
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
2499
+ const failed = outcomes.filter((o) => o.status === "failed").length;
2500
+ if (skipped === 0 && failed === 0) return;
2501
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
2502
+ this.attachmentsSkippedSignalled.add(messageId);
2503
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
2504
+ this.log({
2505
+ level: "info",
2506
+ 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`,
2507
+ conversation_id: conversationId,
2508
+ message_id: messageId
2509
+ });
2510
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
2511
+ skipped,
2512
+ failed,
2513
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {}
2514
+ });
2515
+ }
2245
2516
  /** Register a freshly-dispatched message with its session's watcher state. */
2246
2517
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2247
2518
  let watcher = this.watchers.get(sessionId);
@@ -2263,6 +2534,7 @@ var ChannelDriver = class {
2263
2534
  opencodeMessageId,
2264
2535
  message,
2265
2536
  dispatchedAt: now,
2537
+ processingAnchorMs: now,
2266
2538
  deadline: now + this.pausedMaxWaitMs,
2267
2539
  started: false,
2268
2540
  done: false,
@@ -2321,6 +2593,10 @@ var ChannelDriver = class {
2321
2593
  opencodeMessageId,
2322
2594
  message,
2323
2595
  dispatchedAt: this.now(),
2596
+ // Anchor the absolute-age ceiling to the SERVER-SIDE `processed_at` (the same
2597
+ // value seeding `deadline`), NOT `dispatchedAt` — so a re-adopted zombie's age
2598
+ // reflects the real turn duration and the ceiling fires on the ORIGINAL turn.
2599
+ processingAnchorMs: processedAtMs,
2324
2600
  deadline: processedAtMs + this.pausedMaxWaitMs,
2325
2601
  // The server row is ALREADY `processing`; do not re-fire markProcessing.
2326
2602
  started: true,
@@ -2474,18 +2750,20 @@ var ChannelDriver = class {
2474
2750
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2475
2751
  const awaitingHuman = observedOpen || latchedPaused;
2476
2752
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2753
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2477
2754
  let claimed;
2478
2755
  try {
2479
2756
  claimed = await this.markProcessing(
2480
2757
  conv.id,
2481
2758
  inFlight.evidentMessageId,
2482
2759
  sessionId,
2483
- inFlight.opencodeMessageId
2760
+ inFlight.opencodeMessageId,
2761
+ title
2484
2762
  );
2485
2763
  } catch (err) {
2486
2764
  if (err instanceof ChannelAuthError) throw err;
2487
2765
  this.log({
2488
- level: "error",
2766
+ level: "warn",
2489
2767
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2490
2768
  conversation_id: conv.id,
2491
2769
  message_id: inFlight.evidentMessageId
@@ -2495,7 +2773,7 @@ var ChannelDriver = class {
2495
2773
  inFlight.started = true;
2496
2774
  if (!claimed) {
2497
2775
  this.log({
2498
- level: "info",
2776
+ level: "debug",
2499
2777
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2500
2778
  conversation_id: conv.id,
2501
2779
  message_id: inFlight.evidentMessageId
@@ -2511,18 +2789,20 @@ var ChannelDriver = class {
2511
2789
  conversation_id: conv.id,
2512
2790
  message_id: inFlight.evidentMessageId
2513
2791
  });
2792
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2514
2793
  try {
2515
2794
  await this.markDone(
2516
2795
  conv.id,
2517
2796
  inFlight.evidentMessageId,
2518
2797
  sessionId,
2519
- inFlight.opencodeMessageId
2798
+ inFlight.opencodeMessageId,
2799
+ title
2520
2800
  );
2521
2801
  } catch (err) {
2522
2802
  if (err instanceof ChannelAuthError) throw err;
2523
2803
  if (err instanceof ChannelTerminalError) {
2524
2804
  this.log({
2525
- level: "error",
2805
+ level: "warn",
2526
2806
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2527
2807
  conversation_id: conv.id,
2528
2808
  message_id: inFlight.evidentMessageId
@@ -2532,7 +2812,7 @@ var ChannelDriver = class {
2532
2812
  }
2533
2813
  if (this.now() >= inFlight.deadline) {
2534
2814
  this.log({
2535
- level: "error",
2815
+ level: "warn",
2536
2816
  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)}`,
2537
2817
  conversation_id: conv.id,
2538
2818
  message_id: inFlight.evidentMessageId
@@ -2541,7 +2821,7 @@ var ChannelDriver = class {
2541
2821
  return;
2542
2822
  }
2543
2823
  this.log({
2544
- level: "error",
2824
+ level: "warn",
2545
2825
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2546
2826
  conversation_id: conv.id,
2547
2827
  message_id: inFlight.evidentMessageId
@@ -2569,7 +2849,7 @@ var ChannelDriver = class {
2569
2849
  if (err instanceof ChannelAuthError) throw err;
2570
2850
  if (err instanceof ChannelTerminalError) {
2571
2851
  this.log({
2572
- level: "error",
2852
+ level: "warn",
2573
2853
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2574
2854
  conversation_id: conv.id,
2575
2855
  message_id: inFlight.evidentMessageId
@@ -2579,7 +2859,7 @@ var ChannelDriver = class {
2579
2859
  }
2580
2860
  if (this.now() >= inFlight.deadline) {
2581
2861
  this.log({
2582
- level: "error",
2862
+ level: "warn",
2583
2863
  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)}`,
2584
2864
  conversation_id: conv.id,
2585
2865
  message_id: inFlight.evidentMessageId
@@ -2588,7 +2868,7 @@ var ChannelDriver = class {
2588
2868
  return;
2589
2869
  }
2590
2870
  this.log({
2591
- level: "error",
2871
+ level: "warn",
2592
2872
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2593
2873
  conversation_id: conv.id,
2594
2874
  message_id: inFlight.evidentMessageId
@@ -2609,6 +2889,19 @@ var ChannelDriver = class {
2609
2889
  });
2610
2890
  }
2611
2891
  const activelyRunning = state === "running" && !awaitingHuman;
2892
+ if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2893
+ this.log({
2894
+ level: "warn",
2895
+ 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`,
2896
+ conversation_id: conv.id,
2897
+ message_id: inFlight.evidentMessageId
2898
+ });
2899
+ void this.postSignal(conv.id, inFlight.evidentMessageId, "gave_up", {
2900
+ watched_for_ms: this.now() - inFlight.processingAnchorMs
2901
+ });
2902
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
2903
+ return;
2904
+ }
2612
2905
  if (activelyRunning && !inFlight.awaitingHumanLatched && !inFlight.aliveInFlight && this.now() - inFlight.lastAliveAt >= HEARTBEAT_MS) {
2613
2906
  inFlight.aliveInFlight = true;
2614
2907
  void this.postSignal(conv.id, inFlight.evidentMessageId, "alive").then((ok) => {
@@ -2641,7 +2934,7 @@ var ChannelDriver = class {
2641
2934
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2642
2935
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2643
2936
  this.log({
2644
- level: "info",
2937
+ level: "debug",
2645
2938
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2646
2939
  conversation_id: conv.id,
2647
2940
  message_id: inFlight.evidentMessageId
@@ -2652,9 +2945,7 @@ var ChannelDriver = class {
2652
2945
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2653
2946
  }
2654
2947
  }
2655
- // -------------------------------------------------------------------------
2656
2948
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2657
- // -------------------------------------------------------------------------
2658
2949
  /**
2659
2950
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2660
2951
  *
@@ -2671,15 +2962,20 @@ var ChannelDriver = class {
2671
2962
  */
2672
2963
  async readoptProcessing() {
2673
2964
  const rows = await this.getProcessingMessages();
2674
- if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
2965
+ if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
2675
2966
  const stillProcessing = new Set(rows.map((r) => r.id));
2676
- for (const id of [...this.dontRedispatch, ...this.doneUndeliverable]) {
2967
+ for (const id of [
2968
+ ...this.dontRedispatch,
2969
+ ...this.doneUndeliverable,
2970
+ ...this.readoptPollUnresolvedSignalled
2971
+ ]) {
2677
2972
  if (!stillProcessing.has(id)) {
2678
2973
  const cleared = this.dontRedispatch.delete(id);
2679
2974
  const clearedUndeliverable = this.doneUndeliverable.delete(id);
2975
+ this.readoptPollUnresolvedSignalled.delete(id);
2680
2976
  if (cleared || clearedUndeliverable) {
2681
2977
  this.log({
2682
- level: "info",
2978
+ level: "debug",
2683
2979
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2684
2980
  message_id: id
2685
2981
  });
@@ -2692,7 +2988,7 @@ var ChannelDriver = class {
2692
2988
  for (const row of rows) {
2693
2989
  if (!row.opencode_session_id) {
2694
2990
  this.log({
2695
- level: "error",
2991
+ level: "warn",
2696
2992
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2697
2993
  conversation_id: row.conversation_id,
2698
2994
  message_id: row.id
@@ -2709,7 +3005,7 @@ var ChannelDriver = class {
2709
3005
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2710
3006
  if (!res.ok) {
2711
3007
  this.log({
2712
- level: "error",
3008
+ level: "warn",
2713
3009
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2714
3010
  });
2715
3011
  continue;
@@ -2717,7 +3013,7 @@ var ChannelDriver = class {
2717
3013
  const body = await res.json();
2718
3014
  if (!Array.isArray(body)) {
2719
3015
  this.log({
2720
- level: "error",
3016
+ level: "warn",
2721
3017
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2722
3018
  });
2723
3019
  continue;
@@ -2725,13 +3021,15 @@ var ChannelDriver = class {
2725
3021
  messages = body;
2726
3022
  } catch (err) {
2727
3023
  this.log({
2728
- level: "error",
3024
+ level: "warn",
2729
3025
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2730
3026
  });
2731
3027
  continue;
2732
3028
  }
3029
+ const anyUntracked = sessionRows.some((row) => !this.isTracked(sessionId, row.id));
3030
+ const sessionOngoing = anyUntracked ? await isSessionOngoing(this.port, sessionId) : null;
2733
3031
  for (const row of sessionRows) {
2734
- await this.readoptOne(sessionId, row, messages);
3032
+ await this.readoptOne(sessionId, row, messages, sessionOngoing);
2735
3033
  }
2736
3034
  }
2737
3035
  }
@@ -2753,10 +3051,10 @@ var ChannelDriver = class {
2753
3051
  *
2754
3052
  * Only `ChannelAuthError` propagates.
2755
3053
  */
2756
- async readoptOne(sessionId, row, messages) {
3054
+ async readoptOne(sessionId, row, messages, sessionOngoing) {
2757
3055
  if (this.isTracked(sessionId, row.id)) {
2758
3056
  this.log({
2759
- level: "info",
3057
+ level: "debug",
2760
3058
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2761
3059
  conversation_id: row.conversation_id,
2762
3060
  message_id: row.id
@@ -2768,7 +3066,7 @@ var ChannelDriver = class {
2768
3066
  if (state === "done") {
2769
3067
  if (this.doneUndeliverable.has(row.id)) {
2770
3068
  this.log({
2771
- level: "info",
3069
+ level: "debug",
2772
3070
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2773
3071
  conversation_id: row.conversation_id,
2774
3072
  message_id: row.id
@@ -2782,21 +3080,23 @@ var ChannelDriver = class {
2782
3080
  message_id: row.id
2783
3081
  });
2784
3082
  try {
2785
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3083
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3084
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
2786
3085
  } catch (err) {
2787
3086
  if (err instanceof ChannelAuthError) throw err;
2788
3087
  if (err instanceof ChannelTerminalError) {
2789
3088
  this.doneUndeliverable.add(row.id);
2790
3089
  this.log({
2791
- level: "error",
3090
+ level: "warn",
2792
3091
  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}`,
2793
3092
  conversation_id: row.conversation_id,
2794
3093
  message_id: row.id
2795
3094
  });
3095
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2796
3096
  return;
2797
3097
  }
2798
3098
  this.log({
2799
- level: "error",
3099
+ level: "warn",
2800
3100
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2801
3101
  conversation_id: row.conversation_id,
2802
3102
  message_id: row.id
@@ -2804,6 +3104,7 @@ var ChannelDriver = class {
2804
3104
  return;
2805
3105
  }
2806
3106
  this.dontRedispatch.delete(row.id);
3107
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
2807
3108
  return;
2808
3109
  }
2809
3110
  if (state === "failed") {
@@ -2821,15 +3122,16 @@ var ChannelDriver = class {
2821
3122
  if (err instanceof ChannelTerminalError) {
2822
3123
  this.doneUndeliverable.add(row.id);
2823
3124
  this.log({
2824
- level: "error",
3125
+ level: "warn",
2825
3126
  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}`,
2826
3127
  conversation_id: row.conversation_id,
2827
3128
  message_id: row.id
2828
3129
  });
3130
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2829
3131
  return;
2830
3132
  }
2831
3133
  this.log({
2832
- level: "error",
3134
+ level: "warn",
2833
3135
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2834
3136
  conversation_id: row.conversation_id,
2835
3137
  message_id: row.id
@@ -2837,17 +3139,83 @@ var ChannelDriver = class {
2837
3139
  return;
2838
3140
  }
2839
3141
  this.dontRedispatch.delete(row.id);
3142
+ void this.postSignal(row.conversation_id, row.id, "readopt_failed");
2840
3143
  return;
2841
3144
  }
2842
3145
  if (this.dontRedispatch.has(row.id)) {
2843
3146
  this.log({
2844
- level: "info",
3147
+ level: "debug",
2845
3148
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2846
3149
  conversation_id: row.conversation_id,
2847
3150
  message_id: row.id
2848
3151
  });
2849
3152
  return;
2850
3153
  }
3154
+ let statusReadableOngoing = null;
3155
+ if (state === "running" && ocId) {
3156
+ const reply = findLastAssistantReplyFor(messages, ocId);
3157
+ const shape = this.replyCompletionShape(reply);
3158
+ const ongoing = sessionOngoing;
3159
+ statusReadableOngoing = ongoing;
3160
+ if (ongoing === false) {
3161
+ this.log({
3162
+ level: "info",
3163
+ message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status (absent/idle) \u2014 re-dispatching from scratch (status-gated recovery)`,
3164
+ conversation_id: row.conversation_id,
3165
+ message_id: row.id
3166
+ });
3167
+ await this.forceReadoptRun(sessionId, row);
3168
+ return;
3169
+ }
3170
+ if (ongoing === true) {
3171
+ this.log({
3172
+ level: "debug",
3173
+ 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)`,
3174
+ conversation_id: row.conversation_id,
3175
+ message_id: row.id
3176
+ });
3177
+ } else {
3178
+ if (shape === "b1") {
3179
+ this.log({
3180
+ level: "debug",
3181
+ 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`,
3182
+ conversation_id: row.conversation_id,
3183
+ message_id: row.id
3184
+ });
3185
+ if (!this.readoptPollUnresolvedSignalled.has(row.id)) {
3186
+ this.readoptPollUnresolvedSignalled.add(row.id);
3187
+ void this.postSignal(row.conversation_id, row.id, "readopt_poll_unresolved");
3188
+ }
3189
+ return;
3190
+ }
3191
+ this.log({
3192
+ level: "debug",
3193
+ 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`,
3194
+ conversation_id: row.conversation_id,
3195
+ message_id: row.id
3196
+ });
3197
+ }
3198
+ }
3199
+ if (statusReadableOngoing === null && state === "running" && ocId && isPreamblePinnedRunning(messages, ocId)) {
3200
+ const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
3201
+ if (descendantAlive === true) {
3202
+ this.log({
3203
+ level: "debug",
3204
+ 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)`,
3205
+ conversation_id: row.conversation_id,
3206
+ message_id: row.id
3207
+ });
3208
+ } else {
3209
+ this.log({
3210
+ level: "info",
3211
+ message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned on recovery (root ${sessionId.slice(0, 8)}), no live descendant runner \u2014 re-dispatching from scratch${descendantAlive === null ? " (descendant liveness indeterminate; a restart guarantees no live runner, so this does NOT block the re-dispatch)" : ""}`,
3212
+ conversation_id: row.conversation_id,
3213
+ message_id: row.id
3214
+ });
3215
+ await this.forceReadoptRun(sessionId, row);
3216
+ return;
3217
+ }
3218
+ }
2851
3219
  if ((state === "running" || state === "queued") && ocId) {
2852
3220
  const conv = this.convForRow(sessionId, row);
2853
3221
  const message = this.queuedMessageForRow(row);
@@ -2856,11 +3224,12 @@ var ChannelDriver = class {
2856
3224
  this.readopted.add(row.id);
2857
3225
  this.ensureWatcherRunning(sessionId);
2858
3226
  this.log({
2859
- level: "info",
3227
+ level: "debug",
2860
3228
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2861
3229
  conversation_id: row.conversation_id,
2862
3230
  message_id: row.id
2863
3231
  });
3232
+ void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
2864
3233
  return;
2865
3234
  }
2866
3235
  await this.forceReadoptRun(sessionId, row);
@@ -2889,7 +3258,7 @@ var ChannelDriver = class {
2889
3258
  async forceReadoptRun(sessionId, row) {
2890
3259
  if (this.stopped) {
2891
3260
  this.log({
2892
- level: "info",
3261
+ level: "debug",
2893
3262
  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`,
2894
3263
  conversation_id: row.conversation_id,
2895
3264
  message_id: row.id
@@ -2898,7 +3267,7 @@ var ChannelDriver = class {
2898
3267
  }
2899
3268
  if (this.awaitingReadopt.has(row.id)) {
2900
3269
  this.log({
2901
- level: "info",
3270
+ level: "debug",
2902
3271
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
2903
3272
  conversation_id: row.conversation_id,
2904
3273
  message_id: row.id
@@ -2908,11 +3277,12 @@ var ChannelDriver = class {
2908
3277
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
2909
3278
  this.dontRedispatch.add(row.id);
2910
3279
  this.log({
2911
- level: "info",
3280
+ level: "debug",
2912
3281
  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)`,
2913
3282
  conversation_id: row.conversation_id,
2914
3283
  message_id: row.id
2915
3284
  });
3285
+ void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
2916
3286
  return;
2917
3287
  }
2918
3288
  const options = {
@@ -2926,40 +3296,44 @@ var ChannelDriver = class {
2926
3296
  message_id: row.id
2927
3297
  });
2928
3298
  this.awaitingReadopt.add(row.id);
3299
+ const readoptConv = this.convForRow(sessionId, row);
3300
+ const readoptMessage = this.queuedMessageForRow(row);
3301
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
2929
3302
  let ocId;
2930
3303
  try {
2931
3304
  ocId = await this.dispatchLocked(
2932
3305
  sessionId,
2933
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3306
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
2934
3307
  );
2935
3308
  } catch (err) {
2936
3309
  this.awaitingReadopt.delete(row.id);
2937
3310
  if (err instanceof ChannelAuthError) throw err;
2938
3311
  this.log({
2939
- level: "error",
3312
+ level: "warn",
2940
3313
  message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2941
3314
  conversation_id: row.conversation_id,
2942
3315
  message_id: row.id
2943
3316
  });
3317
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2944
3318
  return;
2945
3319
  }
2946
3320
  if (ocId === null) {
2947
3321
  this.awaitingReadopt.delete(row.id);
2948
3322
  this.log({
2949
- level: "error",
3323
+ level: "warn",
2950
3324
  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`,
2951
3325
  conversation_id: row.conversation_id,
2952
3326
  message_id: row.id
2953
3327
  });
3328
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
2954
3329
  return;
2955
3330
  }
2956
- const conv = this.convForRow(sessionId, row);
2957
- const message = this.queuedMessageForRow(row);
2958
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3331
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
2959
3332
  this.dispatched.add(row.id);
2960
3333
  this.readopted.add(row.id);
2961
3334
  this.awaitingReadopt.delete(row.id);
2962
3335
  this.ensureWatcherRunning(sessionId);
3336
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
2963
3337
  }
2964
3338
  /**
2965
3339
  * True if `evidentMessageId` is already being driven — either in the
@@ -3008,7 +3382,8 @@ var ChannelDriver = class {
3008
3382
  opencode_agent: row.opencode_agent,
3009
3383
  opencode_model: row.opencode_model,
3010
3384
  source_message_id: row.source_message_id,
3011
- slack_user_id: row.slack_user_id
3385
+ slack_user_id: row.slack_user_id,
3386
+ attachments: row.attachments ?? null
3012
3387
  };
3013
3388
  }
3014
3389
  /**
@@ -3029,7 +3404,7 @@ var ChannelDriver = class {
3029
3404
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3030
3405
  this.dontRedispatch.add(evidentMessageId);
3031
3406
  this.log({
3032
- level: "info",
3407
+ level: "debug",
3033
3408
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3034
3409
  conversation_id: watcher.conv.id,
3035
3410
  message_id: evidentMessageId
@@ -3169,6 +3544,128 @@ var ChannelDriver = class {
3169
3544
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3170
3545
  return parent;
3171
3546
  }
3547
+ /**
3548
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3549
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3550
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3551
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3552
+ * Best-effort:
3553
+ * - a resolved NON-EMPTY title is cached and terminal (a real session name
3554
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3555
+ * - while the title is still absent/empty we do NOT latch it — OpenCode names
3556
+ * sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
3557
+ * must leave the cache unresolved and re-fetch on the next need so a later
3558
+ * call (e.g. at `done`) picks up the name assigned in the meantime. Such a
3559
+ * call returns `null` (omit the title on THIS PATCH) without caching;
3560
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3561
+ * and returns `null` — it must NEVER throw or block completion.
3562
+ * A failure is logged with agent/session context (no silent catch).
3563
+ */
3564
+ async resolveSessionTitle(sessionId, conversationId) {
3565
+ const cached = this.sessionTitles.get(sessionId);
3566
+ if (cached != null) return cached;
3567
+ try {
3568
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3569
+ if (res.ok) {
3570
+ const body = await res.json();
3571
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3572
+ if (title.length > 0) {
3573
+ this.sessionTitles.set(sessionId, title);
3574
+ return title;
3575
+ }
3576
+ return null;
3577
+ }
3578
+ this.log({
3579
+ level: "debug",
3580
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3581
+ conversation_id: conversationId
3582
+ });
3583
+ } catch (err) {
3584
+ this.log({
3585
+ level: "debug",
3586
+ 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)}`,
3587
+ conversation_id: conversationId
3588
+ });
3589
+ }
3590
+ return null;
3591
+ }
3592
+ /**
3593
+ * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3594
+ * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
3595
+ *
3596
+ * The PRIMARY recovery trigger is "preamble-pinned on recovery ⇒ idle" — a
3597
+ * runner restart wipes OpenCode's in-memory `SessionStatus`/`Runner`, so a
3598
+ * completed `finish: "tool-calls"` root reply encountered during re-adoption is
3599
+ * idle by OpenCode's own definition and is re-dispatched. This method exists only
3600
+ * so the WI-3 caller can VETO that re-dispatch in the rare case a descendant is
3601
+ * provably in flight at the exact moment of recovery.
3602
+ *
3603
+ * "Alive" criterion (TIGHTENED): a descendant is alive only when it is PROVABLY,
3604
+ * ACTIVELY generating — its LAST message is an assistant still mid-generation
3605
+ * (`completed == null`, via `isSessionActivelyGenerating`). An
3606
+ * INCOMPLETE-BUT-NOT-GENERATING child — last message a user message, or a
3607
+ * completed `finish: "tool-calls"` step — is NOT alive after a restart (nothing
3608
+ * is generating once the runner is gone), so it does NOT veto. (This is
3609
+ * deliberately NOT `!isTurnComplete`, which also matches those dead-but-non-terminal
3610
+ * shapes and would falsely veto — re-hanging the very turn this path recovers.)
3611
+ *
3612
+ * Return contract (encoded so WI-3 need not re-derive it):
3613
+ * - `true` → a descendant is provably, actively generating (veto re-dispatch).
3614
+ * - `false` → descendants exist but none is actively generating (the restart
3615
+ * case), OR no descendant is found at all.
3616
+ * - `null` → liveness is INDETERMINATE (enumeration via `listSessions` failed).
3617
+ *
3618
+ * ⚠️ `null` (UNKNOWN) MUST NOT be treated as "alive": WI-3 treats `null` the same
3619
+ * as `false` and does NOT veto — a restart guarantees no live runner, so an
3620
+ * indeterminate cross-check almost always means "couldn't reach a child that no
3621
+ * longer exists". The inversion lives in the caller; this method just reports
3622
+ * true/false/null faithfully.
3623
+ *
3624
+ * VERIFY-BEFORE-DEPEND: we depend ONLY on (a) `parentID` from `GET /session/:id`
3625
+ * (already proven by the existing child-session interaction tests, via
3626
+ * `resolveSessionParent`/`sessionBelongsTo`) and (b) the child's own message-list
3627
+ * terminal state. We do NOT depend on any session-level `busy`/`idle` field —
3628
+ * there is none on `GET /session/:id`; OpenCode's busy state is in-memory
3629
+ * `SessionStatus` only.
3630
+ */
3631
+ async isAnyDescendantSessionAlive(rootSessionId) {
3632
+ const sessions = await listSessions(this.port);
3633
+ if (!sessions) {
3634
+ this.log({
3635
+ level: "warn",
3636
+ message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3637
+ });
3638
+ return null;
3639
+ }
3640
+ for (const candidate of sessions) {
3641
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
3642
+ if (!await this.sessionBelongsTo(candidate.id, rootSessionId)) continue;
3643
+ const childMsgs = await getSessionMessages(this.port, candidate.id);
3644
+ if (isSessionActivelyGenerating(childMsgs)) {
3645
+ return true;
3646
+ }
3647
+ }
3648
+ return false;
3649
+ }
3650
+ /**
3651
+ * Cheap decision-telemetry label for a running row's LAST correlated reply
3652
+ * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
3653
+ * - `b1` — the reply itself is still in flight (`time.completed == null`) —
3654
+ * the aborted-in-flight production bug after a restart.
3655
+ * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
3656
+ * (the sub-agent preamble — #253's shape).
3657
+ * - `other` — any other shape (defensive; a running row is normally b1 or b2).
3658
+ * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
3659
+ * shape) directly rather than re-importing the module-private `completedOf`/
3660
+ * `finishOf` — this is a display label only, not a correctness predicate.
3661
+ */
3662
+ replyCompletionShape(reply) {
3663
+ if (!reply) return "other";
3664
+ const completed = reply.info?.time?.completed ?? reply.time?.completed;
3665
+ if (completed == null) return "b1";
3666
+ const finish = reply.info?.finish ?? reply.finish;
3667
+ return finish === "tool-calls" ? "b2" : "other";
3668
+ }
3172
3669
  /**
3173
3670
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
3174
3671
  *
@@ -3219,9 +3716,7 @@ var ChannelDriver = class {
3219
3716
  }
3220
3717
  return inFlight.sort(byOldest)[0];
3221
3718
  }
3222
- // -------------------------------------------------------------------------
3223
3719
  // Evident API calls (combinedAuth thread routes)
3224
- // -------------------------------------------------------------------------
3225
3720
  async getPendingConversations() {
3226
3721
  const res = await this.fetchImpl(
3227
3722
  `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
@@ -3301,7 +3796,7 @@ var ChannelDriver = class {
3301
3796
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3302
3797
  * retry vehicle for the swap-to-running.
3303
3798
  */
3304
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
3799
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3305
3800
  const res = await this.fetchImpl(
3306
3801
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3307
3802
  {
@@ -3310,7 +3805,8 @@ var ChannelDriver = class {
3310
3805
  body: JSON.stringify({
3311
3806
  status: "processing",
3312
3807
  opencode_session_id: sessionId,
3313
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3808
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3809
+ ...title ? { title } : {}
3314
3810
  })
3315
3811
  }
3316
3812
  );
@@ -3349,7 +3845,7 @@ var ChannelDriver = class {
3349
3845
  * watcher retries next tick within the
3350
3846
  * deadline, Finding 4).
3351
3847
  */
3352
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
3848
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
3353
3849
  const res = await this.fetchImpl(
3354
3850
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3355
3851
  {
@@ -3358,7 +3854,8 @@ var ChannelDriver = class {
3358
3854
  body: JSON.stringify({
3359
3855
  status: "done",
3360
3856
  opencode_session_id: sessionId,
3361
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3857
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3858
+ ...title ? { title } : {}
3362
3859
  })
3363
3860
  }
3364
3861
  );
@@ -3419,7 +3916,7 @@ var ChannelDriver = class {
3419
3916
  );
3420
3917
  if (!res.ok) {
3421
3918
  this.log({
3422
- level: "error",
3919
+ level: "warn",
3423
3920
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3424
3921
  conversation_id: conversationId,
3425
3922
  message_id: messageId
@@ -3429,7 +3926,7 @@ var ChannelDriver = class {
3429
3926
  return true;
3430
3927
  } catch (err) {
3431
3928
  this.log({
3432
- level: "error",
3929
+ level: "warn",
3433
3930
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3434
3931
  conversation_id: conversationId,
3435
3932
  message_id: messageId
@@ -3491,9 +3988,7 @@ var ChannelDriver = class {
3491
3988
  return false;
3492
3989
  }
3493
3990
  }
3494
- // -------------------------------------------------------------------------
3495
3991
  // Retry wrapper
3496
- // -------------------------------------------------------------------------
3497
3992
  /**
3498
3993
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3499
3994
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3773,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
3773
4268
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3774
4269
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3775
4270
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
3776
- function log2(state, message, isError = false) {
4271
+ function resolveLogLevel(options) {
4272
+ const accepted = Object.keys(LOG_LEVELS);
4273
+ const validate = (value, source) => {
4274
+ const normalized = value.trim().toLowerCase();
4275
+ if (!accepted.includes(normalized)) {
4276
+ throw new Error(
4277
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4278
+ );
4279
+ }
4280
+ return normalized;
4281
+ };
4282
+ if (options.logLevel !== void 0) {
4283
+ return validate(options.logLevel, " (--log-level)");
4284
+ }
4285
+ if (options.verbose) {
4286
+ return "debug";
4287
+ }
4288
+ const env = process.env.EVIDENT_LOG_LEVEL;
4289
+ if (env !== void 0 && env !== "") {
4290
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4291
+ }
4292
+ return "info";
4293
+ }
4294
+ function meetsThreshold(state, level) {
4295
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4296
+ }
4297
+ function log2(state, message, level = "info") {
4298
+ if (!meetsThreshold(state, level)) return;
3777
4299
  if (state.json) {
3778
4300
  console.log(
3779
4301
  JSON.stringify({
3780
4302
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3781
- level: isError ? "error" : "info",
4303
+ level,
3782
4304
  message
3783
4305
  })
3784
4306
  );
3785
4307
  } else if (!state.interactive) {
3786
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4308
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
3787
4309
  console.log(`${prefix} ${message}`);
3788
4310
  }
3789
4311
  }
3790
4312
  function logActivity(state, entry) {
4313
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4314
+ if (!meetsThreshold(state, level)) return;
3791
4315
  const fullEntry = {
3792
4316
  ...entry,
4317
+ level,
3793
4318
  timestamp: /* @__PURE__ */ new Date()
3794
4319
  };
3795
4320
  state.activityLog.push(fullEntry);
@@ -3798,9 +4323,9 @@ function logActivity(state, entry) {
3798
4323
  }
3799
4324
  if (!state.interactive) {
3800
4325
  if (entry.type === "error") {
3801
- log2(state, entry.error ?? "Unknown error", true);
3802
- } else if (entry.type === "info" && entry.message) {
3803
- log2(state, entry.message);
4326
+ log2(state, entry.error ?? "Unknown error", level);
4327
+ } else if (entry.message) {
4328
+ log2(state, entry.message, level);
3804
4329
  }
3805
4330
  }
3806
4331
  }
@@ -3999,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
3999
4524
  process.env
4000
4525
  );
4001
4526
  for (const warning2 of config2.warnings) {
4002
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4527
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4003
4528
  }
4004
4529
  if (!config2.enabled) return;
4005
4530
  logActivity(state, {
@@ -4071,6 +4596,20 @@ async function cleanup(state, opts = {}) {
4071
4596
  }
4072
4597
  async function run(options) {
4073
4598
  const interactive = isInteractive(options.json);
4599
+ let logLevel;
4600
+ try {
4601
+ logLevel = resolveLogLevel(options);
4602
+ } catch (error2) {
4603
+ const message = error2 instanceof Error ? error2.message : String(error2);
4604
+ if (options.json) {
4605
+ console.log(JSON.stringify({ status: "error", error: message }));
4606
+ } else {
4607
+ printError(message);
4608
+ }
4609
+ await shutdownTelemetry();
4610
+ process.exit(1);
4611
+ return;
4612
+ }
4074
4613
  const state = {
4075
4614
  agentId: options.agent || "",
4076
4615
  agentName: null,
@@ -4079,6 +4618,7 @@ async function run(options) {
4079
4618
  idleTimeout: options.idleTimeout ?? null,
4080
4619
  json: options.json ?? false,
4081
4620
  interactive,
4621
+ logLevel,
4082
4622
  connected: false,
4083
4623
  opencodeConnected: false,
4084
4624
  opencodeVersion: null,
@@ -4096,8 +4636,8 @@ async function run(options) {
4096
4636
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4097
4637
  log2(
4098
4638
  state,
4099
- "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.",
4100
- false
4639
+ "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.",
4640
+ "warn"
4101
4641
  );
4102
4642
  }
4103
4643
  const handleSignal = async () => {
@@ -4213,9 +4753,9 @@ async function run(options) {
4213
4753
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4214
4754
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4215
4755
  if (versionWarning) {
4216
- log2(state, versionWarning, false);
4756
+ log2(state, versionWarning, "warn");
4217
4757
  if (state.interactive && !state.json) {
4218
- logActivity(state, { type: "info", message: versionWarning });
4758
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
4219
4759
  }
4220
4760
  }
4221
4761
  } catch (error2) {
@@ -4230,11 +4770,17 @@ async function run(options) {
4230
4770
  getAuthHeader: () => state.authHeader,
4231
4771
  conversationFilter: state.conversationFilter,
4232
4772
  stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
4233
- log: (entry) => logActivity(state, {
4234
- type: entry.level === "error" ? "error" : "info",
4235
- message: entry.message,
4236
- error: entry.level === "error" ? entry.message : void 0
4237
- })
4773
+ log: (entry) => (
4774
+ // Thread the driver's real level straight through so `debug`/`warn`
4775
+ // survive the sink filter (they no longer collapse to info). `type`
4776
+ // stays the coarse error/non-error split the activity log renders with.
4777
+ logActivity(state, {
4778
+ type: entry.level === "error" ? "error" : "info",
4779
+ level: entry.level,
4780
+ message: entry.message,
4781
+ error: entry.level === "error" ? entry.message : void 0
4782
+ })
4783
+ )
4238
4784
  });
4239
4785
  state.channelDriver = channelDriver;
4240
4786
  const connection = new RunnerConnection({
@@ -4388,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4388
4934
  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);
4389
4935
  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 }));
4390
4936
  program.command("whoami").description("Show the currently logged in user").action(whoami);
4391
- 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(
4937
+ 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(
4938
+ "--log-level <level>",
4939
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
4940
+ ).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(
4392
4941
  "--session-cleanup-max-age <duration>",
4393
4942
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4394
4943
  ).option(
@@ -4402,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
4402
4951
  run({
4403
4952
  agent: options.agent,
4404
4953
  port: parseInt(options.port, 10),
4954
+ // Raw string — validation/precedence is single-sourced in run.ts's
4955
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
4956
+ logLevel: options.logLevel,
4405
4957
  verbose: options.verbose,
4406
4958
  conversation: options.conversation,
4407
4959
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,