@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.14c6359

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));
@@ -1271,6 +1370,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
1271
1370
  }
1272
1371
  return lastOk ?? last;
1273
1372
  }
1373
+ function messageUsage(messages, userMessageId) {
1374
+ if (!messages || messages.length === 0) return null;
1375
+ const byParentAll = messages.filter(
1376
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1377
+ );
1378
+ const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
1379
+ const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
1380
+ let correlated;
1381
+ if (byParent.length > 0) {
1382
+ correlated = byParent;
1383
+ } else {
1384
+ const reply = findAssistantReplyAfter(messages, userMessageId);
1385
+ correlated = reply ? [reply] : [];
1386
+ }
1387
+ if (correlated.length === 0) return null;
1388
+ let sawAnyUsage = false;
1389
+ let inputSum = 0;
1390
+ let outputSum = 0;
1391
+ let reasoningSum = 0;
1392
+ let cacheReadSum = 0;
1393
+ let cacheWriteSum = 0;
1394
+ let costSum = 0;
1395
+ let sawCost = false;
1396
+ let modelId = null;
1397
+ let providerId = null;
1398
+ for (const m of correlated) {
1399
+ const info = m.info;
1400
+ if (!info) continue;
1401
+ const tokens = info.tokens;
1402
+ if (tokens) {
1403
+ sawAnyUsage = true;
1404
+ inputSum += tokens.input ?? 0;
1405
+ outputSum += tokens.output ?? 0;
1406
+ reasoningSum += tokens.reasoning ?? 0;
1407
+ cacheReadSum += tokens.cache?.read ?? 0;
1408
+ cacheWriteSum += tokens.cache?.write ?? 0;
1409
+ }
1410
+ if (typeof info.cost === "number") {
1411
+ sawAnyUsage = true;
1412
+ sawCost = true;
1413
+ costSum += info.cost;
1414
+ }
1415
+ if (typeof info.modelID === "string") {
1416
+ sawAnyUsage = true;
1417
+ modelId = info.modelID;
1418
+ }
1419
+ if (typeof info.providerID === "string") {
1420
+ sawAnyUsage = true;
1421
+ providerId = info.providerID;
1422
+ }
1423
+ }
1424
+ if (!sawAnyUsage) return null;
1425
+ return {
1426
+ usage_provider_id: providerId,
1427
+ usage_model_id: modelId,
1428
+ usage_tokens_input: inputSum,
1429
+ usage_tokens_output: outputSum,
1430
+ usage_tokens_reasoning: reasoningSum,
1431
+ usage_tokens_cache_read: cacheReadSum,
1432
+ usage_tokens_cache_write: cacheWriteSum,
1433
+ // NULL means "OpenCode never reported a cost" (never inferred from
1434
+ // tokens) — distinct from a genuine 0-cost turn, which would set
1435
+ // `sawCost` true with `costSum === 0`.
1436
+ usage_cost_usd: sawCost ? costSum : null
1437
+ };
1438
+ }
1274
1439
  function messageRunState(messages, userMessageId) {
1275
1440
  if (!messages || messages.length === 0) return "unknown";
1276
1441
  const hasUser = messages.some((m) => idOf(m) === userMessageId);
@@ -1811,6 +1976,17 @@ function messageIdOf(m) {
1811
1976
  const infoId = m.info?.id;
1812
1977
  return typeof infoId === "string" ? infoId : void 0;
1813
1978
  }
1979
+ function cleanImageMime(contentType) {
1980
+ if (!contentType) return null;
1981
+ const media = contentType.split(";")[0].trim().toLowerCase();
1982
+ return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
1983
+ }
1984
+ var LOG_LEVELS = {
1985
+ debug: 0,
1986
+ info: 1,
1987
+ warn: 2,
1988
+ error: 3
1989
+ };
1814
1990
  var DEFAULT_RETRY_POLICY = {
1815
1991
  maxAttempts: 6,
1816
1992
  baseDelayMs: 500,
@@ -1941,6 +2117,15 @@ var ChannelDriver = class {
1941
2117
  * so the NEXT tick may retry exactly once more).
1942
2118
  */
1943
2119
  awaitingReadopt = /* @__PURE__ */ new Set();
2120
+ /**
2121
+ * "Already signalled `attachments_skipped` for this Evident message id" (#376).
2122
+ * The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
2123
+ * — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
2124
+ * next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
2125
+ * `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
2126
+ * outcome, not the dispatch. Not cleared (a message is signalled once for life).
2127
+ */
2128
+ attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
1944
2129
  /**
1945
2130
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1946
2131
  * first session creation so drain-created sessions are rooted at the project
@@ -1958,6 +2143,16 @@ var ChannelDriver = class {
1958
2143
  * entry = not yet resolved; `null` = resolved root (stop walking).
1959
2144
  */
1960
2145
  sessionParents = /* @__PURE__ */ new Map();
2146
+ /**
2147
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
2148
+ * NON-EMPTY name is stored (terminal — a real session name won't later un-name),
2149
+ * so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
2150
+ * resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
2151
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
2152
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
2153
+ * no watcher) can resolve the title.
2154
+ */
2155
+ sessionTitles = /* @__PURE__ */ new Map();
1961
2156
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1962
2157
  draining = false;
1963
2158
  /**
@@ -1995,9 +2190,6 @@ var ChannelDriver = class {
1995
2190
  get opencodeBase() {
1996
2191
  return `http://127.0.0.1:${this.port}`;
1997
2192
  }
1998
- // -------------------------------------------------------------------------
1999
- // Public API
2000
- // -------------------------------------------------------------------------
2001
2193
  /**
2002
2194
  * Drain all pending channel conversations once: poll → dispatch → register.
2003
2195
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -2139,9 +2331,7 @@ var ChannelDriver = class {
2139
2331
  if (!stillLive) return;
2140
2332
  }
2141
2333
  }
2142
- // -------------------------------------------------------------------------
2143
2334
  // Conversation processing (WI-3 — async dispatch)
2144
- // -------------------------------------------------------------------------
2145
2335
  /**
2146
2336
  * Dispatch each pending message for a conversation to opencode's native queue
2147
2337
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -2173,9 +2363,10 @@ var ChannelDriver = class {
2173
2363
  conversation_id: conv.id,
2174
2364
  message_id: message.id
2175
2365
  });
2366
+ const sendAttachments = this.buildSendAttachments(conv, message);
2176
2367
  opencodeMessageId = await this.dispatchLocked(
2177
2368
  sessionId,
2178
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2369
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2179
2370
  );
2180
2371
  } catch (err) {
2181
2372
  if (err instanceof ChannelAuthError) throw err;
@@ -2183,7 +2374,7 @@ var ChannelDriver = class {
2183
2374
  if (await sessionExists(this.port, sessionId) === false) {
2184
2375
  this.sessions.delete(conv.id);
2185
2376
  this.log({
2186
- level: "info",
2377
+ level: "warn",
2187
2378
  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
2379
  conversation_id: conv.id,
2189
2380
  message_id: message.id
@@ -2202,7 +2393,7 @@ var ChannelDriver = class {
2202
2393
  }
2203
2394
  if (opencodeMessageId === null) {
2204
2395
  this.log({
2205
- level: "error",
2396
+ level: "warn",
2206
2397
  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
2398
  conversation_id: conv.id,
2208
2399
  message_id: message.id
@@ -2216,7 +2407,7 @@ var ChannelDriver = class {
2216
2407
  }
2217
2408
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2218
2409
  this.log({
2219
- level: "error",
2410
+ level: "warn",
2220
2411
  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
2412
  conversation_id: conv.id
2222
2413
  });
@@ -2230,7 +2421,7 @@ var ChannelDriver = class {
2230
2421
  const exists = await sessionExists(this.port, bound);
2231
2422
  if (exists === false) {
2232
2423
  this.log({
2233
- level: "info",
2424
+ level: "debug",
2234
2425
  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
2426
  conversation_id: conv.id
2236
2427
  });
@@ -2265,15 +2456,13 @@ var ChannelDriver = class {
2265
2456
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2266
2457
  if (!this.opencodeDirectory) {
2267
2458
  this.log({
2268
- level: "info",
2459
+ level: "warn",
2269
2460
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2270
2461
  });
2271
2462
  }
2272
2463
  return this.opencodeDirectory;
2273
2464
  }
2274
- // -------------------------------------------------------------------------
2275
2465
  // Per-session watcher (WI-3)
2276
- // -------------------------------------------------------------------------
2277
2466
  /**
2278
2467
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2279
2468
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2293,6 +2482,103 @@ var ChannelDriver = class {
2293
2482
  );
2294
2483
  return run2;
2295
2484
  }
2485
+ // Inbound image attachments (#255, WI-8)
2486
+ /**
2487
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
2488
+ * `undefined` when the message has none (so a text-only turn is unchanged).
2489
+ *
2490
+ * The driver OWNS the two channel-facing concerns the session module cannot:
2491
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
2492
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
2493
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
2494
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
2495
+ * existing callback surface when any image was skipped/failed.
2496
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
2497
+ * reports outcomes back via `onOutcomes`.
2498
+ */
2499
+ buildSendAttachments(conv, message) {
2500
+ const refs = message.attachments;
2501
+ if (!refs || refs.length === 0) return void 0;
2502
+ return {
2503
+ inputs: refs.map((a, index) => ({
2504
+ index,
2505
+ mime: a.mime,
2506
+ ...a.filename ? { filename: a.filename } : {}
2507
+ })),
2508
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
2509
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
2510
+ };
2511
+ }
2512
+ /**
2513
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
2514
+ * (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
2515
+ * existing authenticated fetch, and base64-encode into a
2516
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
2517
+ *
2518
+ * The endpoint streams the source bytes verbatim (200), or returns 404
2519
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
2520
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
2521
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
2522
+ * Failures are logged with context (no silent swallow).
2523
+ */
2524
+ async fetchAttachmentDataUrl(messageId, index, mime) {
2525
+ try {
2526
+ const res = await this.fetchImpl(
2527
+ `${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
2528
+ { headers: { Authorization: this.getAuthHeader() } }
2529
+ );
2530
+ if (!res.ok) {
2531
+ this.log({
2532
+ level: "error",
2533
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
2534
+ message_id: messageId
2535
+ });
2536
+ return null;
2537
+ }
2538
+ const buf = await res.arrayBuffer();
2539
+ const base64 = Buffer.from(buf).toString("base64");
2540
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
2541
+ return `data:${dataMime};base64,${base64}`;
2542
+ } catch (err) {
2543
+ this.log({
2544
+ level: "error",
2545
+ 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)}`,
2546
+ message_id: messageId
2547
+ });
2548
+ return null;
2549
+ }
2550
+ }
2551
+ /**
2552
+ * On any skipped/failed image, post an in-thread note to Evident over the
2553
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
2554
+ * Evident routes the note to source via `conversation.deliver`.
2555
+ *
2556
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
2557
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
2558
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
2559
+ * reaches the channel.
2560
+ *
2561
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
2562
+ */
2563
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
2564
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
2565
+ const failed = outcomes.filter((o) => o.status === "failed").length;
2566
+ if (skipped === 0 && failed === 0) return;
2567
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
2568
+ this.attachmentsSkippedSignalled.add(messageId);
2569
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
2570
+ this.log({
2571
+ level: "info",
2572
+ 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`,
2573
+ conversation_id: conversationId,
2574
+ message_id: messageId
2575
+ });
2576
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
2577
+ skipped,
2578
+ failed,
2579
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {}
2580
+ });
2581
+ }
2296
2582
  /** Register a freshly-dispatched message with its session's watcher state. */
2297
2583
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2298
2584
  let watcher = this.watchers.get(sessionId);
@@ -2530,18 +2816,20 @@ var ChannelDriver = class {
2530
2816
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2531
2817
  const awaitingHuman = observedOpen || latchedPaused;
2532
2818
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
2819
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2533
2820
  let claimed;
2534
2821
  try {
2535
2822
  claimed = await this.markProcessing(
2536
2823
  conv.id,
2537
2824
  inFlight.evidentMessageId,
2538
2825
  sessionId,
2539
- inFlight.opencodeMessageId
2826
+ inFlight.opencodeMessageId,
2827
+ title
2540
2828
  );
2541
2829
  } catch (err) {
2542
2830
  if (err instanceof ChannelAuthError) throw err;
2543
2831
  this.log({
2544
- level: "error",
2832
+ level: "warn",
2545
2833
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2546
2834
  conversation_id: conv.id,
2547
2835
  message_id: inFlight.evidentMessageId
@@ -2551,7 +2839,7 @@ var ChannelDriver = class {
2551
2839
  inFlight.started = true;
2552
2840
  if (!claimed) {
2553
2841
  this.log({
2554
- level: "info",
2842
+ level: "debug",
2555
2843
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2556
2844
  conversation_id: conv.id,
2557
2845
  message_id: inFlight.evidentMessageId
@@ -2567,18 +2855,22 @@ var ChannelDriver = class {
2567
2855
  conversation_id: conv.id,
2568
2856
  message_id: inFlight.evidentMessageId
2569
2857
  });
2858
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2859
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2570
2860
  try {
2571
2861
  await this.markDone(
2572
2862
  conv.id,
2573
2863
  inFlight.evidentMessageId,
2574
2864
  sessionId,
2575
- inFlight.opencodeMessageId
2865
+ inFlight.opencodeMessageId,
2866
+ title,
2867
+ usage
2576
2868
  );
2577
2869
  } catch (err) {
2578
2870
  if (err instanceof ChannelAuthError) throw err;
2579
2871
  if (err instanceof ChannelTerminalError) {
2580
2872
  this.log({
2581
- level: "error",
2873
+ level: "warn",
2582
2874
  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
2875
  conversation_id: conv.id,
2584
2876
  message_id: inFlight.evidentMessageId
@@ -2588,7 +2880,7 @@ var ChannelDriver = class {
2588
2880
  }
2589
2881
  if (this.now() >= inFlight.deadline) {
2590
2882
  this.log({
2591
- level: "error",
2883
+ level: "warn",
2592
2884
  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
2885
  conversation_id: conv.id,
2594
2886
  message_id: inFlight.evidentMessageId
@@ -2597,7 +2889,7 @@ var ChannelDriver = class {
2597
2889
  return;
2598
2890
  }
2599
2891
  this.log({
2600
- level: "error",
2892
+ level: "warn",
2601
2893
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2602
2894
  conversation_id: conv.id,
2603
2895
  message_id: inFlight.evidentMessageId
@@ -2619,13 +2911,14 @@ var ChannelDriver = class {
2619
2911
  conversation_id: conv.id,
2620
2912
  message_id: inFlight.evidentMessageId
2621
2913
  });
2914
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2622
2915
  try {
2623
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
2916
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
2624
2917
  } catch (err) {
2625
2918
  if (err instanceof ChannelAuthError) throw err;
2626
2919
  if (err instanceof ChannelTerminalError) {
2627
2920
  this.log({
2628
- level: "error",
2921
+ level: "warn",
2629
2922
  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
2923
  conversation_id: conv.id,
2631
2924
  message_id: inFlight.evidentMessageId
@@ -2635,7 +2928,7 @@ var ChannelDriver = class {
2635
2928
  }
2636
2929
  if (this.now() >= inFlight.deadline) {
2637
2930
  this.log({
2638
- level: "error",
2931
+ level: "warn",
2639
2932
  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
2933
  conversation_id: conv.id,
2641
2934
  message_id: inFlight.evidentMessageId
@@ -2644,7 +2937,7 @@ var ChannelDriver = class {
2644
2937
  return;
2645
2938
  }
2646
2939
  this.log({
2647
- level: "error",
2940
+ level: "warn",
2648
2941
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2649
2942
  conversation_id: conv.id,
2650
2943
  message_id: inFlight.evidentMessageId
@@ -2667,7 +2960,7 @@ var ChannelDriver = class {
2667
2960
  const activelyRunning = state === "running" && !awaitingHuman;
2668
2961
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2669
2962
  this.log({
2670
- level: "error",
2963
+ level: "warn",
2671
2964
  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
2965
  conversation_id: conv.id,
2673
2966
  message_id: inFlight.evidentMessageId
@@ -2710,7 +3003,7 @@ var ChannelDriver = class {
2710
3003
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2711
3004
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2712
3005
  this.log({
2713
- level: "info",
3006
+ level: "debug",
2714
3007
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2715
3008
  conversation_id: conv.id,
2716
3009
  message_id: inFlight.evidentMessageId
@@ -2721,9 +3014,7 @@ var ChannelDriver = class {
2721
3014
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2722
3015
  }
2723
3016
  }
2724
- // -------------------------------------------------------------------------
2725
3017
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2726
- // -------------------------------------------------------------------------
2727
3018
  /**
2728
3019
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2729
3020
  *
@@ -2753,7 +3044,7 @@ var ChannelDriver = class {
2753
3044
  this.readoptPollUnresolvedSignalled.delete(id);
2754
3045
  if (cleared || clearedUndeliverable) {
2755
3046
  this.log({
2756
- level: "info",
3047
+ level: "debug",
2757
3048
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2758
3049
  message_id: id
2759
3050
  });
@@ -2766,7 +3057,7 @@ var ChannelDriver = class {
2766
3057
  for (const row of rows) {
2767
3058
  if (!row.opencode_session_id) {
2768
3059
  this.log({
2769
- level: "error",
3060
+ level: "warn",
2770
3061
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2771
3062
  conversation_id: row.conversation_id,
2772
3063
  message_id: row.id
@@ -2783,7 +3074,7 @@ var ChannelDriver = class {
2783
3074
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2784
3075
  if (!res.ok) {
2785
3076
  this.log({
2786
- level: "error",
3077
+ level: "warn",
2787
3078
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2788
3079
  });
2789
3080
  continue;
@@ -2791,7 +3082,7 @@ var ChannelDriver = class {
2791
3082
  const body = await res.json();
2792
3083
  if (!Array.isArray(body)) {
2793
3084
  this.log({
2794
- level: "error",
3085
+ level: "warn",
2795
3086
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2796
3087
  });
2797
3088
  continue;
@@ -2799,7 +3090,7 @@ var ChannelDriver = class {
2799
3090
  messages = body;
2800
3091
  } catch (err) {
2801
3092
  this.log({
2802
- level: "error",
3093
+ level: "warn",
2803
3094
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2804
3095
  });
2805
3096
  continue;
@@ -2832,7 +3123,7 @@ var ChannelDriver = class {
2832
3123
  async readoptOne(sessionId, row, messages, sessionOngoing) {
2833
3124
  if (this.isTracked(sessionId, row.id)) {
2834
3125
  this.log({
2835
- level: "info",
3126
+ level: "debug",
2836
3127
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2837
3128
  conversation_id: row.conversation_id,
2838
3129
  message_id: row.id
@@ -2844,7 +3135,7 @@ var ChannelDriver = class {
2844
3135
  if (state === "done") {
2845
3136
  if (this.doneUndeliverable.has(row.id)) {
2846
3137
  this.log({
2847
- level: "info",
3138
+ level: "debug",
2848
3139
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2849
3140
  conversation_id: row.conversation_id,
2850
3141
  message_id: row.id
@@ -2858,13 +3149,15 @@ var ChannelDriver = class {
2858
3149
  message_id: row.id
2859
3150
  });
2860
3151
  try {
2861
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3152
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3153
+ const usage = messageUsage(messages, ocId ?? "");
3154
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
2862
3155
  } catch (err) {
2863
3156
  if (err instanceof ChannelAuthError) throw err;
2864
3157
  if (err instanceof ChannelTerminalError) {
2865
3158
  this.doneUndeliverable.add(row.id);
2866
3159
  this.log({
2867
- level: "error",
3160
+ level: "warn",
2868
3161
  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
3162
  conversation_id: row.conversation_id,
2870
3163
  message_id: row.id
@@ -2873,7 +3166,7 @@ var ChannelDriver = class {
2873
3166
  return;
2874
3167
  }
2875
3168
  this.log({
2876
- level: "error",
3169
+ level: "warn",
2877
3170
  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
3171
  conversation_id: row.conversation_id,
2879
3172
  message_id: row.id
@@ -2886,6 +3179,7 @@ var ChannelDriver = class {
2886
3179
  }
2887
3180
  if (state === "failed") {
2888
3181
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3182
+ const usage = messageUsage(messages, ocId ?? "");
2889
3183
  this.log({
2890
3184
  level: "error",
2891
3185
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -2893,13 +3187,13 @@ var ChannelDriver = class {
2893
3187
  message_id: row.id
2894
3188
  });
2895
3189
  try {
2896
- await this.markFailed(row.conversation_id, row.id, sessionId, error2);
3190
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
2897
3191
  } catch (err) {
2898
3192
  if (err instanceof ChannelAuthError) throw err;
2899
3193
  if (err instanceof ChannelTerminalError) {
2900
3194
  this.doneUndeliverable.add(row.id);
2901
3195
  this.log({
2902
- level: "error",
3196
+ level: "warn",
2903
3197
  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
3198
  conversation_id: row.conversation_id,
2905
3199
  message_id: row.id
@@ -2908,7 +3202,7 @@ var ChannelDriver = class {
2908
3202
  return;
2909
3203
  }
2910
3204
  this.log({
2911
- level: "error",
3205
+ level: "warn",
2912
3206
  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
3207
  conversation_id: row.conversation_id,
2914
3208
  message_id: row.id
@@ -2921,7 +3215,7 @@ var ChannelDriver = class {
2921
3215
  }
2922
3216
  if (this.dontRedispatch.has(row.id)) {
2923
3217
  this.log({
2924
- level: "info",
3218
+ level: "debug",
2925
3219
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2926
3220
  conversation_id: row.conversation_id,
2927
3221
  message_id: row.id
@@ -2946,7 +3240,7 @@ var ChannelDriver = class {
2946
3240
  }
2947
3241
  if (ongoing === true) {
2948
3242
  this.log({
2949
- level: "info",
3243
+ level: "debug",
2950
3244
  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
3245
  conversation_id: row.conversation_id,
2952
3246
  message_id: row.id
@@ -2954,7 +3248,7 @@ var ChannelDriver = class {
2954
3248
  } else {
2955
3249
  if (shape === "b1") {
2956
3250
  this.log({
2957
- level: "info",
3251
+ level: "debug",
2958
3252
  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
3253
  conversation_id: row.conversation_id,
2960
3254
  message_id: row.id
@@ -2966,7 +3260,7 @@ var ChannelDriver = class {
2966
3260
  return;
2967
3261
  }
2968
3262
  this.log({
2969
- level: "info",
3263
+ level: "debug",
2970
3264
  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
3265
  conversation_id: row.conversation_id,
2972
3266
  message_id: row.id
@@ -2977,7 +3271,7 @@ var ChannelDriver = class {
2977
3271
  const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2978
3272
  if (descendantAlive === true) {
2979
3273
  this.log({
2980
- level: "info",
3274
+ level: "debug",
2981
3275
  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
3276
  conversation_id: row.conversation_id,
2983
3277
  message_id: row.id
@@ -3001,7 +3295,7 @@ var ChannelDriver = class {
3001
3295
  this.readopted.add(row.id);
3002
3296
  this.ensureWatcherRunning(sessionId);
3003
3297
  this.log({
3004
- level: "info",
3298
+ level: "debug",
3005
3299
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
3006
3300
  conversation_id: row.conversation_id,
3007
3301
  message_id: row.id
@@ -3035,7 +3329,7 @@ var ChannelDriver = class {
3035
3329
  async forceReadoptRun(sessionId, row) {
3036
3330
  if (this.stopped) {
3037
3331
  this.log({
3038
- level: "info",
3332
+ level: "debug",
3039
3333
  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
3334
  conversation_id: row.conversation_id,
3041
3335
  message_id: row.id
@@ -3044,7 +3338,7 @@ var ChannelDriver = class {
3044
3338
  }
3045
3339
  if (this.awaitingReadopt.has(row.id)) {
3046
3340
  this.log({
3047
- level: "info",
3341
+ level: "debug",
3048
3342
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3049
3343
  conversation_id: row.conversation_id,
3050
3344
  message_id: row.id
@@ -3054,7 +3348,7 @@ var ChannelDriver = class {
3054
3348
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
3055
3349
  this.dontRedispatch.add(row.id);
3056
3350
  this.log({
3057
- level: "info",
3351
+ level: "debug",
3058
3352
  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
3353
  conversation_id: row.conversation_id,
3060
3354
  message_id: row.id
@@ -3073,17 +3367,20 @@ var ChannelDriver = class {
3073
3367
  message_id: row.id
3074
3368
  });
3075
3369
  this.awaitingReadopt.add(row.id);
3370
+ const readoptConv = this.convForRow(sessionId, row);
3371
+ const readoptMessage = this.queuedMessageForRow(row);
3372
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
3076
3373
  let ocId;
3077
3374
  try {
3078
3375
  ocId = await this.dispatchLocked(
3079
3376
  sessionId,
3080
- () => sendPromptAsync(this.port, sessionId, row.content, options)
3377
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
3081
3378
  );
3082
3379
  } catch (err) {
3083
3380
  this.awaitingReadopt.delete(row.id);
3084
3381
  if (err instanceof ChannelAuthError) throw err;
3085
3382
  this.log({
3086
- level: "error",
3383
+ level: "warn",
3087
3384
  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
3385
  conversation_id: row.conversation_id,
3089
3386
  message_id: row.id
@@ -3094,7 +3391,7 @@ var ChannelDriver = class {
3094
3391
  if (ocId === null) {
3095
3392
  this.awaitingReadopt.delete(row.id);
3096
3393
  this.log({
3097
- level: "error",
3394
+ level: "warn",
3098
3395
  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
3396
  conversation_id: row.conversation_id,
3100
3397
  message_id: row.id
@@ -3102,9 +3399,7 @@ var ChannelDriver = class {
3102
3399
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3103
3400
  return;
3104
3401
  }
3105
- const conv = this.convForRow(sessionId, row);
3106
- const message = this.queuedMessageForRow(row);
3107
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
3402
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
3108
3403
  this.dispatched.add(row.id);
3109
3404
  this.readopted.add(row.id);
3110
3405
  this.awaitingReadopt.delete(row.id);
@@ -3158,7 +3453,8 @@ var ChannelDriver = class {
3158
3453
  opencode_agent: row.opencode_agent,
3159
3454
  opencode_model: row.opencode_model,
3160
3455
  source_message_id: row.source_message_id,
3161
- slack_user_id: row.slack_user_id
3456
+ slack_user_id: row.slack_user_id,
3457
+ attachments: row.attachments ?? null
3162
3458
  };
3163
3459
  }
3164
3460
  /**
@@ -3179,7 +3475,7 @@ var ChannelDriver = class {
3179
3475
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3180
3476
  this.dontRedispatch.add(evidentMessageId);
3181
3477
  this.log({
3182
- level: "info",
3478
+ level: "debug",
3183
3479
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3184
3480
  conversation_id: watcher.conv.id,
3185
3481
  message_id: evidentMessageId
@@ -3319,6 +3615,51 @@ var ChannelDriver = class {
3319
3615
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3320
3616
  return parent;
3321
3617
  }
3618
+ /**
3619
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
3620
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
3621
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
3622
+ * has no watcher) can use it. `conversationId` is passed only for log context.
3623
+ * Best-effort:
3624
+ * - a resolved NON-EMPTY title is cached and terminal (a real session name
3625
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
3626
+ * - while the title is still absent/empty we do NOT latch it — OpenCode names
3627
+ * sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
3628
+ * must leave the cache unresolved and re-fetch on the next need so a later
3629
+ * call (e.g. at `done`) picks up the name assigned in the meantime. Such a
3630
+ * call returns `null` (omit the title on THIS PATCH) without caching;
3631
+ * - a failed request likewise leaves the cache unresolved (retry next need)
3632
+ * and returns `null` — it must NEVER throw or block completion.
3633
+ * A failure is logged with agent/session context (no silent catch).
3634
+ */
3635
+ async resolveSessionTitle(sessionId, conversationId) {
3636
+ const cached = this.sessionTitles.get(sessionId);
3637
+ if (cached != null) return cached;
3638
+ try {
3639
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
3640
+ if (res.ok) {
3641
+ const body = await res.json();
3642
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
3643
+ if (title.length > 0) {
3644
+ this.sessionTitles.set(sessionId, title);
3645
+ return title;
3646
+ }
3647
+ return null;
3648
+ }
3649
+ this.log({
3650
+ level: "debug",
3651
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
3652
+ conversation_id: conversationId
3653
+ });
3654
+ } catch (err) {
3655
+ this.log({
3656
+ level: "debug",
3657
+ 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)}`,
3658
+ conversation_id: conversationId
3659
+ });
3660
+ }
3661
+ return null;
3662
+ }
3322
3663
  /**
3323
3664
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3324
3665
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -3362,7 +3703,7 @@ var ChannelDriver = class {
3362
3703
  const sessions = await listSessions(this.port);
3363
3704
  if (!sessions) {
3364
3705
  this.log({
3365
- level: "error",
3706
+ level: "warn",
3366
3707
  message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3367
3708
  });
3368
3709
  return null;
@@ -3446,9 +3787,7 @@ var ChannelDriver = class {
3446
3787
  }
3447
3788
  return inFlight.sort(byOldest)[0];
3448
3789
  }
3449
- // -------------------------------------------------------------------------
3450
3790
  // Evident API calls (combinedAuth thread routes)
3451
- // -------------------------------------------------------------------------
3452
3791
  async getPendingConversations() {
3453
3792
  const res = await this.fetchImpl(
3454
3793
  `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
@@ -3528,7 +3867,7 @@ var ChannelDriver = class {
3528
3867
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3529
3868
  * retry vehicle for the swap-to-running.
3530
3869
  */
