@evident-ai/cli 3.0.1-dev.4be1aa5 → 3.0.1-dev.4e02c98

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
@@ -1173,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
1173
1173
  const data = await response.json();
1174
1174
  return data.id;
1175
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
+ }
1176
1260
  function messageText(m) {
1177
1261
  if (!m || !Array.isArray(m.parts)) return "";
1178
1262
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1179
1263
  }
1180
- async function sendPromptAsync(port, sessionId, content, options) {
1264
+ async function sendPromptAsync(port, sessionId, content, options, attachments) {
1181
1265
  const before = await getSessionMessages(port, sessionId);
1182
1266
  const knownUserIds = new Set(
1183
1267
  (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1184
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
+ }
1185
1281
  const body = {
1186
- parts: [{ type: "text", text: content }]
1282
+ parts
1187
1283
  };
1188
1284
  if (options?.agent) {
1189
1285
  body.agent = options.agent;
@@ -1222,7 +1318,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
1222
1318
  best = { id, created };
1223
1319
  }
1224
1320
  }
1225
- if (best) return best.id;
1321
+ if (best) {
1322
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
1323
+ return best.id;
1324
+ }
1226
1325
  }
1227
1326
  if (attempt < READ_BACK_ATTEMPTS - 1) {
1228
1327
  await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
@@ -1811,6 +1910,17 @@ function messageIdOf(m) {
1811
1910
  const infoId = m.info?.id;
1812
1911
  return typeof infoId === "string" ? infoId : void 0;
1813
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
+ };
1814
1924
  var DEFAULT_RETRY_POLICY = {
1815
1925
  maxAttempts: 6,
1816
1926
  baseDelayMs: 500,
@@ -1918,6 +2028,15 @@ var ChannelDriver = class {
1918
2028
  * the row leaves the processing list, exactly like `dontRedispatch`.
1919
2029
  */
1920
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();
1921
2040
  /**
1922
2041
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
1923
2042
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -1932,6 +2051,15 @@ var ChannelDriver = class {
1932
2051
  * so the NEXT tick may retry exactly once more).
1933
2052
  */
1934
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();
1935
2063
  /**
1936
2064
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1937
2065
  * first session creation so drain-created sessions are rooted at the project
@@ -1949,6 +2077,16 @@ var ChannelDriver = class {
1949
2077
  * entry = not yet resolved; `null` = resolved root (stop walking).
1950
2078
  */
1951
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();
1952
2090
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1953
2091
  draining = false;
1954
2092
  /**
@@ -1986,9 +2124,6 @@ var ChannelDriver = class {
1986
2124
  get opencodeBase() {
1987
2125
  return `http://127.0.0.1:${this.port}`;
1988
2126
  }
1989
- // -------------------------------------------------------------------------
1990
- // Public API
1991
- // -------------------------------------------------------------------------
1992
2127
  /**
1993
2128
  * Drain all pending channel conversations once: poll → dispatch → register.
1994
2129
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -2130,9 +2265,7 @@ var ChannelDriver = class {
2130
2265
  if (!stillLive) return;
2131
2266
  }
2132
2267
  }
2133
- // -------------------------------------------------------------------------
2134
2268
  // Conversation processing (WI-3 — async dispatch)
2135
- // -------------------------------------------------------------------------
2136
2269
  /**
2137
2270
  * Dispatch each pending message for a conversation to opencode's native queue
2138
2271
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -2164,9 +2297,10 @@ var ChannelDriver = class {
2164
2297
  conversation_id: conv.id,
2165
2298
  message_id: message.id
2166
2299
  });
2300
+ const sendAttachments = this.buildSendAttachments(conv, message);
2167
2301
  opencodeMessageId = await this.dispatchLocked(
2168
2302
  sessionId,
2169
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2303
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2170
2304
  );
2171
2305
  } catch (err) {
2172
2306
  if (err instanceof ChannelAuthError) throw err;
@@ -2174,7 +2308,7 @@ var ChannelDriver = class {
2174
2308
  if (await sessionExists(this.port, sessionId) === false) {
2175
2309
  this.sessions.delete(conv.id);
2176
2310
  this.log({
2177
- level: "info",
2311
+ level: "warn",
2178
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.`,
2179
2313
  conversation_id: conv.id,
2180
2314
  message_id: message.id
@@ -2193,7 +2327,7 @@ var ChannelDriver = class {
2193
2327
  }
2194
2328
  if (opencodeMessageId === null) {
2195
2329
  this.log({
2196
- level: "error",
2330
+ level: "warn",
2197
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`,
2198
2332
  conversation_id: conv.id,
2199
2333
  message_id: message.id
@@ -2207,7 +2341,7 @@ var ChannelDriver = class {
2207
2341
  }
2208
2342
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2209
2343
  this.log({
2210
- level: "error",
2344
+ level: "warn",
2211
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).`,
2212
2346
  conversation_id: conv.id
2213
2347
  });
@@ -2221,7 +2355,7 @@ var ChannelDriver = class {
2221
2355
  const exists = await sessionExists(this.port, bound);
2222
2356
  if (exists === false) {
2223
2357
  this.log({
2224
- level: "info",
2358
+ level: "debug",
2225
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.`,
2226
2360
  conversation_id: conv.id
2227
2361
  });
@@ -2256,15 +2390,13 @@ var ChannelDriver = class {
2256
2390
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2257
2391
  if (!this.opencodeDirectory) {
2258
2392
  this.log({
2259
- level: "info",
2393
+ level: "warn",
2260
2394
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2261
2395
  });
2262
2396
  }
2263
2397
  return this.opencodeDirectory;
2264
2398
  }
2265
- // -------------------------------------------------------------------------
2266
2399
  // Per-session watcher (WI-3)
2267
- // -------------------------------------------------------------------------
2268
2400
  /**
2269
2401
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2270
2402
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2284,6 +2416,103 @@ var ChannelDriver = class {
2284
2416
  );
2285
2417
  return run2;
2286
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
+ }
2287
2516
  /** Register a freshly-dispatched message with its session's watcher state. */
2288
2517
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2289
2518
  let watcher = this.watchers.get(sessionId);
@@ -2521,18 +2750,20 @@ var ChannelDriver = class {
2521
2750
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2522
2751
  const awaitingHuman = observedOpen || latchedPaused;
2523
2752
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2753
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2524
2754
  let claimed;
2525
2755
  try {
2526
2756
  claimed = await this.markProcessing(
2527
2757
  conv.id,
2528
2758
  inFlight.evidentMessageId,
2529
2759
  sessionId,
2530
- inFlight.opencodeMessageId
2760
+ inFlight.opencodeMessageId,
2761
+ title
2531
2762
  );
2532
2763
  } catch (err) {
2533
2764
  if (err instanceof ChannelAuthError) throw err;
2534
2765
  this.log({
2535
- level: "error",
2766
+ level: "warn",
2536
2767
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2537
2768
  conversation_id: conv.id,
2538
2769
  message_id: inFlight.evidentMessageId
@@ -2542,7 +2773,7 @@ var ChannelDriver = class {
2542
2773
  inFlight.started = true;
2543
2774
  if (!claimed) {
2544
2775
  this.log({
2545
- level: "info",
2776
+ level: "debug",
2546
2777
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2547
2778
  conversation_id: conv.id,
2548
2779
  message_id: inFlight.evidentMessageId
@@ -2558,18 +2789,20 @@ var ChannelDriver = class {
2558
2789
  conversation_id: conv.id,
2559
2790
  message_id: inFlight.evidentMessageId
2560
2791
  });
2792
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2561
2793
  try {
2562
2794
  await this.markDone(
2563
2795
  conv.id,
2564
2796
  inFlight.evidentMessageId,
2565
2797
  sessionId,
2566
- inFlight.opencodeMessageId
2798
+ inFlight.opencodeMessageId,
2799
+ title
2567
2800
  );
2568
2801
  } catch (err) {
2569
2802
  if (err instanceof ChannelAuthError) throw err;
2570
2803
  if (err instanceof ChannelTerminalError) {
2571
2804
  this.log({
2572
- level: "error",
2805
+ level: "warn",
2573
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}`,
2574
2807
  conversation_id: conv.id,
2575
2808
  message_id: inFlight.evidentMessageId
@@ -2579,7 +2812,7 @@ var ChannelDriver = class {
2579
2812
  }
2580
2813
  if (this.now() >= inFlight.deadline) {
2581
2814
  this.log({
2582
- level: "error",
2815
+ level: "warn",
2583
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)}`,
2584
2817
  conversation_id: conv.id,
2585
2818
  message_id: inFlight.evidentMessageId
@@ -2588,7 +2821,7 @@ var ChannelDriver = class {
2588
2821
  return;
2589
2822
  }
2590
2823
  this.log({
2591
- level: "error",
2824
+ level: "warn",
2592
2825
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2593
2826
  conversation_id: conv.id,
2594
2827
  message_id: inFlight.evidentMessageId
@@ -2616,7 +2849,7 @@ var ChannelDriver = class {
2616
2849
  if (err instanceof ChannelAuthError) throw err;
2617
2850
  if (err instanceof ChannelTerminalError) {
2618
2851
  this.log({
2619
- level: "error",
2852
+ level: "warn",
2620
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}`,
2621
2854
  conversation_id: conv.id,
2622
2855
  message_id: inFlight.evidentMessageId
@@ -2626,7 +2859,7 @@ var ChannelDriver = class {
2626
2859
  }
2627
2860
  if (this.now() >= inFlight.deadline) {
2628
2861
  this.log({
2629
- level: "error",
2862
+ level: "warn",
2630
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)}`,
2631
2864
  conversation_id: conv.id,
2632
2865
  message_id: inFlight.evidentMessageId
@@ -2635,7 +2868,7 @@ var ChannelDriver = class {
2635
2868
  return;
2636
2869
  }
2637
2870
  this.log({
2638
- level: "error",
2871
+ level: "warn",
2639
2872
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2640
2873
  conversation_id: conv.id,
2641
2874
  message_id: inFlight.evidentMessageId
@@ -2658,7 +2891,7 @@ var ChannelDriver = class {
2658
2891
  const activelyRunning = state === "running" && !awaitingHuman;
2659
2892
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2660
2893
  this.log({
2661
- level: "error",
2894
+ level: "warn",
2662
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`,
2663
2896
  conversation_id: conv.id,
2664
2897
  message_id: inFlight.evidentMessageId
@@ -2701,7 +2934,7 @@ var ChannelDriver = class {
2701
2934
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2702
2935
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2703
2936
  this.log({
2704
- level: "info",
2937
+ level: "debug",
2705
2938
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2706
2939
  conversation_id: conv.id,
2707
2940
  message_id: inFlight.evidentMessageId
@@ -2712,9 +2945,7 @@ var ChannelDriver = class {
2712
2945
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2713
2946
  }
2714
2947
  }
2715
- // -------------------------------------------------------------------------
2716
2948
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2717
- // -------------------------------------------------------------------------
2718
2949
  /**
2719
2950
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2720
2951
  *
@@ -2731,15 +2962,20 @@ var ChannelDriver = class {
2731
2962
  */
2732
2963
  async readoptProcessing() {
2733
2964
  const rows = await this.getProcessingMessages();
2734
- if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0) {
2965
+ if (this.dontRedispatch.size > 0 || this.doneUndeliverable.size > 0 || this.readoptPollUnresolvedSignalled.size > 0) {
2735
2966
  const stillProcessing = new Set(rows.map((r) => r.id));
2736
- for (const id of [...this.dontRedispatch, ...this.doneUndeliverable]) {
2967
+ for (const id of [
2968
+ ...this.dontRedispatch,
2969
+ ...this.doneUndeliverable,
2970
+ ...this.readoptPollUnresolvedSignalled
2971
+ ]) {
2737
2972
  if (!stillProcessing.has(id)) {
2738
2973
  const cleared = this.dontRedispatch.delete(id);
2739
2974
  const clearedUndeliverable = this.doneUndeliverable.delete(id);
2975
+ this.readoptPollUnresolvedSignalled.delete(id);
2740
2976
  if (cleared || clearedUndeliverable) {
2741
2977
  this.log({
2742
- level: "info",
2978
+ level: "debug",
2743
2979
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2744
2980
  message_id: id
2745
2981
  });
@@ -2752,7 +2988,7 @@ var ChannelDriver = class {
2752
2988
  for (const row of rows) {
2753
2989
  if (!row.opencode_session_id) {
2754
2990
  this.log({
2755
- level: "error",
2991
+ level: "warn",
2756
2992
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2757
2993
  conversation_id: row.conversation_id,
2758
2994
  message_id: row.id
@@ -2769,7 +3005,7 @@ var ChannelDriver = class {
2769
3005
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2770
3006
  if (!res.ok) {
2771
3007
  this.log({
2772
- level: "error",
3008
+ level: "warn",
2773
3009
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2774
3010
  });
2775
3011
  continue;
@@ -2777,7 +3013,7 @@ var ChannelDriver = class {
2777
3013
  const body = await res.json();
2778
3014
  if (!Array.isArray(body)) {
2779
3015
  this.log({
2780
- level: "error",
3016
+ level: "warn",
2781
3017
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2782
3018
  });
2783
3019
  continue;
@@ -2785,7 +3021,7 @@ var ChannelDriver = class {
2785
3021
  messages = body;
2786
3022
  } catch (err) {
2787
3023
  this.log({
2788
- level: "error",
3024
+ level: "warn",
2789
3025
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2790
3026
  });
2791
3027
  continue;
@@ -2818,7 +3054,7 @@ var ChannelDriver = class {
2818
3054
  async readoptOne(sessionId, row, messages, sessionOngoing) {
2819
3055
  if (this.isTracked(sessionId, row.id)) {
2820
3056
  this.log({
2821
- level: "info",
3057
+ level: "debug",
2822
3058
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2823
3059
  conversation_id: row.conversation_id,
2824
3060
  message_id: row.id
@@ -2830,7 +3066,7 @@ var ChannelDriver = class {
2830
3066
  if (state === "done") {
2831
3067
  if (this.doneUndeliverable.has(row.id)) {
2832
3068
  this.log({
2833
- level: "info",
3069
+ level: "debug",
2834
3070
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2835
3071
  conversation_id: row.conversation_id,
2836
3072
  message_id: row.id
@@ -2844,21 +3080,23 @@ var ChannelDriver = class {
2844
3080
  message_id: row.id
2845
3081
  });
2846
3082
  try {
2847
- 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);
2848
3085
  } catch (err) {
2849
3086
  if (err instanceof ChannelAuthError) throw err;
2850
3087
  if (err instanceof ChannelTerminalError) {
2851
3088
  this.doneUndeliverable.add(row.id);
2852
3089
  this.log({
2853
- level: "error",
3090
+ level: "warn",
2854
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}`,
2855
3092
  conversation_id: row.conversation_id,
2856
3093
  message_id: row.id
2857
3094
  });
3095
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2858
3096
  return;
2859
3097
  }
2860
3098
  this.log({
2861
- level: "error",
3099
+ level: "warn",
2862
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)}`,
2863
3101
  conversation_id: row.conversation_id,
2864
3102
  message_id: row.id
@@ -2866,6 +3104,7 @@ var ChannelDriver = class {
2866
3104
  return;
2867
3105
  }
2868
3106
  this.dontRedispatch.delete(row.id);
3107
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
2869
3108
  return;
2870
3109
  }
2871
3110
  if (state === "failed") {
@@ -2883,15 +3122,16 @@ var ChannelDriver = class {
2883
3122
  if (err instanceof ChannelTerminalError) {
2884
3123
  this.doneUndeliverable.add(row.id);
2885
3124
  this.log({
2886
- level: "error",
3125
+ level: "warn",
2887
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}`,
2888
3127
  conversation_id: row.conversation_id,
2889
3128
  message_id: row.id
2890
3129
  });
3130
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
2891
3131
  return;
2892
3132
  }
2893
3133
  this.log({
2894
- level: "error",
3134
+ level: "warn",
2895
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)}`,
2896
3136
  conversation_id: row.conversation_id,
2897
3137
  message_id: row.id
@@ -2899,11 +3139,12 @@ var ChannelDriver = class {
2899
3139
  return;
2900
3140
  }
2901
3141
  this.dontRedispatch.delete(row.id);
3142
+ void this.postSignal(row.conversation_id, row.id, "readopt_failed");
2902
3143
  return;
2903
3144
  }
2904
3145
  if (this.dontRedispatch.has(row.id)) {
2905
3146
  this.log({
2906
- level: "info",
3147
+ level: "debug",
2907
3148
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2908
3149
  conversation_id: row.conversation_id,
2909
3150
  message_id: row.id
@@ -2928,7 +3169,7 @@ var ChannelDriver = class {
2928
3169
  }
2929
3170
  if (ongoing === true) {
2930
3171
  this.log({
2931
- level: "info",
3172
+ level: "debug",
2932
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)`,
2933
3174
  conversation_id: row.conversation_id,
2934
3175
  message_id: row.id
@@ -2936,15 +3177,19 @@ var ChannelDriver = class {
2936
3177
  } else {
2937
3178
  if (shape === "b1") {
2938
3179
  this.log({
2939
- level: "info",
3180
+ level: "debug",
2940
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`,
2941
3182
  conversation_id: row.conversation_id,
2942
3183
  message_id: row.id
2943
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
+ }
2944
3189
  return;
2945
3190
  }
2946
3191
  this.log({
2947
- level: "info",
3192
+ level: "debug",
2948
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`,
2949
3194
  conversation_id: row.conversation_id,
2950
3195
  message_id: row.id
@@ -2955,7 +3200,7 @@ var ChannelDriver = class {
2955
3200
  const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2956
3201
  if (descendantAlive === true) {
2957
3202
  this.log({
2958
- level: "info",
3203
+ level: "debug",
2959
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)`,
2960
3205
  conversation_id: row.conversation_id,
2961
3206
  message_id: row.id
@@ -2979,11 +3224,12 @@ var ChannelDriver = class {
2979
3224
  this.readopted.add(row.id);
2980
3225
  this.ensureWatcherRunning(sessionId);
2981
3226
  this.log({
2982
- level: "info",
3227
+ level: "debug",
2983
3228
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
2984
3229
  conversation_id: row.conversation_id,
2985
3230
  message_id: row.id
2986
3231
  });
3232
+ void this.postSignal(row.conversation_id, row.id, "readopt_reattached");
2987
3233
  return;
2988
3234
  }
2989
3235
  await this.forceReadoptRun(sessionId, row);
@@ -3012,7 +3258,7 @@ var ChannelDriver = class {
3012
3258
  async forceReadoptRun(sessionId, row) {
3013
3259
  if (this.stopped) {
3014
3260
  this.log({
3015
- level: "info",
3261
+ level: "debug",
3016
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`,
3017
3263
  conversation_id: row.conversation_id,
3018
3264
  message_id: row.id
@@ -3021,7 +3267,7 @@ var ChannelDriver = class {
3021
3267
  }
3022
3268
  if (this.awaitingReadopt.has(row.id)) {
3023
3269
  this.log({
3024
- level: "info",
3270
+ level: "debug",
3025
3271
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3026
3272
  conversation_id: row.conversation_id,
3027
3273
  message_id: row.id
@@ -3031,11 +3277,12 @@ var ChannelDriver = class {
3031
3277
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
3032
3278
  this.dontRedispatch.add(row.id);
3033
3279
  this.log({
3034
- level: "info",
3280
+ level: "debug",
3035
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)`,
3036
3282
  conversation_id: row.conversation_id,
3037
3283
  message_id: row.id
3038
3284
  });
3285
+ void this.postSignal(row.conversation_id, row.id, "readopt_window_elapsed");
3039
3286
  return;
3040
3287
  }
3041
3288
  const options = {
@@ -3049,40 +3296,44 @@ var ChannelDriver = class {
3049
3296
  message_id: row.id
3050
3297
  });
3051
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);
3052
3302
  let ocId;
3053
3303
  try {
3054
3304
  ocId = await this.dispatchLocked(
3055
3305
  sessionId,
3056
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3306
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
3057
3307
  );
3058
3308
  } catch (err) {
3059
3309
  this.awaitingReadopt.delete(row.id);
3060
3310
  if (err instanceof ChannelAuthError) throw err;
3061
3311
  this.log({
3062
- level: "error",
3312
+ level: "warn",
3063
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)}`,
3064
3314
  conversation_id: row.conversation_id,
3065
3315
  message_id: row.id
3066
3316
  });
3317
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3067
3318
  return;
3068
3319
  }
3069
3320
  if (ocId === null) {
3070
3321
  this.awaitingReadopt.delete(row.id);
3071
3322
  this.log({
3072
- level: "error",
3323
+ level: "warn",
3073
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`,
3074
3325
  conversation_id: row.conversation_id,
3075
3326
  message_id: row.id
3076
3327
  });
3328
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3077
3329
  return;
3078
3330
  }
3079
- const conv = this.convForRow(sessionId, row);
3080
- const message = this.queuedMessageForRow(row);
3081
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3331
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
3082
3332
  this.dispatched.add(row.id);
3083
3333
  this.readopted.add(row.id);
3084
3334
  this.awaitingReadopt.delete(row.id);
3085
3335
  this.ensureWatcherRunning(sessionId);
3336
+ void this.postSignal(row.conversation_id, row.id, "readopt_redispatched");
3086
3337
  }
3087
3338
  /**
3088
3339
  * True if `evidentMessageId` is already being driven — either in the
@@ -3131,7 +3382,8 @@ var ChannelDriver = class {
3131
3382
  opencode_agent: row.opencode_agent,
3132
3383
  opencode_model: row.opencode_model,
3133
3384
  source_message_id: row.source_message_id,
3134
- slack_user_id: row.slack_user_id
3385
+ slack_user_id: row.slack_user_id,
3386
+ attachments: row.attachments ?? null
3135
3387
  };
3136
3388
  }
3137
3389
  /**
@@ -3152,7 +3404,7 @@ var ChannelDriver = class {
3152
3404
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3153
3405
  this.dontRedispatch.add(evidentMessageId);
3154
3406
  this.log({
3155
- level: "info",
3407
+ level: "debug",
3156
3408
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3157
3409
  conversation_id: watcher.conv.id,
3158
3410
  message_id: evidentMessageId
@@ -3292,6 +3544,51 @@ var ChannelDriver = class {
3292
3544
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3293
3545
  return parent;
3294
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
+ }
3295
3592
  /**
3296
3593
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3297
3594
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -3335,7 +3632,7 @@ var ChannelDriver = class {
3335
3632
  const sessions = await listSessions(this.port);
3336
3633
  if (!sessions) {
3337
3634
  this.log({
3338
- level: "error",
3635
+ level: "warn",
3339
3636
  message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3340
3637
  });
3341
3638
  return null;
@@ -3419,9 +3716,7 @@ var ChannelDriver = class {
3419
3716
  }
3420
3717
  return inFlight.sort(byOldest)[0];
3421
3718
  }
3422
- // -------------------------------------------------------------------------
3423
3719
  // Evident API calls (combinedAuth thread routes)
3424
- // -------------------------------------------------------------------------
3425
3720
  async getPendingConversations() {
3426
3721
  const res = await this.fetchImpl(
3427
3722
  `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
@@ -3501,7 +3796,7 @@ var ChannelDriver = class {
3501
3796
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3502
3797
  * retry vehicle for the swap-to-running.
3503
3798
  */
3504
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
3799
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3505
3800
  const res = await this.fetchImpl(
3506
3801
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3507
3802
  {
@@ -3510,7 +3805,8 @@ var ChannelDriver = class {
3510
3805
  body: JSON.stringify({
3511
3806
  status: "processing",
3512
3807
  opencode_session_id: sessionId,
3513
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3808
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3809
+ ...title ? { title } : {}
3514
3810
  })
3515
3811
  }
3516
3812
  );
@@ -3549,7 +3845,7 @@ var ChannelDriver = class {
3549
3845
  * watcher retries next tick within the
3550
3846
  * deadline, Finding 4).
3551
3847
  */
3552
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
3848
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
3553
3849
  const res = await this.fetchImpl(
3554
3850
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3555
3851
  {
@@ -3558,7 +3854,8 @@ var ChannelDriver = class {
3558
3854
  body: JSON.stringify({
3559
3855
  status: "done",
3560
3856
  opencode_session_id: sessionId,
3561
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3857
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3858
+ ...title ? { title } : {}
3562
3859
  })
3563
3860
  }
3564
3861
  );
@@ -3619,7 +3916,7 @@ var ChannelDriver = class {
3619
3916
  );
3620
3917
  if (!res.ok) {
3621
3918
  this.log({
3622
- level: "error",
3919
+ level: "warn",
3623
3920
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3624
3921
  conversation_id: conversationId,
3625
3922
  message_id: messageId
@@ -3629,7 +3926,7 @@ var ChannelDriver = class {
3629
3926
  return true;
3630
3927
  } catch (err) {
3631
3928
  this.log({
3632
- level: "error",
3929
+ level: "warn",
3633
3930
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3634
3931
  conversation_id: conversationId,
3635
3932
  message_id: messageId
@@ -3691,9 +3988,7 @@ var ChannelDriver = class {
3691
3988
  return false;
3692
3989
  }
3693
3990
  }
3694
- // -------------------------------------------------------------------------
3695
3991
  // Retry wrapper
3696
- // -------------------------------------------------------------------------
3697
3992
  /**
3698
3993
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3699
3994
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3973,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
3973
4268
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
3974
4269
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
3975
4270
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
3976
- 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;
3977
4299
  if (state.json) {
3978
4300
  console.log(
3979
4301
  JSON.stringify({
3980
4302
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3981
- level: isError ? "error" : "info",
4303
+ level,
3982
4304
  message
3983
4305
  })
3984
4306
  );
3985
4307
  } else if (!state.interactive) {
3986
- 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");
3987
4309
  console.log(`${prefix} ${message}`);
3988
4310
  }
3989
4311
  }
3990
4312
  function logActivity(state, entry) {
4313
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4314
+ if (!meetsThreshold(state, level)) return;
3991
4315
  const fullEntry = {
3992
4316
  ...entry,
4317
+ level,
3993
4318
  timestamp: /* @__PURE__ */ new Date()
3994
4319
  };
3995
4320
  state.activityLog.push(fullEntry);
@@ -3998,9 +4323,9 @@ function logActivity(state, entry) {
3998
4323
  }
3999
4324
  if (!state.interactive) {
4000
4325
  if (entry.type === "error") {
4001
- log2(state, entry.error ?? "Unknown error", true);
4002
- } else if (entry.type === "info" && entry.message) {
4003
- log2(state, entry.message);
4326
+ log2(state, entry.error ?? "Unknown error", level);
4327
+ } else if (entry.message) {
4328
+ log2(state, entry.message, level);
4004
4329
  }
4005
4330
  }
4006
4331
  }
@@ -4199,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
4199
4524
  process.env
4200
4525
  );
4201
4526
  for (const warning2 of config2.warnings) {
4202
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4527
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4203
4528
  }
4204
4529
  if (!config2.enabled) return;
4205
4530
  logActivity(state, {
@@ -4271,6 +4596,20 @@ async function cleanup(state, opts = {}) {
4271
4596
  }
4272
4597
  async function run(options) {
4273
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
+ }
4274
4613
  const state = {
4275
4614
  agentId: options.agent || "",
4276
4615
  agentName: null,
@@ -4279,6 +4618,7 @@ async function run(options) {
4279
4618
  idleTimeout: options.idleTimeout ?? null,
4280
4619
  json: options.json ?? false,
4281
4620
  interactive,
4621
+ logLevel,
4282
4622
  connected: false,
4283
4623
  opencodeConnected: false,
4284
4624
  opencodeVersion: null,
@@ -4296,8 +4636,8 @@ async function run(options) {
4296
4636
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4297
4637
  log2(
4298
4638
  state,
4299
- "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.",
4300
- 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"
4301
4641
  );
4302
4642
  }
4303
4643
  const handleSignal = async () => {
@@ -4413,9 +4753,9 @@ async function run(options) {
4413
4753
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4414
4754
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4415
4755
  if (versionWarning) {
4416
- log2(state, versionWarning, false);
4756
+ log2(state, versionWarning, "warn");
4417
4757
  if (state.interactive && !state.json) {
4418
- logActivity(state, { type: "info", message: versionWarning });
4758
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
4419
4759
  }
4420
4760
  }
4421
4761
  } catch (error2) {
@@ -4430,11 +4770,17 @@ async function run(options) {
4430
4770
  getAuthHeader: () => state.authHeader,
4431
4771
  conversationFilter: state.conversationFilter,
4432
4772
  stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
4433
- log: (entry) => logActivity(state, {
4434
- type: entry.level === "error" ? "error" : "info",
4435
- message: entry.message,
4436
- error: entry.level === "error" ? entry.message : void 0
4437
- })
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
+ )
4438
4784
  });
4439
4785
  state.channelDriver = channelDriver;
4440
4786
  const connection = new RunnerConnection({
@@ -4588,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4588
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);
4589
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 }));
4590
4936
  program.command("whoami").description("Show the currently logged in user").action(whoami);
4591
- 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(
4592
4941
  "--session-cleanup-max-age <duration>",
4593
4942
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4594
4943
  ).option(
@@ -4602,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
4602
4951
  run({
4603
4952
  agent: options.agent,
4604
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,
4605
4957
  verbose: options.verbose,
4606
4958
  conversation: options.conversation,
4607
4959
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,