@evident-ai/cli 3.0.1-dev.10c527f → 3.0.1-dev.18aac68

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,
@@ -1941,6 +2051,15 @@ var ChannelDriver = class {
1941
2051
  * so the NEXT tick may retry exactly once more).
1942
2052
  */
1943
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();
1944
2063
  /**
1945
2064
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1946
2065
  * first session creation so drain-created sessions are rooted at the project
@@ -1958,6 +2077,16 @@ var ChannelDriver = class {
1958
2077
  * entry = not yet resolved; `null` = resolved root (stop walking).
1959
2078
  */
1960
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();
1961
2090
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1962
2091
  draining = false;
1963
2092
  /**
@@ -2173,9 +2302,10 @@ var ChannelDriver = class {
2173
2302
  conversation_id: conv.id,
2174
2303
  message_id: message.id
2175
2304
  });
2305
+ const sendAttachments = this.buildSendAttachments(conv, message);
2176
2306
  opencodeMessageId = await this.dispatchLocked(
2177
2307
  sessionId,
2178
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2308
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2179
2309
  );
2180
2310
  } catch (err) {
2181
2311
  if (err instanceof ChannelAuthError) throw err;
@@ -2183,7 +2313,7 @@ var ChannelDriver = class {
2183
2313
  if (await sessionExists(this.port, sessionId) === false) {
2184
2314
  this.sessions.delete(conv.id);
2185
2315
  this.log({
2186
- level: "info",
2316
+ level: "warn",
2187
2317
  message: `Message ${message.id.slice(0, 8)} dispatch hit a session (${sessionId.slice(0, 8)}) that was deleted mid-dispatch (cleanup race) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick (recreated then, in order). Already-dispatched turns keep their watcher.`,
2188
2318
  conversation_id: conv.id,
2189
2319
  message_id: message.id
@@ -2202,7 +2332,7 @@ var ChannelDriver = class {
2202
2332
  }
2203
2333
  if (opencodeMessageId === null) {
2204
2334
  this.log({
2205
- level: "error",
2335
+ level: "warn",
2206
2336
  message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
2207
2337
  conversation_id: conv.id,
2208
2338
  message_id: message.id
@@ -2216,7 +2346,7 @@ var ChannelDriver = class {
2216
2346
  }
2217
2347
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2218
2348
  this.log({
2219
- level: "error",
2349
+ level: "warn",
2220
2350
  message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
2221
2351
  conversation_id: conv.id
2222
2352
  });
@@ -2230,7 +2360,7 @@ var ChannelDriver = class {
2230
2360
  const exists = await sessionExists(this.port, bound);
2231
2361
  if (exists === false) {
2232
2362
  this.log({
2233
- level: "info",
2363
+ level: "debug",
2234
2364
  message: `OpenCode session ${bound} for conversation ${conv.id.slice(0, 8)} no longer exists (deleted or DB reset) \u2014 creating a fresh session and rebinding.`,
2235
2365
  conversation_id: conv.id
2236
2366
  });
@@ -2265,7 +2395,7 @@ var ChannelDriver = class {
2265
2395
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2266
2396
  if (!this.opencodeDirectory) {
2267
2397
  this.log({
2268
- level: "info",
2398
+ level: "warn",
2269
2399
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2270
2400
  });
2271
2401
  }
@@ -2293,6 +2423,105 @@ var ChannelDriver = class {
2293
2423
  );
2294
2424
  return run2;
2295
2425
  }
2426
+ // -------------------------------------------------------------------------
2427
+ // Inbound image attachments (#255, WI-8)
2428
+ // -------------------------------------------------------------------------
2429
+ /**
2430
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
2431
+ * `undefined` when the message has none (so a text-only turn is unchanged).
2432
+ *
2433
+ * The driver OWNS the two channel-facing concerns the session module cannot:
2434
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
2435
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
2436
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
2437
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
2438
+ * existing callback surface when any image was skipped/failed.
2439
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
2440
+ * reports outcomes back via `onOutcomes`.
2441
+ */
2442
+ buildSendAttachments(conv, message) {
2443
+ const refs = message.attachments;
2444
+ if (!refs || refs.length === 0) return void 0;
2445
+ return {
2446
+ inputs: refs.map((a, index) => ({
2447
+ index,
2448
+ mime: a.mime,
2449
+ ...a.filename ? { filename: a.filename } : {}
2450
+ })),
2451
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
2452
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
2453
+ };
2454
+ }
2455
+ /**
2456
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
2457
+ * (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
2458
+ * existing authenticated fetch, and base64-encode into a
2459
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
2460
+ *
2461
+ * The endpoint streams the source bytes verbatim (200), or returns 404
2462
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
2463
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
2464
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
2465
+ * Failures are logged with context (no silent swallow).
2466
+ */
2467
+ async fetchAttachmentDataUrl(messageId, index, mime) {
2468
+ try {
2469
+ const res = await this.fetchImpl(
2470
+ `${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
2471
+ { headers: { Authorization: this.getAuthHeader() } }
2472
+ );
2473
+ if (!res.ok) {
2474
+ this.log({
2475
+ level: "error",
2476
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
2477
+ message_id: messageId
2478
+ });
2479
+ return null;
2480
+ }
2481
+ const buf = await res.arrayBuffer();
2482
+ const base64 = Buffer.from(buf).toString("base64");
2483
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
2484
+ return `data:${dataMime};base64,${base64}`;
2485
+ } catch (err) {
2486
+ this.log({
2487
+ level: "error",
2488
+ 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)}`,
2489
+ message_id: messageId
2490
+ });
2491
+ return null;
2492
+ }
2493
+ }
2494
+ /**
2495
+ * On any skipped/failed image, post an in-thread note to Evident over the
2496
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
2497
+ * Evident routes the note to source via `conversation.deliver`.
2498
+ *
2499
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
2500
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
2501
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
2502
+ * reaches the channel.
2503
+ *
2504
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
2505
+ */
2506
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
2507
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
2508
+ const failed = outcomes.filter((o) => o.status === "failed").length;
2509
+ if (skipped === 0 && failed === 0) return;
2510
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
2511
+ this.attachmentsSkippedSignalled.add(messageId);
2512
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
2513
+ this.log({
2514
+ level: "info",
2515
+ 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`,
2516
+ conversation_id: conversationId,
2517
+ message_id: messageId
2518
+ });
2519
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
2520
+ skipped,
2521
+ failed,
2522
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {}
2523
+ });
2524
+ }
2296
2525
  /** Register a freshly-dispatched message with its session's watcher state. */
2297
2526
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2298
2527
  let watcher = this.watchers.get(sessionId);
@@ -2530,18 +2759,20 @@ var ChannelDriver = class {
2530
2759
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2531
2760
  const awaitingHuman = observedOpen || latchedPaused;
2532
2761
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2762
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2533
2763
  let claimed;
2534
2764
  try {
2535
2765
  claimed = await this.markProcessing(
2536
2766
  conv.id,
2537
2767
  inFlight.evidentMessageId,
2538
2768
  sessionId,
2539
- inFlight.opencodeMessageId
2769
+ inFlight.opencodeMessageId,
2770
+ title
2540
2771
  );
2541
2772
  } catch (err) {
2542
2773
  if (err instanceof ChannelAuthError) throw err;
2543
2774
  this.log({
2544
- level: "error",
2775
+ level: "warn",
2545
2776
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2546
2777
  conversation_id: conv.id,
2547
2778
  message_id: inFlight.evidentMessageId
@@ -2551,7 +2782,7 @@ var ChannelDriver = class {
2551
2782
  inFlight.started = true;
2552
2783
  if (!claimed) {
2553
2784
  this.log({
2554
- level: "info",
2785
+ level: "debug",
2555
2786
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2556
2787
  conversation_id: conv.id,
2557
2788
  message_id: inFlight.evidentMessageId
@@ -2567,18 +2798,20 @@ var ChannelDriver = class {
2567
2798
  conversation_id: conv.id,
2568
2799
  message_id: inFlight.evidentMessageId
2569
2800
  });
2801
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2570
2802
  try {
2571
2803
  await this.markDone(
2572
2804
  conv.id,
2573
2805
  inFlight.evidentMessageId,
2574
2806
  sessionId,
2575
- inFlight.opencodeMessageId
2807
+ inFlight.opencodeMessageId,
2808
+ title
2576
2809
  );
2577
2810
  } catch (err) {
2578
2811
  if (err instanceof ChannelAuthError) throw err;
2579
2812
  if (err instanceof ChannelTerminalError) {
2580
2813
  this.log({
2581
- level: "error",
2814
+ level: "warn",
2582
2815
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2583
2816
  conversation_id: conv.id,
2584
2817
  message_id: inFlight.evidentMessageId
@@ -2588,7 +2821,7 @@ var ChannelDriver = class {
2588
2821
  }
2589
2822
  if (this.now() >= inFlight.deadline) {
2590
2823
  this.log({
2591
- level: "error",
2824
+ level: "warn",
2592
2825
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
2593
2826
  conversation_id: conv.id,
2594
2827
  message_id: inFlight.evidentMessageId
@@ -2597,7 +2830,7 @@ var ChannelDriver = class {
2597
2830
  return;
2598
2831
  }
2599
2832
  this.log({
2600
- level: "error",
2833
+ level: "warn",
2601
2834
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2602
2835
  conversation_id: conv.id,
2603
2836
  message_id: inFlight.evidentMessageId
@@ -2625,7 +2858,7 @@ var ChannelDriver = class {
2625
2858
  if (err instanceof ChannelAuthError) throw err;
2626
2859
  if (err instanceof ChannelTerminalError) {
2627
2860
  this.log({
2628
- level: "error",
2861
+ level: "warn",
2629
2862
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
2630
2863
  conversation_id: conv.id,
2631
2864
  message_id: inFlight.evidentMessageId
@@ -2635,7 +2868,7 @@ var ChannelDriver = class {
2635
2868
  }
2636
2869
  if (this.now() >= inFlight.deadline) {
2637
2870
  this.log({
2638
- level: "error",
2871
+ level: "warn",
2639
2872
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
2640
2873
  conversation_id: conv.id,
2641
2874
  message_id: inFlight.evidentMessageId
@@ -2644,7 +2877,7 @@ var ChannelDriver = class {
2644
2877
  return;
2645
2878
  }
2646
2879
  this.log({
2647
- level: "error",
2880
+ level: "warn",
2648
2881
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2649
2882
  conversation_id: conv.id,
2650
2883
  message_id: inFlight.evidentMessageId
@@ -2667,7 +2900,7 @@ var ChannelDriver = class {
2667
2900
  const activelyRunning = state === "running" && !awaitingHuman;
2668
2901
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2669
2902
  this.log({
2670
- level: "error",
2903
+ level: "warn",
2671
2904
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
2672
2905
  conversation_id: conv.id,
2673
2906
  message_id: inFlight.evidentMessageId
@@ -2710,7 +2943,7 @@ var ChannelDriver = class {
2710
2943
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2711
2944
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2712
2945
  this.log({
2713
- level: "info",
2946
+ level: "debug",
2714
2947
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2715
2948
  conversation_id: conv.id,
2716
2949
  message_id: inFlight.evidentMessageId
@@ -2753,7 +2986,7 @@ var ChannelDriver = class {
2753
2986
  this.readoptPollUnresolvedSignalled.delete(id);
2754
2987
  if (cleared || clearedUndeliverable) {
2755
2988
  this.log({
2756
- level: "info",
2989
+ level: "debug",
2757
2990
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2758
2991
  message_id: id
2759
2992
  });
@@ -2766,7 +2999,7 @@ var ChannelDriver = class {
2766
2999
  for (const row of rows) {
2767
3000
  if (!row.opencode_session_id) {
2768
3001
  this.log({
2769
- level: "error",
3002
+ level: "warn",
2770
3003
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2771
3004
  conversation_id: row.conversation_id,
2772
3005
  message_id: row.id
@@ -2783,7 +3016,7 @@ var ChannelDriver = class {
2783
3016
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2784
3017
  if (!res.ok) {
2785
3018
  this.log({
2786
- level: "error",
3019
+ level: "warn",
2787
3020
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2788
3021
  });
2789
3022
  continue;
@@ -2791,7 +3024,7 @@ var ChannelDriver = class {
2791
3024
  const body = await res.json();
2792
3025
  if (!Array.isArray(body)) {
2793
3026
  this.log({
2794
- level: "error",
3027
+ level: "warn",
2795
3028
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2796
3029
  });
2797
3030
  continue;
@@ -2799,7 +3032,7 @@ var ChannelDriver = class {
2799
3032
  messages = body;
2800
3033
  } catch (err) {
2801
3034
  this.log({
2802
- level: "error",
3035
+ level: "warn",
2803
3036
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2804
3037
  });
2805
3038
  continue;
@@ -2832,7 +3065,7 @@ var ChannelDriver = class {
2832
3065
  async readoptOne(sessionId, row, messages, sessionOngoing) {
2833
3066
  if (this.isTracked(sessionId, row.id)) {
2834
3067
  this.log({
2835
- level: "info",
3068
+ level: "debug",
2836
3069
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2837
3070
  conversation_id: row.conversation_id,
2838
3071
  message_id: row.id
@@ -2844,7 +3077,7 @@ var ChannelDriver = class {
2844
3077
  if (state === "done") {
2845
3078
  if (this.doneUndeliverable.has(row.id)) {
2846
3079
  this.log({
2847
- level: "info",
3080
+ level: "debug",
2848
3081
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2849
3082
  conversation_id: row.conversation_id,
2850
3083
  message_id: row.id
@@ -2858,13 +3091,14 @@ var ChannelDriver = class {
2858
3091
  message_id: row.id
2859
3092
  });
2860
3093
  try {
2861
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3094
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3095
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
2862
3096
  } catch (err) {
2863
3097
  if (err instanceof ChannelAuthError) throw err;
2864
3098
  if (err instanceof ChannelTerminalError) {
2865
3099
  this.doneUndeliverable.add(row.id);
2866
3100
  this.log({
2867
- level: "error",
3101
+ level: "warn",
2868
3102
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
2869
3103
  conversation_id: row.conversation_id,
2870
3104
  message_id: row.id
@@ -2873,7 +3107,7 @@ var ChannelDriver = class {
2873
3107
  return;
2874
3108
  }
2875
3109
  this.log({
2876
- level: "error",
3110
+ level: "warn",
2877
3111
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2878
3112
  conversation_id: row.conversation_id,
2879
3113
  message_id: row.id
@@ -2899,7 +3133,7 @@ var ChannelDriver = class {
2899
3133
  if (err instanceof ChannelTerminalError) {
2900
3134
  this.doneUndeliverable.add(row.id);
2901
3135
  this.log({
2902
- level: "error",
3136
+ level: "warn",
2903
3137
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
2904
3138
  conversation_id: row.conversation_id,
2905
3139
  message_id: row.id
@@ -2908,7 +3142,7 @@ var ChannelDriver = class {
2908
3142
  return;
2909
3143
  }
2910
3144
  this.log({
2911
- level: "error",
3145
+ level: "warn",
2912
3146
  message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
2913
3147
  conversation_id: row.conversation_id,
2914
3148
  message_id: row.id
@@ -2921,7 +3155,7 @@ var ChannelDriver = class {
2921
3155
  }
2922
3156
  if (this.dontRedispatch.has(row.id)) {
2923
3157
  this.log({
2924
- level: "info",
3158
+ level: "debug",
2925
3159
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2926
3160
  conversation_id: row.conversation_id,
2927
3161
  message_id: row.id
@@ -2946,7 +3180,7 @@ var ChannelDriver = class {
2946
3180
  }
2947
3181
  if (ongoing === true) {
2948
3182
  this.log({
2949
- level: "info",
3183
+ level: "debug",
2950
3184
  message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
2951
3185
  conversation_id: row.conversation_id,
2952
3186
  message_id: row.id
@@ -2954,7 +3188,7 @@ var ChannelDriver = class {
2954
3188
  } else {
2955
3189
  if (shape === "b1") {
2956
3190
  this.log({
2957
- level: "info",
3191
+ level: "debug",
2958
3192
  message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
2959
3193
  conversation_id: row.conversation_id,
2960
3194
  message_id: row.id
@@ -2966,7 +3200,7 @@ var ChannelDriver = class {
2966
3200
  return;
2967
3201
  }
2968
3202
  this.log({
2969
- level: "info",
3203
+ level: "debug",
2970
3204
  message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
2971
3205
  conversation_id: row.conversation_id,
2972
3206
  message_id: row.id
@@ -2977,7 +3211,7 @@ var ChannelDriver = class {
2977
3211
  const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2978
3212
  if (descendantAlive === true) {
2979
3213
  this.log({
2980
- level: "info",
3214
+ level: "debug",
2981
3215
  message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
2982
3216
  conversation_id: row.conversation_id,
2983
3217
  message_id: row.id
@@ -3001,7 +3235,7 @@ var ChannelDriver = class {
3001
3235
  this.readopted.add(row.id);
3002
3236
  this.ensureWatcherRunning(sessionId);
3003
3237
  this.log({
3004
- level: "info",
3238
+ level: "debug",
3005
3239
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
3006
3240
  conversation_id: row.conversation_id,
3007
3241
  message_id: row.id
@@ -3035,7 +3269,7 @@ var ChannelDriver = class {
3035
3269
  async forceReadoptRun(sessionId, row) {
3036
3270
  if (this.stopped) {
3037
3271
  this.log({
3038
- level: "info",
3272
+ level: "debug",
3039
3273
  message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but the runner is stopping \u2014 not starting a fresh turn; leaving for restart recovery`,
3040
3274
  conversation_id: row.conversation_id,
3041
3275
  message_id: row.id
@@ -3044,7 +3278,7 @@ var ChannelDriver = class {
3044
3278
  }
3045
3279
  if (this.awaitingReadopt.has(row.id)) {
3046
3280
  this.log({
3047
- level: "info",
3281
+ level: "debug",
3048
3282
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3049
3283
  conversation_id: row.conversation_id,
3050
3284
  message_id: row.id
@@ -3054,7 +3288,7 @@ var ChannelDriver = class {
3054
3288
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
3055
3289
  this.dontRedispatch.add(row.id);
3056
3290
  this.log({
3057
- level: "info",
3291
+ level: "debug",
3058
3292
  message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but its re-adopt window has already elapsed \u2014 not dispatching an unwatchable turn; parking until it leaves processing (cron will reset it)`,
3059
3293
  conversation_id: row.conversation_id,
3060
3294
  message_id: row.id
@@ -3073,17 +3307,20 @@ var ChannelDriver = class {
3073
3307
  message_id: row.id
3074
3308
  });
3075
3309
  this.awaitingReadopt.add(row.id);
3310
+ const readoptConv = this.convForRow(sessionId, row);
3311
+ const readoptMessage = this.queuedMessageForRow(row);
3312
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
3076
3313
  let ocId;
3077
3314
  try {
3078
3315
  ocId = await this.dispatchLocked(
3079
3316
  sessionId,
3080
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3317
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
3081
3318
  );
3082
3319
  } catch (err) {
3083
3320
  this.awaitingReadopt.delete(row.id);
3084
3321
  if (err instanceof ChannelAuthError) throw err;
3085
3322
  this.log({
3086
- level: "error",
3323
+ level: "warn",
3087
3324
  message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
3088
3325
  conversation_id: row.conversation_id,
3089
3326
  message_id: row.id
@@ -3094,7 +3331,7 @@ var ChannelDriver = class {
3094
3331
  if (ocId === null) {
3095
3332
  this.awaitingReadopt.delete(row.id);
3096
3333
  this.log({
3097
- level: "error",
3334
+ level: "warn",
3098
3335
  message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
3099
3336
  conversation_id: row.conversation_id,
3100
3337
  message_id: row.id
@@ -3102,9 +3339,7 @@ var ChannelDriver = class {
3102
3339
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3103
3340
  return;
3104
3341
  }
3105
- const conv = this.convForRow(sessionId, row);
3106
- const message = this.queuedMessageForRow(row);
3107
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3342
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
3108
3343
  this.dispatched.add(row.id);
3109
3344
  this.readopted.add(row.id);
3110
3345
  this.awaitingReadopt.delete(row.id);
@@ -3158,7 +3393,8 @@ var ChannelDriver = class {
3158
3393
  opencode_agent: row.opencode_agent,
3159
3394
  opencode_model: row.opencode_model,
3160
3395
  source_message_id: row.source_message_id,
3161
- slack_user_id: row.slack_user_id
3396
+ slack_user_id: row.slack_user_id,
3397
+ attachments: row.attachments ?? null
3162
3398
  };
3163
3399
  }
3164
3400
  /**
@@ -3179,7 +3415,7 @@ var ChannelDriver = class {
3179
3415
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3180
3416
  this.dontRedispatch.add(evidentMessageId);
3181
3417
  this.log({
3182
- level: "info",
3418
+ level: "debug",
3183
3419
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3184
3420
  conversation_id: watcher.conv.id,
3185
3421
  message_id: evidentMessageId
@@ -3319,6 +3555,51 @@ var ChannelDriver = class {
3319
3555
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3320
3556
  return parent;
3321
3557
  }
3558
+ /**
3559
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3560
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3561
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3562
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3563
+ * Best-effort:
3564
+ * - a resolved NON-EMPTY title is cached and terminal (a real session name
3565
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3566
+ * - while the title is still absent/empty we do NOT latch it — OpenCode names
3567
+ * sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
3568
+ * must leave the cache unresolved and re-fetch on the next need so a later
3569
+ * call (e.g. at `done`) picks up the name assigned in the meantime. Such a
3570
+ * call returns `null` (omit the title on THIS PATCH) without caching;
3571
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3572
+ * and returns `null` — it must NEVER throw or block completion.
3573
+ * A failure is logged with agent/session context (no silent catch).
3574
+ */
3575
+ async resolveSessionTitle(sessionId, conversationId) {
3576
+ const cached = this.sessionTitles.get(sessionId);
3577
+ if (cached != null) return cached;
3578
+ try {
3579
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3580
+ if (res.ok) {
3581
+ const body = await res.json();
3582
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3583
+ if (title.length > 0) {
3584
+ this.sessionTitles.set(sessionId, title);
3585
+ return title;
3586
+ }
3587
+ return null;
3588
+ }
3589
+ this.log({
3590
+ level: "debug",
3591
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3592
+ conversation_id: conversationId
3593
+ });
3594
+ } catch (err) {
3595
+ this.log({
3596
+ level: "debug",
3597
+ 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)}`,
3598
+ conversation_id: conversationId
3599
+ });
3600
+ }
3601
+ return null;
3602
+ }
3322
3603
  /**
3323
3604
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3324
3605
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -3362,7 +3643,7 @@ var ChannelDriver = class {
3362
3643
  const sessions = await listSessions(this.port);
3363
3644
  if (!sessions) {
3364
3645
  this.log({
3365
- level: "error",
3646
+ level: "warn",
3366
3647
  message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3367
3648
  });
3368
3649
  return null;
@@ -3528,7 +3809,7 @@ var ChannelDriver = class {
3528
3809
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3529
3810
  * retry vehicle for the swap-to-running.
3530
3811
  */
3531
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
3812
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3532
3813
  const res = await this.fetchImpl(
3533
3814
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3534
3815
  {
@@ -3537,7 +3818,8 @@ var ChannelDriver = class {
3537
3818
  body: JSON.stringify({
3538
3819
  status: "processing",
3539
3820
  opencode_session_id: sessionId,
3540
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3821
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3822
+ ...title ? { title } : {}
3541
3823
  })
3542
3824
  }
3543
3825
  );
@@ -3576,7 +3858,7 @@ var ChannelDriver = class {
3576
3858
  * watcher retries next tick within the
3577
3859
  * deadline, Finding 4).
3578
3860
  */
3579
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
3861
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
3580
3862
  const res = await this.fetchImpl(
3581
3863
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3582
3864
  {
@@ -3585,7 +3867,8 @@ var ChannelDriver = class {
3585
3867
  body: JSON.stringify({
3586
3868
  status: "done",
3587
3869
  opencode_session_id: sessionId,
3588
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3870
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3871
+ ...title ? { title } : {}
3589
3872
  })
3590
3873
  }
3591
3874
  );
@@ -3646,7 +3929,7 @@ var ChannelDriver = class {
3646
3929
  );
3647
3930
  if (!res.ok) {
3648
3931
  this.log({
3649
- level: "error",
3932
+ level: "warn",
3650
3933
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3651
3934
  conversation_id: conversationId,
3652
3935
  message_id: messageId
@@ -3656,7 +3939,7 @@ var ChannelDriver = class {
3656
3939
  return true;
3657
3940
  } catch (err) {
3658
3941
  this.log({
3659
- level: "error",
3942
+ level: "warn",
3660
3943
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3661
3944
  conversation_id: conversationId,
3662
3945
  message_id: messageId
@@ -4000,23 +4283,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
4000
4283
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
4001
4284
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
4002
4285
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
4003
- function log2(state, message, isError = false) {
4286
+ function resolveLogLevel(options) {
4287
+ const accepted = Object.keys(LOG_LEVELS);
4288
+ const validate = (value, source) => {
4289
+ const normalized = value.trim().toLowerCase();
4290
+ if (!accepted.includes(normalized)) {
4291
+ throw new Error(
4292
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4293
+ );
4294
+ }
4295
+ return normalized;
4296
+ };
4297
+ if (options.logLevel !== void 0) {
4298
+ return validate(options.logLevel, " (--log-level)");
4299
+ }
4300
+ if (options.verbose) {
4301
+ return "debug";
4302
+ }
4303
+ const env = process.env.EVIDENT_LOG_LEVEL;
4304
+ if (env !== void 0 && env !== "") {
4305
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4306
+ }
4307
+ return "info";
4308
+ }
4309
+ function meetsThreshold(state, level) {
4310
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4311
+ }
4312
+ function log2(state, message, level = "info") {
4313
+ if (!meetsThreshold(state, level)) return;
4004
4314
  if (state.json) {
4005
4315
  console.log(
4006
4316
  JSON.stringify({
4007
4317
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4008
- level: isError ? "error" : "info",
4318
+ level,
4009
4319
  message
4010
4320
  })
4011
4321
  );
4012
4322
  } else if (!state.interactive) {
4013
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4323
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
4014
4324
  console.log(`${prefix} ${message}`);
4015
4325
  }
4016
4326
  }
4017
4327
  function logActivity(state, entry) {
4328
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4329
+ if (!meetsThreshold(state, level)) return;
4018
4330
  const fullEntry = {
4019
4331
  ...entry,
4332
+ level,
4020
4333
  timestamp: /* @__PURE__ */ new Date()
4021
4334
  };
4022
4335
  state.activityLog.push(fullEntry);
@@ -4025,9 +4338,9 @@ function logActivity(state, entry) {
4025
4338
  }
4026
4339
  if (!state.interactive) {
4027
4340
  if (entry.type === "error") {
4028
- log2(state, entry.error ?? "Unknown error", true);
4029
- } else if (entry.type === "info" && entry.message) {
4030
- log2(state, entry.message);
4341
+ log2(state, entry.error ?? "Unknown error", level);
4342
+ } else if (entry.message) {
4343
+ log2(state, entry.message, level);
4031
4344
  }
4032
4345
  }
4033
4346
  }
@@ -4226,7 +4539,7 @@ function scheduleSessionCleanup(state, driver, options) {
4226
4539
  process.env
4227
4540
  );
4228
4541
  for (const warning2 of config2.warnings) {
4229
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4542
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4230
4543
  }
4231
4544
  if (!config2.enabled) return;
4232
4545
  logActivity(state, {
@@ -4298,6 +4611,20 @@ async function cleanup(state, opts = {}) {
4298
4611
  }
4299
4612
  async function run(options) {
4300
4613
  const interactive = isInteractive(options.json);
4614
+ let logLevel;
4615
+ try {
4616
+ logLevel = resolveLogLevel(options);
4617
+ } catch (error2) {
4618
+ const message = error2 instanceof Error ? error2.message : String(error2);
4619
+ if (options.json) {
4620
+ console.log(JSON.stringify({ status: "error", error: message }));
4621
+ } else {
4622
+ printError(message);
4623
+ }
4624
+ await shutdownTelemetry();
4625
+ process.exit(1);
4626
+ return;
4627
+ }
4301
4628
  const state = {
4302
4629
  agentId: options.agent || "",
4303
4630
  agentName: null,
@@ -4306,6 +4633,7 @@ async function run(options) {
4306
4633
  idleTimeout: options.idleTimeout ?? null,
4307
4634
  json: options.json ?? false,
4308
4635
  interactive,
4636
+ logLevel,
4309
4637
  connected: false,
4310
4638
  opencodeConnected: false,
4311
4639
  opencodeVersion: null,
@@ -4323,8 +4651,8 @@ async function run(options) {
4323
4651
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4324
4652
  log2(
4325
4653
  state,
4326
- "Warning: No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
4327
- false
4654
+ "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.",
4655
+ "warn"
4328
4656
  );
4329
4657
  }
4330
4658
  const handleSignal = async () => {
@@ -4440,9 +4768,9 @@ async function run(options) {
4440
4768
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4441
4769
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4442
4770
  if (versionWarning) {
4443
- log2(state, versionWarning, false);
4771
+ log2(state, versionWarning, "warn");
4444
4772
  if (state.interactive && !state.json) {
4445
- logActivity(state, { type: "info", message: versionWarning });
4773
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
4446
4774
  }
4447
4775
  }
4448
4776
  } catch (error2) {
@@ -4457,11 +4785,17 @@ async function run(options) {
4457
4785
  getAuthHeader: () => state.authHeader,
4458
4786
  conversationFilter: state.conversationFilter,
4459
4787
  stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
4460
- log: (entry) => logActivity(state, {
4461
- type: entry.level === "error" ? "error" : "info",
4462
- message: entry.message,
4463
- error: entry.level === "error" ? entry.message : void 0
4464
- })
4788
+ log: (entry) => (
4789
+ // Thread the driver's real level straight through so `debug`/`warn`
4790
+ // survive the sink filter (they no longer collapse to info). `type`
4791
+ // stays the coarse error/non-error split the activity log renders with.
4792
+ logActivity(state, {
4793
+ type: entry.level === "error" ? "error" : "info",
4794
+ level: entry.level,
4795
+ message: entry.message,
4796
+ error: entry.level === "error" ? entry.message : void 0
4797
+ })
4798
+ )
4465
4799
  });
4466
4800
  state.channelDriver = channelDriver;
4467
4801
  const connection = new RunnerConnection({
@@ -4615,7 +4949,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4615
4949
  program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
4616
4950
  program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
4617
4951
  program.command("whoami").description("Show the currently logged in user").action(whoami);
4618
- program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
4952
+ 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(
4953
+ "--log-level <level>",
4954
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
4955
+ ).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
4619
4956
  "--session-cleanup-max-age <duration>",
4620
4957
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4621
4958
  ).option(
@@ -4629,6 +4966,9 @@ program.command("run").description("Connect to Evident and process messages").op
4629
4966
  run({
4630
4967
  agent: options.agent,
4631
4968
  port: parseInt(options.port, 10),
4969
+ // Raw string — validation/precedence is single-sourced in run.ts's
4970
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
4971
+ logLevel: options.logLevel,
4632
4972
  verbose: options.verbose,
4633
4973
  conversation: options.conversation,
4634
4974
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,