3531
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
3870
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3532
3871
  const res = await this.fetchImpl(
3533
3872
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3534
3873
  {
@@ -3537,7 +3876,8 @@ var ChannelDriver = class {
3537
3876
  body: JSON.stringify({
3538
3877
  status: "processing",
3539
3878
  opencode_session_id: sessionId,
3540
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3879
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3880
+ ...title ? { title } : {}
3541
3881
  })
3542
3882
  }
3543
3883
  );
@@ -3576,7 +3916,7 @@ var ChannelDriver = class {
3576
3916
  * watcher retries next tick within the
3577
3917
  * deadline, Finding 4).
3578
3918
  */
3579
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
3919
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
3580
3920
  const res = await this.fetchImpl(
3581
3921
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3582
3922
  {
@@ -3585,7 +3925,9 @@ var ChannelDriver = class {
3585
3925
  body: JSON.stringify({
3586
3926
  status: "done",
3587
3927
  opencode_session_id: sessionId,
3588
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
3928
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
3929
+ ...title ? { title } : {},
3930
+ ...usage ? usage : {}
3589
3931
  })
3590
3932
  }
3591
3933
  );
@@ -3603,10 +3945,11 @@ var ChannelDriver = class {
3603
3945
  * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3604
3946
  * failure reason reaches the channel.
3605
3947
  */
3606
- async markFailed(conversationId, messageId, sessionId, error2) {
3948
+ async markFailed(conversationId, messageId, sessionId, error2, usage) {
3607
3949
  const body = { status: "failed" };
3608
3950
  if (sessionId !== void 0) body.opencode_session_id = sessionId;
3609
3951
  if (error2 !== void 0) body.error = error2;
3952
+ if (usage) Object.assign(body, usage);
3610
3953
  await this.callWithRetry(
3611
3954
  "marking message as failed",
3612
3955
  () => this.fetchImpl(
@@ -3646,7 +3989,7 @@ var ChannelDriver = class {
3646
3989
  );
3647
3990
  if (!res.ok) {
3648
3991
  this.log({
3649
- level: "error",
3992
+ level: "warn",
3650
3993
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3651
3994
  conversation_id: conversationId,
3652
3995
  message_id: messageId
@@ -3656,7 +3999,7 @@ var ChannelDriver = class {
3656
3999
  return true;
3657
4000
  } catch (err) {
3658
4001
  this.log({
3659
- level: "error",
4002
+ level: "warn",
3660
4003
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3661
4004
  conversation_id: conversationId,
3662
4005
  message_id: messageId
@@ -3718,9 +4061,7 @@ var ChannelDriver = class {
3718
4061
  return false;
3719
4062
  }
3720
4063
  }
3721
- // -------------------------------------------------------------------------
3722
4064
  // Retry wrapper
3723
- // -------------------------------------------------------------------------
3724
4065
  /**
3725
4066
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3726
4067
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3919,7 +4260,7 @@ async function resolveAgentIdFromKey(authHeader) {
3919
4260
  if (!response.ok) {
3920
4261
  const serverMessage = await readErrorMessage(response);
3921
4262
  return {
3922
- error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
4263
+ error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
3923
4264
  };
3924
4265
  }
3925
4266
  const data = await response.json();
@@ -3927,11 +4268,11 @@ async function resolveAgentIdFromKey(authHeader) {
3927
4268
  return { agent_id: data.agent_id };
3928
4269
  }
3929
4270
  return {
3930
- error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
4271
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
3931
4272
  };
3932
4273
  } catch (error2) {
3933
4274
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3934
- return { error: `Failed to resolve agent from key: ${message}` };
4275
+ return { error: `Failed to resolve runner from key: ${message}` };
3935
4276
  }
3936
4277
  }
3937
4278
  async function notifyAgentDisconnected(agentId, authHeader) {
@@ -3967,12 +4308,12 @@ async function getAgentInfo(agentId, authHeader) {
3967
4308
  const serverMessage = await readErrorMessage(response);
3968
4309
  return {
3969
4310
  valid: false,
3970
- error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
4311
+ error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
3971
4312
  };
3972
4313
  }
3973
4314
  if (response.status === 404) {
3974
4315
  const serverMessage = await readErrorMessage(response);
3975
- return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
4316
+ return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
3976
4317
  }
3977
4318
  if (!response.ok) {
3978
4319
  const serverMessage = await readErrorMessage(response);
@@ -3985,13 +4326,13 @@ async function getAgentInfo(agentId, authHeader) {
3985
4326
  if (agent.agent_type !== "local") {
3986
4327
  return {
3987
4328
  valid: false,
3988
- error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
4329
+ error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
3989
4330
  };
3990
4331
  }
3991
4332
  return { valid: true, agent };
3992
4333
  } catch (error2) {
3993
4334
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3994
- return { valid: false, error: `Failed to validate agent: ${message}` };
4335
+ return { valid: false, error: `Failed to validate runner: ${message}` };
3995
4336
  }
3996
4337
  }
3997
4338
 
@@ -4000,23 +4341,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
4000
4341
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
4001
4342
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
4002
4343
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
4003
- function log2(state, message, isError = false) {
4344
+ function resolveLogLevel(options) {
4345
+ const accepted = Object.keys(LOG_LEVELS);
4346
+ const validate = (value, source) => {
4347
+ const normalized = value.trim().toLowerCase();
4348
+ if (!accepted.includes(normalized)) {
4349
+ throw new Error(
4350
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
4351
+ );
4352
+ }
4353
+ return normalized;
4354
+ };
4355
+ if (options.logLevel !== void 0) {
4356
+ return validate(options.logLevel, " (--log-level)");
4357
+ }
4358
+ if (options.verbose) {
4359
+ return "debug";
4360
+ }
4361
+ const env = process.env.EVIDENT_LOG_LEVEL;
4362
+ if (env !== void 0 && env !== "") {
4363
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
4364
+ }
4365
+ return "info";
4366
+ }
4367
+ function meetsThreshold(state, level) {
4368
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
4369
+ }
4370
+ function log2(state, message, level = "info") {
4371
+ if (!meetsThreshold(state, level)) return;
4004
4372
  if (state.json) {
4005
4373
  console.log(
4006
4374
  JSON.stringify({
4007
4375
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4008
- level: isError ? "error" : "info",
4376
+ level,
4009
4377
  message
4010
4378
  })
4011
4379
  );
4012
4380
  } else if (!state.interactive) {
4013
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
4381
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
4014
4382
  console.log(`${prefix} ${message}`);
4015
4383
  }
4016
4384
  }
4017
4385
  function logActivity(state, entry) {
4386
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
4387
+ if (!meetsThreshold(state, level)) return;
4018
4388
  const fullEntry = {
4019
4389
  ...entry,
4390
+ level,
4020
4391
  timestamp: /* @__PURE__ */ new Date()
4021
4392
  };
4022
4393
  state.activityLog.push(fullEntry);
@@ -4025,9 +4396,9 @@ function logActivity(state, entry) {
4025
4396
  }
4026
4397
  if (!state.interactive) {
4027
4398
  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);
4399
+ log2(state, entry.error ?? "Unknown error", level);
4400
+ } else if (entry.message) {
4401
+ log2(state, entry.message, level);
4031
4402
  }
4032
4403
  }
4033
4404
  }
@@ -4226,7 +4597,7 @@ function scheduleSessionCleanup(state, driver, options) {
4226
4597
  process.env
4227
4598
  );
4228
4599
  for (const warning2 of config2.warnings) {
4229
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
4600
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4230
4601
  }
4231
4602
  if (!config2.enabled) return;
4232
4603
  logActivity(state, {
@@ -4248,7 +4619,7 @@ async function notifyOffline(state) {
4248
4619
  }
4249
4620
  const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
4250
4621
  if (result.ok) {
4251
- log2(state, "Notified Evident the agent is going offline");
4622
+ log2(state, "Notified Evident the runner is going offline");
4252
4623
  } else {
4253
4624
  logActivity(state, {
4254
4625
  type: "error",
@@ -4298,6 +4669,20 @@ async function cleanup(state, opts = {}) {
4298
4669
  }
4299
4670
  async function run(options) {
4300
4671
  const interactive = isInteractive(options.json);
4672
+ let logLevel;
4673
+ try {
4674
+ logLevel = resolveLogLevel(options);
4675
+ } catch (error2) {
4676
+ const message = error2 instanceof Error ? error2.message : String(error2);
4677
+ if (options.json) {
4678
+ console.log(JSON.stringify({ status: "error", error: message }));
4679
+ } else {
4680
+ printError(message);
4681
+ }
4682
+ await shutdownTelemetry();
4683
+ process.exit(1);
4684
+ return;
4685
+ }
4301
4686
  const state = {
4302
4687
  agentId: options.agent || "",
4303
4688
  agentName: null,
@@ -4306,6 +4691,7 @@ async function run(options) {
4306
4691
  idleTimeout: options.idleTimeout ?? null,
4307
4692
  json: options.json ?? false,
4308
4693
  interactive,
4694
+ logLevel,
4309
4695
  connected: false,
4310
4696
  opencodeConnected: false,
4311
4697
  opencodeVersion: null,
@@ -4323,8 +4709,8 @@ async function run(options) {
4323
4709
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4324
4710
  log2(
4325
4711
  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
4712
+ "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.",
4713
+ "warn"
4328
4714
  );
4329
4715
  }
4330
4716
  const handleSignal = async () => {
@@ -4367,15 +4753,15 @@ async function run(options) {
4367
4753
  const resolved = await resolveAgentIdFromKey(state.authHeader);
4368
4754
  if (resolved.agent_id) {
4369
4755
  state.agentId = resolved.agent_id;
4370
- log2(state, `Resolved agent ID from key: ${state.agentId}`);
4756
+ log2(state, `Resolved runner ID from key: ${state.agentId}`);
4371
4757
  if (state.interactive && !state.json) {
4372
4758
  logActivity(state, {
4373
4759
  type: "info",
4374
- message: `Agent ID resolved from key: ${state.agentId}`
4760
+ message: `Runner ID resolved from key: ${state.agentId}`
4375
4761
  });
4376
4762
  }
4377
4763
  } else {
4378
- printError(resolved.error || "Failed to resolve agent ID from key");
4764
+ printError(resolved.error || "Failed to resolve runner ID from key");
4379
4765
  process.exit(1);
4380
4766
  }
4381
4767
  } else {
@@ -4403,7 +4789,7 @@ async function run(options) {
4403
4789
  console.log(chalk6.bold("Evident Run"));
4404
4790
  console.log(chalk6.dim("-".repeat(40)));
4405
4791
  }
4406
- const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
4792
+ const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
4407
4793
  let validation = await getAgentInfo(state.agentId, state.authHeader);
4408
4794
  if (!validation.valid && validation.authFailed && interactive) {
4409
4795
  spinner?.fail("Authentication failed");
@@ -4415,14 +4801,14 @@ async function run(options) {
4415
4801
  "Login successful! Retrying..."
4416
4802
  );
4417
4803
  state.authHeader = getAuthHeader(credentials2);
4418
- spinner?.start("Validating agent...");
4804
+ spinner?.start("Validating runner...");
4419
4805
  validation = await getAgentInfo(state.agentId, state.authHeader);
4420
4806
  }
4421
4807
  if (!validation.valid) {
4422
- spinner?.fail(`Agent validation failed: ${validation.error}`);
4808
+ spinner?.fail(`Runner validation failed: ${validation.error}`);
4423
4809
  throw new Error(validation.error);
4424
4810
  }
4425
- spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
4811
+ spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
4426
4812
  state.agentName = validation.agent.name;
4427
4813
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
4428
4814
  try {
@@ -4440,9 +4826,9 @@ async function run(options) {
4440
4826
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4441
4827
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4442
4828
  if (versionWarning) {
4443
- log2(state, versionWarning, false);
4829
+ log2(state, versionWarning, "warn");
4444
4830
  if (state.interactive && !state.json) {
4445
- logActivity(state, { type: "info", message: versionWarning });
4831
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
4446
4832
  }
4447
4833
  }
4448
4834
  } catch (error2) {
@@ -4457,11 +4843,17 @@ async function run(options) {
4457
4843
  getAuthHeader: () => state.authHeader,
4458
4844
  conversationFilter: state.conversationFilter,
4459
4845
  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
- })
4846
+ log: (entry) => (
4847
+ // Thread the driver's real level straight through so `debug`/`warn`
4848
+ // survive the sink filter (they no longer collapse to info). `type`
4849
+ // stays the coarse error/non-error split the activity log renders with.
4850
+ logActivity(state, {
4851
+ type: entry.level === "error" ? "error" : "info",
4852
+ level: entry.level,
4853
+ message: entry.message,
4854
+ error: entry.level === "error" ? entry.message : void 0
4855
+ })
4856
+ )
4465
4857
  });
4466
4858
  state.channelDriver = channelDriver;
4467
4859
  const connection = new RunnerConnection({
@@ -4475,7 +4867,7 @@ async function run(options) {
4475
4867
  state.agentId = agentId;
4476
4868
  logActivity(state, {
4477
4869
  type: "info",
4478
- message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
4870
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
4479
4871
  });
4480
4872
  emitAgentConnected(state.agentId, {
4481
4873
  port: state.port,
@@ -4615,7 +5007,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4615
5007
  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
5008
  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
5009
  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(
5010
+ program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Runner ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
5011
+ "--log-level <level>",
5012
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
5013
+ ).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
5014
  "--session-cleanup-max-age <duration>",
4620
5015
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4621
5016
  ).option(
@@ -4629,6 +5024,9 @@ program.command("run").description("Connect to Evident and process messages").op
4629
5024
  run({
4630
5025
  agent: options.agent,
4631
5026
  port: parseInt(options.port, 10),
5027
+ // Raw string — validation/precedence is single-sourced in run.ts's
5028
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
5029
+ logLevel: options.logLevel,
4632
5030
  verbose: options.verbose,
4633
5031
  conversation: options.conversation,
4634
5032
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,