@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.0d1732f

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
@@ -11,8 +11,8 @@ import chalk2 from "chalk";
11
11
 
12
12
  // src/lib/config.ts
13
13
  import Conf from "conf";
14
- import { homedir } from "os";
15
- import { join } from "path";
14
+ import { chmodSync, existsSync, statSync } from "fs";
15
+ import { dirname } from "path";
16
16
  var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
17
17
  var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
18
18
  var defaults = {
@@ -47,8 +47,35 @@ var credentials = new Conf({
47
47
  projectName: "evident",
48
48
  projectSuffix: "",
49
49
  configName: "credentials",
50
- defaults: {}
50
+ defaults: {},
51
+ configFileMode: 384
51
52
  });
53
+ var CREDENTIALS_FILE_MODE = 384;
54
+ var CREDENTIALS_DIR_MODE = 448;
55
+ var permissionWarningEmitted = false;
56
+ function hardenCredentialsPermissions() {
57
+ if (process.platform === "win32") {
58
+ return;
59
+ }
60
+ const file = credentials.path;
61
+ for (const [path, mode] of [
62
+ [file, CREDENTIALS_FILE_MODE],
63
+ [dirname(file), CREDENTIALS_DIR_MODE]
64
+ ]) {
65
+ try {
66
+ if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
67
+ chmodSync(path, mode);
68
+ }
69
+ } catch (err) {
70
+ if (!permissionWarningEmitted) {
71
+ permissionWarningEmitted = true;
72
+ console.error(
73
+ `[config] could not restrict permissions on ${path}; the credentials file may be readable by other users on this machine: ${err instanceof Error ? err.message : String(err)}`
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
52
79
  function getApiUrlConfig() {
53
80
  return getApiUrl();
54
81
  }
@@ -59,6 +86,7 @@ function credentialsKey() {
59
86
  return getApiUrl();
60
87
  }
61
88
  function getCredentials() {
89
+ hardenCredentialsPermissions();
62
90
  const byEndpoint = credentials.get("byEndpoint") ?? {};
63
91
  return byEndpoint[credentialsKey()] ?? {};
64
92
  }
@@ -70,14 +98,17 @@ function setCredentials(creds) {
70
98
  expiresAt: creds.expiresAt
71
99
  };
72
100
  credentials.set("byEndpoint", byEndpoint);
101
+ hardenCredentialsPermissions();
73
102
  }
74
103
  function clearCredentials() {
75
104
  const byEndpoint = credentials.get("byEndpoint") ?? {};
76
105
  delete byEndpoint[credentialsKey()];
77
106
  credentials.set("byEndpoint", byEndpoint);
107
+ hardenCredentialsPermissions();
78
108
  }
79
109
  function clearAllCredentials() {
80
110
  credentials.clear();
111
+ hardenCredentialsPermissions();
81
112
  }
82
113
  function getCliName() {
83
114
  const argv1 = process.argv[1] || "";
@@ -285,14 +316,14 @@ function blank() {
285
316
  console.log();
286
317
  }
287
318
  function waitForEnter(prompt = "Press Enter to continue...") {
288
- return new Promise((resolve2) => {
319
+ return new Promise((resolve3) => {
289
320
  process.stdout.write(chalk.dim(prompt));
290
321
  const handler = () => {
291
322
  process.stdin.removeListener("data", handler);
292
323
  process.stdin.setRawMode?.(false);
293
324
  process.stdin.pause();
294
325
  console.log();
295
- resolve2();
326
+ resolve3();
296
327
  };
297
328
  if (process.stdin.isTTY) {
298
329
  process.stdin.setRawMode?.(true);
@@ -302,7 +333,7 @@ function waitForEnter(prompt = "Press Enter to continue...") {
302
333
  });
303
334
  }
304
335
  function sleep(ms) {
305
- return new Promise((resolve2) => setTimeout(resolve2, ms));
336
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
306
337
  }
307
338
 
308
339
  // src/commands/login.ts
@@ -373,22 +404,23 @@ async function deviceFlowLogin(options) {
373
404
  }
374
405
  async function tokenLogin() {
375
406
  console.log("Token login mode.");
376
- console.log("Visit your Evident dashboard to generate a CLI token.");
407
+ console.log("Run `evident login` on a machine with a browser to get a token.");
408
+ console.log("Manage or revoke existing tokens under Settings \u2192 CLI tokens.");
377
409
  blank();
378
410
  process.stdout.write("Paste token: ");
379
- const token = await new Promise((resolve2) => {
411
+ const token = await new Promise((resolve3) => {
380
412
  let data = "";
381
413
  process.stdin.setEncoding("utf8");
382
414
  process.stdin.on("data", (chunk) => {
383
415
  data += chunk;
384
416
  });
385
417
  process.stdin.on("end", () => {
386
- resolve2(data.trim());
418
+ resolve3(data.trim());
387
419
  });
388
420
  if (process.stdin.isTTY) {
389
421
  process.stdin.once("data", (chunk) => {
390
422
  process.stdin.pause();
391
- resolve2(chunk.toString().trim());
423
+ resolve3(chunk.toString().trim());
392
424
  });
393
425
  process.stdin.resume();
394
426
  }
@@ -467,9 +499,9 @@ async function whoami() {
467
499
  }
468
500
 
469
501
  // src/commands/run.ts
502
+ import { homedir as homedir2 } from "os";
503
+ import { isAbsolute as isAbsolute2, join as join2, parse, resolve as resolvePath } from "path";
470
504
  import chalk6 from "chalk";
471
- import ora3 from "ora";
472
- import { select as select3 } from "@inquirer/prompts";
473
505
 
474
506
  // ../../packages/types/src/telemetry/index.ts
475
507
  var TelemetryEventTypes = {
@@ -485,6 +517,10 @@ var TelemetryEventTypes = {
485
517
  var MAX_FRAME_BYTES = 256 * 1024;
486
518
  var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
487
519
 
520
+ // ../../packages/types/src/runner-files.ts
521
+ var MAX_FILE_PUSH_BYTES = 64 * 1024;
522
+ var MAX_FILE_SYNC_DIRECTORIES = 16;
523
+
488
524
  // ../../packages/types/src/logging/index.ts
489
525
  var CORRELATION_ID_HEADER = "x-evident-correlation-id";
490
526
  function log(level, event, fields) {
@@ -499,6 +535,12 @@ function log(level, event, fields) {
499
535
  );
500
536
  }
501
537
  }
538
+ function errorFields(err) {
539
+ if (err instanceof Error) {
540
+ return { error: err.message, error_name: err.name };
541
+ }
542
+ return { error: String(err) };
543
+ }
502
544
  function stripQuery(url) {
503
545
  try {
504
546
  return new URL(url).pathname;
@@ -508,6 +550,10 @@ function stripQuery(url) {
508
550
  }
509
551
  }
510
552
 
553
+ // src/commands/run.ts
554
+ import ora3 from "ora";
555
+ import { select as select3 } from "@inquirer/prompts";
556
+
511
557
  // src/lib/telemetry.ts
512
558
  var CLI_VERSION = (true ? "3.0.0" : void 0) ?? process.env.npm_package_version ?? "unknown";
513
559
  function getCliVersion() {
@@ -645,14 +691,27 @@ var EventTypes = {
645
691
  // CLI lifecycle
646
692
  CLI_STARTED: "cli.started",
647
693
  CLI_COMMAND: "cli.command",
648
- CLI_ERROR: "cli.error"
694
+ CLI_ERROR: "cli.error",
695
+ // Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
696
+ // names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
697
+ DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
698
+ DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
649
699
  };
650
700
 
651
701
  // src/lib/auth.ts
652
702
  async function getAuthCredentials() {
703
+ const runnerKey = process.env.EVIDENT_RUNNER_KEY;
653
704
  const agentKey = process.env.EVIDENT_AGENT_KEY;
705
+ if (runnerKey) {
706
+ return {
707
+ token: runnerKey,
708
+ authType: "agent_key",
709
+ keySource: "runner_key",
710
+ notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
711
+ };
712
+ }
654
713
  if (agentKey) {
655
- return { token: agentKey, authType: "agent_key" };
714
+ return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
656
715
  }
657
716
  const userToken = process.env.EVIDENT_TOKEN;
658
717
  if (userToken) {
@@ -706,7 +765,7 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
706
765
  if (health.healthy) {
707
766
  return health;
708
767
  }
709
- await new Promise((resolve2) => setTimeout(resolve2, 1e3));
768
+ await new Promise((resolve3) => setTimeout(resolve3, 1e3));
710
769
  }
711
770
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
712
771
  }
@@ -721,7 +780,7 @@ function buildOpenCodeVersionWarning(version2) {
721
780
  if (isQueueValidatedVersion(version2)) return null;
722
781
  const detected = version2 ? `v${version2}` : "unknown";
723
782
  const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
724
- return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
783
+ return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
725
784
  }
726
785
 
727
786
  // src/lib/opencode/process.ts
@@ -1013,6 +1072,12 @@ async function promptOpenCodeInstall(interactive) {
1013
1072
  return action;
1014
1073
  }
1015
1074
 
1075
+ // src/lib/opencode/provider-check.ts
1076
+ function buildNoProviderWarning(hasProvider) {
1077
+ if (hasProvider !== false) return null;
1078
+ return "Warning: opencode has no authenticated model provider configured, so it won't be able to answer prompts. Run `opencode auth login` to set one up (see https://opencode.ai for details).";
1079
+ }
1080
+
1016
1081
  // src/lib/opencode/session.ts
1017
1082
  function opencodeBase(port) {
1018
1083
  return `http://127.0.0.1:${port}`;
@@ -1173,17 +1238,128 @@ async function createOpenCodeSession(port, directory) {
1173
1238
  const data = await response.json();
1174
1239
  return data.id;
1175
1240
  }
1241
+ async function getModelAttachmentCapability(port, model) {
1242
+ try {
1243
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1244
+ if (!res.ok) {
1245
+ console.error(
1246
+ `[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
1247
+ );
1248
+ return null;
1249
+ }
1250
+ const body = await res.json();
1251
+ const providers = Array.isArray(body?.providers) ? body.providers : null;
1252
+ if (!providers) {
1253
+ console.error(
1254
+ `[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
1255
+ );
1256
+ return null;
1257
+ }
1258
+ const slash = model ? model.indexOf("/") : -1;
1259
+ const providerId = slash > 0 ? model.slice(0, slash) : void 0;
1260
+ let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
1261
+ const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
1262
+ let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
1263
+ if (!provider && !providerId) {
1264
+ const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
1265
+ if (defaultProviderIds.length === 1) {
1266
+ provider = providers.find((p) => p?.id === defaultProviderIds[0]);
1267
+ }
1268
+ }
1269
+ if (!provider || !provider.models) return null;
1270
+ if (!modelId && defaults2 && typeof provider.id === "string") {
1271
+ const def = defaults2[provider.id];
1272
+ if (typeof def === "string") modelId = def;
1273
+ }
1274
+ if (!modelId) {
1275
+ if (providerId) {
1276
+ const keys = Object.keys(provider.models);
1277
+ if (keys.length === 1) modelId = keys[0];
1278
+ }
1279
+ if (!modelId) return null;
1280
+ }
1281
+ const entry = provider.models[modelId];
1282
+ if (!entry || typeof entry !== "object") return null;
1283
+ if (entry.capabilities && typeof entry.capabilities === "object") {
1284
+ if (typeof entry.capabilities.attachment === "boolean") {
1285
+ return entry.capabilities.attachment;
1286
+ }
1287
+ }
1288
+ return typeof entry.attachment === "boolean" ? entry.attachment : null;
1289
+ } catch (err) {
1290
+ console.error(
1291
+ `[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1292
+ );
1293
+ return null;
1294
+ }
1295
+ }
1296
+ async function buildFileParts(attachments, capable) {
1297
+ const outcomes = [];
1298
+ const parts = [];
1299
+ const capabilityUnknown = capable === null;
1300
+ if (capable !== true) {
1301
+ for (const a of attachments.inputs) {
1302
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
1303
+ }
1304
+ return { parts, outcomes, capabilityUnknown };
1305
+ }
1306
+ for (const a of attachments.inputs) {
1307
+ let dataUrl = null;
1308
+ try {
1309
+ dataUrl = await attachments.fetchDataUrl(a.index);
1310
+ } catch (err) {
1311
+ console.error(
1312
+ `[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
1313
+ );
1314
+ dataUrl = null;
1315
+ }
1316
+ if (dataUrl !== null && typeof dataUrl === "object") {
1317
+ outcomes.push({
1318
+ index: a.index,
1319
+ mime: a.mime,
1320
+ filename: a.filename,
1321
+ status: "failed",
1322
+ reason: "needs_reauth"
1323
+ });
1324
+ continue;
1325
+ }
1326
+ if (dataUrl == null) {
1327
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
1328
+ continue;
1329
+ }
1330
+ parts.push({
1331
+ type: "file",
1332
+ mime: a.mime,
1333
+ url: dataUrl,
1334
+ ...a.filename ? { filename: a.filename } : {}
1335
+ });
1336
+ outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
1337
+ }
1338
+ return { parts, outcomes, capabilityUnknown };
1339
+ }
1176
1340
  function messageText(m) {
1177
1341
  if (!m || !Array.isArray(m.parts)) return "";
1178
1342
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1179
1343
  }
1180
- async function sendPromptAsync(port, sessionId, content, options) {
1344
+ async function sendPromptAsync(port, sessionId, content, options, attachments) {
1181
1345
  const before = await getSessionMessages(port, sessionId);
1182
1346
  const knownUserIds = new Set(
1183
1347
  (before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
1184
1348
  );
1349
+ const parts = [{ type: "text", text: content }];
1350
+ let pendingOutcomes = null;
1351
+ if (attachments && attachments.inputs.length > 0) {
1352
+ const capable = await getModelAttachmentCapability(port, options?.model);
1353
+ const {
1354
+ parts: fileParts,
1355
+ outcomes,
1356
+ capabilityUnknown
1357
+ } = await buildFileParts(attachments, capable);
1358
+ parts.push(...fileParts);
1359
+ if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
1360
+ }
1185
1361
  const body = {
1186
- parts: [{ type: "text", text: content }]
1362
+ parts
1187
1363
  };
1188
1364
  if (options?.agent) {
1189
1365
  body.agent = options.agent;
@@ -1222,10 +1398,13 @@ async function sendPromptAsync(port, sessionId, content, options) {
1222
1398
  best = { id, created };
1223
1399
  }
1224
1400
  }
1225
- if (best) return best.id;
1401
+ if (best) {
1402
+ if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
1403
+ return best.id;
1404
+ }
1226
1405
  }
1227
1406
  if (attempt < READ_BACK_ATTEMPTS - 1) {
1228
- await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
1407
+ await new Promise((resolve3) => setTimeout(resolve3, READ_BACK_DELAY_MS));
1229
1408
  }
1230
1409
  }
1231
1410
  return null;
@@ -1271,6 +1450,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
1271
1450
  }
1272
1451
  return lastOk ?? last;
1273
1452
  }
1453
+ function messageUsage(messages, userMessageId) {
1454
+ if (!messages || messages.length === 0) return null;
1455
+ const byParentAll = messages.filter(
1456
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1457
+ );
1458
+ const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
1459
+ const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
1460
+ let correlated;
1461
+ if (byParent.length > 0) {
1462
+ correlated = byParent;
1463
+ } else {
1464
+ const reply = findAssistantReplyAfter(messages, userMessageId);
1465
+ correlated = reply ? [reply] : [];
1466
+ }
1467
+ if (correlated.length === 0) return null;
1468
+ let sawAnyUsage = false;
1469
+ let inputSum = 0;
1470
+ let outputSum = 0;
1471
+ let reasoningSum = 0;
1472
+ let cacheReadSum = 0;
1473
+ let cacheWriteSum = 0;
1474
+ let costSum = 0;
1475
+ let sawCost = false;
1476
+ let modelId = null;
1477
+ let providerId = null;
1478
+ for (const m of correlated) {
1479
+ const info = m.info;
1480
+ if (!info) continue;
1481
+ const tokens = info.tokens;
1482
+ if (tokens) {
1483
+ sawAnyUsage = true;
1484
+ inputSum += tokens.input ?? 0;
1485
+ outputSum += tokens.output ?? 0;
1486
+ reasoningSum += tokens.reasoning ?? 0;
1487
+ cacheReadSum += tokens.cache?.read ?? 0;
1488
+ cacheWriteSum += tokens.cache?.write ?? 0;
1489
+ }
1490
+ if (typeof info.cost === "number") {
1491
+ sawAnyUsage = true;
1492
+ sawCost = true;
1493
+ costSum += info.cost;
1494
+ }
1495
+ if (typeof info.modelID === "string") {
1496
+ sawAnyUsage = true;
1497
+ modelId = info.modelID;
1498
+ }
1499
+ if (typeof info.providerID === "string") {
1500
+ sawAnyUsage = true;
1501
+ providerId = info.providerID;
1502
+ }
1503
+ }
1504
+ if (!sawAnyUsage) return null;
1505
+ return {
1506
+ usage_provider_id: providerId,
1507
+ usage_model_id: modelId,
1508
+ usage_tokens_input: inputSum,
1509
+ usage_tokens_output: outputSum,
1510
+ usage_tokens_reasoning: reasoningSum,
1511
+ usage_tokens_cache_read: cacheReadSum,
1512
+ usage_tokens_cache_write: cacheWriteSum,
1513
+ // NULL means "OpenCode never reported a cost" (never inferred from
1514
+ // tokens) — distinct from a genuine 0-cost turn, which would set
1515
+ // `sawCost` true with `costSum === 0`.
1516
+ usage_cost_usd: sawCost ? costSum : null
1517
+ };
1518
+ }
1274
1519
  function messageRunState(messages, userMessageId) {
1275
1520
  if (!messages || messages.length === 0) return "unknown";
1276
1521
  const hasUser = messages.some((m) => idOf(m) === userMessageId);
@@ -1306,6 +1551,37 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1306
1551
  (m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
1307
1552
  );
1308
1553
  }
1554
+ async function hasAnyConfiguredProvider(port) {
1555
+ try {
1556
+ const res = await fetch(`${opencodeBase(port)}/config/providers`);
1557
+ if (!res.ok) {
1558
+ console.error(
1559
+ `[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
1560
+ );
1561
+ return null;
1562
+ }
1563
+ const body = await res.json();
1564
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1565
+ console.error(
1566
+ `[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
1567
+ );
1568
+ return null;
1569
+ }
1570
+ const defaults2 = body.default;
1571
+ if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
1572
+ console.error(
1573
+ `[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
1574
+ );
1575
+ return null;
1576
+ }
1577
+ return Object.keys(defaults2).length > 0;
1578
+ } catch (err) {
1579
+ console.error(
1580
+ `[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
1581
+ );
1582
+ return null;
1583
+ }
1584
+ }
1309
1585
 
1310
1586
  // src/lib/opencode/session-cleanup.ts
1311
1587
  var DURATION_UNIT_MS = {
@@ -1464,10 +1740,11 @@ var StreamForwarder = class {
1464
1740
  * Abort every in-flight stream (e.g. on WebSocket close).
1465
1741
  */
1466
1742
  abortAll() {
1467
- for (const stream of this.inflight.values()) {
1743
+ for (const [sid, stream] of this.inflight.entries()) {
1468
1744
  try {
1469
1745
  stream.abort();
1470
- } catch {
1746
+ } catch (err) {
1747
+ log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
1471
1748
  }
1472
1749
  }
1473
1750
  this.inflight.clear();
@@ -1501,12 +1778,12 @@ var StreamForwarder = class {
1501
1778
  let endBody;
1502
1779
  if (has_body) {
1503
1780
  const chunks = [];
1504
- bodyPromise = new Promise((resolve2) => {
1781
+ bodyPromise = new Promise((resolve3) => {
1505
1782
  pushBody = (buf) => {
1506
1783
  chunks.push(buf);
1507
1784
  };
1508
1785
  endBody = () => {
1509
- resolve2(Buffer.concat(chunks));
1786
+ resolve3(Buffer.concat(chunks));
1510
1787
  };
1511
1788
  });
1512
1789
  }
@@ -1617,31 +1894,20 @@ function connectTunnel(options) {
1617
1894
  onConnected,
1618
1895
  onDisconnected,
1619
1896
  onError,
1620
- onRequest,
1621
1897
  onResponse,
1622
1898
  onInfo,
1623
1899
  onDrainPing
1624
1900
  } = options;
1625
1901
  const tunnelUrl = getTunnelUrlConfig();
1626
1902
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1627
- return new Promise((resolve2, reject) => {
1903
+ return new Promise((resolve3, reject) => {
1628
1904
  const ws = new WebSocket2(url, {
1629
1905
  headers: {
1630
1906
  Authorization: authHeader
1631
1907
  }
1632
1908
  });
1633
- const streamStartTimes = /* @__PURE__ */ new Map();
1634
1909
  const forwarder = new StreamForwarder(ws, port, {
1635
- onOpen: (sid, method, path) => {
1636
- if (path === TUNNEL_DRAIN_PING_PATH) return;
1637
- streamStartTimes.set(sid, Date.now());
1638
- onRequest?.(method, path, sid);
1639
- },
1640
- onHead: (sid, status) => {
1641
- const startedAt = streamStartTimes.get(sid);
1642
- streamStartTimes.delete(sid);
1643
- onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1644
- },
1910
+ onHead: () => onResponse?.(),
1645
1911
  onDrainPing: () => onDrainPing?.()
1646
1912
  });
1647
1913
  const connectionTimeout = setTimeout(() => {
@@ -1689,7 +1955,7 @@ function connectTunnel(options) {
1689
1955
  clearTimeout(connectionTimeout);
1690
1956
  const connectedAgentId = message.agent_id ?? agentId;
1691
1957
  onConnected?.(connectedAgentId);
1692
- resolve2({
1958
+ resolve3({
1693
1959
  ws,
1694
1960
  close: () => ws.close(1e3, "CLI shutdown")
1695
1961
  });
@@ -1717,7 +1983,6 @@ function connectTunnel(options) {
1717
1983
  ws.on("close", (code, reason) => {
1718
1984
  const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1719
1985
  forwarder.abortAll();
1720
- streamStartTimes.clear();
1721
1986
  onDisconnected?.(code, reasonStr);
1722
1987
  });
1723
1988
  });
@@ -1752,7 +2017,11 @@ var RunnerConnection = class {
1752
2017
  if (this.connection) {
1753
2018
  try {
1754
2019
  this.connection.close();
1755
- } catch {
2020
+ } catch (err) {
2021
+ log("error", "runner_connection_close_failed", {
2022
+ agent_id: this.resolvedAgentId,
2023
+ ...errorFields(err)
2024
+ });
1756
2025
  }
1757
2026
  this.connection = null;
1758
2027
  }
@@ -1804,6 +2073,404 @@ var RunnerConnection = class {
1804
2073
  }
1805
2074
  };
1806
2075
 
2076
+ // src/lib/channels/driver.ts
2077
+ import { homedir } from "os";
2078
+
2079
+ // src/lib/file-push.ts
2080
+ import { randomUUID } from "crypto";
2081
+ import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2082
+ import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
2083
+ var FILE_MODE = 384;
2084
+ var DIRECTORY_MODE = 448;
2085
+ async function writePushedFile(request) {
2086
+ const { requestedPath, content, allowedDirectories, homeDir } = request;
2087
+ const bytes = content.byteLength;
2088
+ if (allowedDirectories.length === 0) {
2089
+ return refuse("file_sync_disabled", "File sync is not enabled on this runner.", {
2090
+ path: requestedPath,
2091
+ bytes
2092
+ });
2093
+ }
2094
+ if (bytes > MAX_FILE_PUSH_BYTES) {
2095
+ return refuse(
2096
+ "file_too_large",
2097
+ `File is ${bytes} bytes; the limit is ${MAX_FILE_PUSH_BYTES}.`,
2098
+ {
2099
+ path: requestedPath,
2100
+ bytes
2101
+ }
2102
+ );
2103
+ }
2104
+ const candidate = expandAndValidate(requestedPath, homeDir);
2105
+ if (candidate === null) {
2106
+ return refuse("invalid_path", "The requested path is not a valid absolute file path.", {
2107
+ path: requestedPath,
2108
+ bytes
2109
+ });
2110
+ }
2111
+ try {
2112
+ const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2113
+ dirname2(candidate)
2114
+ );
2115
+ const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
2116
+ const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
2117
+ if (allowedDirectory === null) {
2118
+ return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2119
+ path: realTarget,
2120
+ bytes
2121
+ });
2122
+ }
2123
+ if (missingSegments.length > 0) {
2124
+ await createMissingDirectories(existingAncestor, missingSegments);
2125
+ const realParent = await realpath(dirname2(realTarget));
2126
+ if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
2127
+ return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2128
+ path: realTarget,
2129
+ bytes,
2130
+ reason: "parent_changed_after_create"
2131
+ });
2132
+ }
2133
+ }
2134
+ await writeAtomically(realTarget, content);
2135
+ log("info", "file_push_written", { path: realTarget, bytes });
2136
+ return { ok: true, path: realTarget };
2137
+ } catch (err) {
2138
+ const errno = err.code ?? "UNKNOWN";
2139
+ return refuse("write_failed", `The runner could not write the file (${errno}).`, {
2140
+ path: candidate,
2141
+ bytes,
2142
+ errno,
2143
+ ...errorFields(err)
2144
+ });
2145
+ }
2146
+ }
2147
+ function expandAndValidate(requestedPath, homeDir) {
2148
+ if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
2149
+ return null;
2150
+ }
2151
+ const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
2152
+ if (expanded.split(/[/\\]/).includes("..")) {
2153
+ return null;
2154
+ }
2155
+ if (!isAbsolute(expanded)) {
2156
+ return null;
2157
+ }
2158
+ const candidate = resolve2(expanded);
2159
+ const name = basename(candidate);
2160
+ return name === "" || name === "." || name === ".." ? null : candidate;
2161
+ }
2162
+ async function resolveNearestExistingAncestor(directory) {
2163
+ const missingSegments = [];
2164
+ let current = directory;
2165
+ for (; ; ) {
2166
+ try {
2167
+ return { existingAncestor: await realpath(current), missingSegments };
2168
+ } catch (err) {
2169
+ const parent = dirname2(current);
2170
+ if (err.code !== "ENOENT" || parent === current) {
2171
+ throw err;
2172
+ }
2173
+ missingSegments.unshift(basename(current));
2174
+ current = parent;
2175
+ }
2176
+ }
2177
+ }
2178
+ async function findContainingAllowedDirectory(allowedDirectories, realTarget) {
2179
+ for (const directory of allowedDirectories) {
2180
+ if (!isAbsolute(directory)) {
2181
+ log("warn", "file_push_allowed_directory_skipped", { directory, reason: "not_absolute" });
2182
+ continue;
2183
+ }
2184
+ const realDirectory = await realpathCreatingIfMissing(directory);
2185
+ if (realDirectory !== null && contains(realDirectory, realTarget)) {
2186
+ return realDirectory;
2187
+ }
2188
+ }
2189
+ return null;
2190
+ }
2191
+ async function realpathCreatingIfMissing(directory) {
2192
+ try {
2193
+ return await realpath(directory);
2194
+ } catch (err) {
2195
+ if (err.code !== "ENOENT") {
2196
+ log("warn", "file_push_allowed_directory_skipped", {
2197
+ directory,
2198
+ reason: "unresolvable",
2199
+ ...errorFields(err)
2200
+ });
2201
+ return null;
2202
+ }
2203
+ }
2204
+ try {
2205
+ await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
2206
+ await chmod(directory, DIRECTORY_MODE);
2207
+ return await realpath(directory);
2208
+ } catch (err) {
2209
+ log("warn", "file_push_allowed_directory_skipped", {
2210
+ directory,
2211
+ reason: "create_failed",
2212
+ ...errorFields(err)
2213
+ });
2214
+ return null;
2215
+ }
2216
+ }
2217
+ function contains(realDirectory, realTarget) {
2218
+ const rel = relative(realDirectory, realTarget);
2219
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
2220
+ }
2221
+ async function createMissingDirectories(existingAncestor, missingSegments) {
2222
+ let current = existingAncestor;
2223
+ for (const segment of missingSegments) {
2224
+ current = join(current, segment);
2225
+ await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
2226
+ await chmod(current, DIRECTORY_MODE);
2227
+ }
2228
+ }
2229
+ async function writeAtomically(realTarget, content) {
2230
+ const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2231
+ let handle;
2232
+ try {
2233
+ handle = await open2(temporaryPath, "wx", FILE_MODE);
2234
+ await handle.writeFile(content);
2235
+ await handle.chmod(FILE_MODE);
2236
+ await handle.close();
2237
+ handle = void 0;
2238
+ await rename(temporaryPath, realTarget);
2239
+ } catch (err) {
2240
+ await discardTemporaryFile(temporaryPath, handle);
2241
+ throw err;
2242
+ }
2243
+ }
2244
+ async function discardTemporaryFile(temporaryPath, handle) {
2245
+ try {
2246
+ await handle?.close();
2247
+ } catch (err) {
2248
+ log("warn", "file_push_temp_close_failed", { path: temporaryPath, ...errorFields(err) });
2249
+ }
2250
+ try {
2251
+ await unlink(temporaryPath);
2252
+ } catch (err) {
2253
+ const errno = err.code;
2254
+ if (errno !== "ENOENT" && errno !== "ENOTDIR") {
2255
+ log("warn", "file_push_temp_cleanup_failed", { path: temporaryPath, ...errorFields(err) });
2256
+ }
2257
+ }
2258
+ }
2259
+ function refuse(code, message, fields) {
2260
+ log(code === "write_failed" ? "error" : "warn", "file_push_refused", { code, ...fields });
2261
+ return { ok: false, code, message };
2262
+ }
2263
+
2264
+ // src/lib/runner-file-sync.ts
2265
+ var MAX_ACK_ATTEMPTS = 5;
2266
+ async function syncPendingRunnerFiles(options) {
2267
+ const pending = await listPendingFiles(options);
2268
+ const pendingIds = new Set(pending.map((file) => file.id));
2269
+ for (const id of options.ackFailures.keys()) {
2270
+ if (!pendingIds.has(id)) options.ackFailures.delete(id);
2271
+ }
2272
+ if (pending.length === 0) return 0;
2273
+ options.log({
2274
+ level: "info",
2275
+ message: `Runner file sync: ${pending.length} file(s) queued for this runner`
2276
+ });
2277
+ let applied = 0;
2278
+ for (const file of pending) {
2279
+ if ((options.ackFailures.get(file.id) ?? 0) >= MAX_ACK_ATTEMPTS) continue;
2280
+ if (await applyOne(options, file)) applied += 1;
2281
+ }
2282
+ return applied;
2283
+ }
2284
+ async function listPendingFiles(options) {
2285
+ let res;
2286
+ try {
2287
+ res = await options.fetchImpl(`${options.apiUrl}/runners/${options.agentId}/files/pending`, {
2288
+ headers: { Authorization: options.getAuthHeader() }
2289
+ });
2290
+ } catch (err) {
2291
+ options.log({
2292
+ level: "warn",
2293
+ message: `Could not list pending runner files \u2014 retrying on the next drain: ${describe(err)}`
2294
+ });
2295
+ return [];
2296
+ }
2297
+ if (!res.ok) {
2298
+ options.log({
2299
+ level: res.status === 404 ? "debug" : "warn",
2300
+ message: `Listing pending runner files returned HTTP ${res.status} \u2014 retrying on the next drain`
2301
+ });
2302
+ return [];
2303
+ }
2304
+ let body;
2305
+ try {
2306
+ body = await res.json();
2307
+ } catch (err) {
2308
+ options.log({
2309
+ level: "warn",
2310
+ message: `Pending runner file list was not readable JSON \u2014 retrying on the next drain: ${describe(err)}`
2311
+ });
2312
+ return [];
2313
+ }
2314
+ if (!Array.isArray(body)) {
2315
+ options.log({
2316
+ level: "warn",
2317
+ message: "Pending runner file list was not an array \u2014 ignoring it for this drain"
2318
+ });
2319
+ return [];
2320
+ }
2321
+ const files = [];
2322
+ for (const entry of body) {
2323
+ const file = asPendingFile(entry);
2324
+ if (file === null) {
2325
+ options.log({
2326
+ level: "warn",
2327
+ message: "Ignoring a malformed pending runner file entry (expected id, path and size)"
2328
+ });
2329
+ continue;
2330
+ }
2331
+ files.push(file);
2332
+ }
2333
+ return files;
2334
+ }
2335
+ function asPendingFile(entry) {
2336
+ if (entry === null || typeof entry !== "object") return null;
2337
+ const { id, path, size } = entry;
2338
+ if (typeof id !== "string" || id === "") return null;
2339
+ if (typeof path !== "string" || path === "") return null;
2340
+ if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null;
2341
+ return { id, path, size };
2342
+ }
2343
+ async function applyOne(options, file) {
2344
+ const label = `${file.id.slice(0, 8)} (${file.path})`;
2345
+ if (options.allowedDirectories.length === 0) {
2346
+ options.log({
2347
+ level: "warn",
2348
+ message: `Runner file ${label} rejected: file sync is not enabled on this runner (start it with --enable-file-sync-to)`
2349
+ });
2350
+ await ack(options, file, "rejected", "file_sync_disabled");
2351
+ return false;
2352
+ }
2353
+ if (file.size > MAX_FILE_PUSH_BYTES) {
2354
+ options.log({
2355
+ level: "warn",
2356
+ message: `Runner file ${label} rejected: declared ${file.size} bytes, the limit is ${MAX_FILE_PUSH_BYTES}`
2357
+ });
2358
+ await ack(options, file, "rejected", "file_too_large");
2359
+ return false;
2360
+ }
2361
+ const download = await downloadContent(options, file, label);
2362
+ if (!download.ok) {
2363
+ if (download.terminal) await ack(options, file, "rejected", download.code);
2364
+ return false;
2365
+ }
2366
+ let outcome;
2367
+ try {
2368
+ outcome = await writePushedFile({
2369
+ requestedPath: file.path,
2370
+ content: download.content,
2371
+ allowedDirectories: options.allowedDirectories,
2372
+ homeDir: options.homeDir
2373
+ });
2374
+ } catch (err) {
2375
+ options.log({
2376
+ level: "error",
2377
+ message: `Runner file ${label} could not be written: ${describe(err)}`
2378
+ });
2379
+ await ack(options, file, "rejected", "write_failed");
2380
+ return false;
2381
+ }
2382
+ if (!outcome.ok) {
2383
+ options.log({
2384
+ level: "warn",
2385
+ message: `Runner file ${label} rejected (${outcome.code}): ${outcome.message}`
2386
+ });
2387
+ await ack(options, file, "rejected", outcome.code);
2388
+ return false;
2389
+ }
2390
+ options.log({
2391
+ level: "info",
2392
+ message: `Runner file ${label} applied (${download.content.byteLength} bytes)`
2393
+ });
2394
+ await ack(options, file, "applied");
2395
+ return true;
2396
+ }
2397
+ function durableDownloadCode(status) {
2398
+ return status === 413 ? "file_too_large" : "write_failed";
2399
+ }
2400
+ async function downloadContent(options, file, label) {
2401
+ try {
2402
+ const res = await options.fetchImpl(
2403
+ `${options.apiUrl}/runners/${options.agentId}/files/${file.id}/content`,
2404
+ { headers: { Authorization: options.getAuthHeader() } }
2405
+ );
2406
+ if (!res.ok) {
2407
+ const terminal = res.status >= 400 && res.status < 500 && res.status !== 401 && res.status !== 403 && res.status !== 408 && res.status !== 429;
2408
+ if (!terminal) {
2409
+ options.log({
2410
+ level: "warn",
2411
+ message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 retrying on the next drain`
2412
+ });
2413
+ return { ok: false, terminal: false };
2414
+ }
2415
+ const code = durableDownloadCode(res.status);
2416
+ options.log({
2417
+ level: "error",
2418
+ message: `Downloading runner file ${label} returned HTTP ${res.status} \u2014 rejecting it as ${code} (the bytes never reached the writer)`
2419
+ });
2420
+ return { ok: false, terminal: true, code };
2421
+ }
2422
+ return { ok: true, content: Buffer.from(await res.arrayBuffer()) };
2423
+ } catch (err) {
2424
+ options.log({
2425
+ level: "warn",
2426
+ message: `Downloading runner file ${label} failed \u2014 retrying on the next drain: ${describe(err)}`
2427
+ });
2428
+ return { ok: false, terminal: false };
2429
+ }
2430
+ }
2431
+ async function ack(options, file, status, reason) {
2432
+ const outcome = `${status}${reason ? ` (${reason})` : ""}`;
2433
+ try {
2434
+ const res = await options.fetchImpl(
2435
+ `${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
2436
+ {
2437
+ method: "POST",
2438
+ headers: {
2439
+ Authorization: options.getAuthHeader(),
2440
+ "Content-Type": "application/json"
2441
+ },
2442
+ body: JSON.stringify(reason ? { status, reason } : { status })
2443
+ }
2444
+ );
2445
+ if (!res.ok) {
2446
+ recordAckFailure(
2447
+ options,
2448
+ file,
2449
+ `Acking runner file ${file.id.slice(0, 8)} as ${outcome} returned HTTP ${res.status}`
2450
+ );
2451
+ return;
2452
+ }
2453
+ options.ackFailures.delete(file.id);
2454
+ } catch (err) {
2455
+ recordAckFailure(
2456
+ options,
2457
+ file,
2458
+ `Acking runner file ${file.id.slice(0, 8)} as ${outcome} failed: ${describe(err)}`
2459
+ );
2460
+ }
2461
+ }
2462
+ function recordAckFailure(options, file, what) {
2463
+ const attempts = (options.ackFailures.get(file.id) ?? 0) + 1;
2464
+ options.ackFailures.set(file.id, attempts);
2465
+ options.log({
2466
+ level: "error",
2467
+ message: attempts >= MAX_ACK_ATTEMPTS ? `${what} \u2014 giving up after ${attempts} attempts. It stays pending until the server expires it; restart the runner to retry.` : `${what} \u2014 it stays pending until a later drain re-acks it (attempt ${attempts} of ${MAX_ACK_ATTEMPTS})`
2468
+ });
2469
+ }
2470
+ function describe(err) {
2471
+ return err instanceof Error ? err.message : String(err);
2472
+ }
2473
+
1807
2474
  // src/lib/channels/driver.ts
1808
2475
  function messageIdOf(m) {
1809
2476
  if (!m || typeof m !== "object") return void 0;
@@ -1811,6 +2478,17 @@ function messageIdOf(m) {
1811
2478
  const infoId = m.info?.id;
1812
2479
  return typeof infoId === "string" ? infoId : void 0;
1813
2480
  }
2481
+ function cleanImageMime(contentType) {
2482
+ if (!contentType) return null;
2483
+ const media = contentType.split(";")[0].trim().toLowerCase();
2484
+ return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
2485
+ }
2486
+ var LOG_LEVELS = {
2487
+ debug: 0,
2488
+ info: 1,
2489
+ warn: 2,
2490
+ error: 3
2491
+ };
1814
2492
  var DEFAULT_RETRY_POLICY = {
1815
2493
  maxAttempts: 6,
1816
2494
  baseDelayMs: 500,
@@ -1822,6 +2500,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
1822
2500
  var HEARTBEAT_MS = 6e4;
1823
2501
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
1824
2502
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2503
+ var MAX_SUPERSEDED_CONVERSATIONS = 256;
1825
2504
  var ChannelAuthError = class extends Error {
1826
2505
  constructor(message) {
1827
2506
  super(message);
@@ -1844,7 +2523,7 @@ function backoffDelay(attempt, policy) {
1844
2523
  function isRetryableStatus(status) {
1845
2524
  return status === 429 || status >= 500 && status <= 599;
1846
2525
  }
1847
- var ChannelDriver = class {
2526
+ var ChannelDriver = class _ChannelDriver {
1848
2527
  agentId;
1849
2528
  port;
1850
2529
  apiUrl;
@@ -1858,8 +2537,38 @@ var ChannelDriver = class {
1858
2537
  pausedMaxWaitMs;
1859
2538
  stuckQueuedMs;
1860
2539
  now;
2540
+ fileSyncDirectories;
2541
+ homeDir;
1861
2542
  /** Cache of conversationId → opencode sessionId. */
1862
2543
  sessions = /* @__PURE__ */ new Map();
2544
+ /**
2545
+ * conversationId → the opencode session this runner has ABANDONED as that
2546
+ * conversation's binding (#553), after a genuine (`sessionExists === true`)
2547
+ * dispatch failure: the session still exists but is wedged, so #485's self-heal
2548
+ * must bind a fresh one.
2549
+ *
2550
+ * Dropping the local binding + clearing the server row is not enough on its own:
2551
+ * a SIBLING message dispatched earlier in the same drain is still in-flight under
2552
+ * the same session, and its watcher's routine status writes carry
2553
+ * `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
2554
+ * and `ensureSession`'s persisted-id fallback then reuses it, defeating the
2555
+ * self-heal. This map makes the runner authoritative instead of racing those
2556
+ * writes: *`ensureSession` never reuses an abandoned id for that conversation,
2557
+ * whatever the server row says* — which holds even when the resurrecting write
2558
+ * is one we deliberately keep (see `markDone`).
2559
+ *
2560
+ * Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
2561
+ * one conversation hold ONE entry (the newest abandonment replaces the older), and
2562
+ * hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
2563
+ * NEWEST abandoned id per conversation is guarded: after a second abandonment a
2564
+ * late sibling of the FIRST session can write that id back and `ensureSession`
2565
+ * will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
2566
+ * NOT dropped when the session's watcher tears down: `markDone` still writes the
2567
+ * abandoned id back (it must, or the reply is lost), so the guard has to outlive
2568
+ * the turn that resurrects it. In-memory only — a restart forgets it, at the same
2569
+ * bounded cost.
2570
+ */
2571
+ supersededSessions = /* @__PURE__ */ new Map();
1863
2572
  /**
1864
2573
  * Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
1865
2574
  * longer idempotent (no caller-supplied `messageID`), and its read-back picks
@@ -1941,6 +2650,15 @@ var ChannelDriver = class {
1941
2650
  * so the NEXT tick may retry exactly once more).
1942
2651
  */
1943
2652
  awaitingReadopt = /* @__PURE__ */ new Set();
2653
+ /**
2654
+ * "Already signalled `attachments_skipped` for this Evident message id" (#376).
2655
+ * The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
2656
+ * — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
2657
+ * next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
2658
+ * `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
2659
+ * outcome, not the dispatch. Not cleared (a message is signalled once for life).
2660
+ */
2661
+ attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
1944
2662
  /**
1945
2663
  * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1946
2664
  * first session creation so drain-created sessions are rooted at the project
@@ -1958,8 +2676,39 @@ var ChannelDriver = class {
1958
2676
  * entry = not yet resolved; `null` = resolved root (stop walking).
1959
2677
  */
1960
2678
  sessionParents = /* @__PURE__ */ new Map();
2679
+ /**
2680
+ * Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
2681
+ * NON-EMPTY, non-placeholder name is stored (terminal — a real session name
2682
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
2683
+ * excludes OpenCode's synchronous default title (see
2684
+ * `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
2685
+ * as an empty title so it never latches. A missing entry = not yet resolved OR
2686
+ * resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
2687
+ * names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
2688
+ * the watcher completion path AND the restart-recovery re-adopt path (which has
2689
+ * no watcher) can resolve the title.
2690
+ */
2691
+ sessionTitles = /* @__PURE__ */ new Map();
1961
2692
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1962
2693
  draining = false;
2694
+ /**
2695
+ * Serialises runner-file syncs (#559) so the ~2s poll tick and a concurrent
2696
+ * drain ping don't download, write and ack the same file twice.
2697
+ */
2698
+ syncingFiles = false;
2699
+ /**
2700
+ * Consecutive failed acks per pending file (#559). Lives on the driver so it
2701
+ * survives across drains — without it, a file whose ack keeps failing is
2702
+ * re-downloaded and re-written every ~2s until the server expires it.
2703
+ */
2704
+ fileAckFailures = /* @__PURE__ */ new Map();
2705
+ /**
2706
+ * Monotonic count of files this runner has pulled and written (#559). Only
2707
+ * ever increases, so `run.ts` detects work by comparing it against the value
2708
+ * it saw on the previous cycle — including work that landed mid-sleep, the
2709
+ * same trick `lastProxiedActivityAt` uses.
2710
+ */
2711
+ appliedFileCount = 0;
1963
2712
  /**
1964
2713
  * The currently-executing `drainPending()` promise, or null when idle. Lets a
1965
2714
  * graceful shutdown (`waitForInFlight`) await an in-progress drain so a turn it
@@ -1990,14 +2739,13 @@ var ChannelDriver = class {
1990
2739
  this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1991
2740
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
1992
2741
  this.now = config2.now ?? (() => Date.now());
2742
+ this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
2743
+ this.homeDir = config2.homeDir ?? homedir();
1993
2744
  }
1994
2745
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1995
2746
  get opencodeBase() {
1996
2747
  return `http://127.0.0.1:${this.port}`;
1997
2748
  }
1998
- // -------------------------------------------------------------------------
1999
- // Public API
2000
- // -------------------------------------------------------------------------
2001
2749
  /**
2002
2750
  * Drain all pending channel conversations once: poll → dispatch → register.
2003
2751
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
@@ -2020,6 +2768,47 @@ var ChannelDriver = class {
2020
2768
  );
2021
2769
  return run2;
2022
2770
  }
2771
+ /**
2772
+ * Pull-and-apply any files Evident has queued for this runner (#559), riding
2773
+ * the EXISTING drain cycle — `run.ts` calls it from the same ~2s channel poll
2774
+ * and drain ping that call `drainPending()`. There is deliberately no channel,
2775
+ * control frame or poll loop of its own: worst-case latency is one poll tick.
2776
+ *
2777
+ * NEVER throws and never surfaces a `ChannelAuthError`: a file failure must not
2778
+ * cost a conversation turn. Failures are logged and either acked as a terminal
2779
+ * outcome or left pending for the next drain (see `runner-file-sync.ts`).
2780
+ *
2781
+ * Re-entrant calls are skipped (the poll tick and a drain ping can overlap).
2782
+ *
2783
+ * @returns the number of files written to disk.
2784
+ */
2785
+ async syncPendingFiles() {
2786
+ if (this.stopped) return 0;
2787
+ if (this.syncingFiles) return 0;
2788
+ this.syncingFiles = true;
2789
+ try {
2790
+ const applied = await syncPendingRunnerFiles({
2791
+ agentId: this.agentId,
2792
+ apiUrl: this.apiUrl,
2793
+ getAuthHeader: this.getAuthHeader,
2794
+ fetchImpl: this.fetchImpl,
2795
+ allowedDirectories: this.fileSyncDirectories,
2796
+ homeDir: this.homeDir,
2797
+ ackFailures: this.fileAckFailures,
2798
+ log: this.log
2799
+ });
2800
+ this.appliedFileCount += applied;
2801
+ return applied;
2802
+ } catch (err) {
2803
+ this.log({
2804
+ level: "error",
2805
+ message: `Runner file sync failed unexpectedly (message processing is unaffected): ${err instanceof Error ? err.message : String(err)}`
2806
+ });
2807
+ return 0;
2808
+ } finally {
2809
+ this.syncingFiles = false;
2810
+ }
2811
+ }
2023
2812
  async runDrain() {
2024
2813
  let dispatched = 0;
2025
2814
  try {
@@ -2053,6 +2842,28 @@ var ChannelDriver = class {
2053
2842
  }
2054
2843
  return false;
2055
2844
  }
2845
+ /**
2846
+ * File-pull work, for `run.ts`'s idle accounting (#559).
2847
+ *
2848
+ * Pulling a file is real work that `drainPending()` knows nothing about, so
2849
+ * without this a near-idle runner counts a credential pull as an empty tick
2850
+ * and `--idle-timeout` can `process.exit` mid-pull — leaving a
2851
+ * `.evident-push-*.tmp` behind — or immediately after the write, before the
2852
+ * browser has run the authorize/callback that activates it (the user then sees
2853
+ * `saved_not_activated` for a runner that was fine).
2854
+ *
2855
+ * Two signals because one cannot cover both cases: `inFlight` is the pull
2856
+ * happening RIGHT NOW (it may outlive the tick that started it), and
2857
+ * `appliedFiles` is monotonic so a pull that started AND finished between two
2858
+ * idle checks still shows up as an advance.
2859
+ *
2860
+ * CALLER CONTRACT: sample `inFlight` BEFORE calling `syncPendingFiles()` for
2861
+ * the cycle. `syncPendingFiles` sets the flag synchronously, so a caller that
2862
+ * samples afterwards reads `true` every single cycle and can never idle out.
2863
+ */
2864
+ fileSyncActivity() {
2865
+ return { appliedFiles: this.appliedFileCount, inFlight: this.syncingFiles };
2866
+ }
2056
2867
  /**
2057
2868
  * OpenCode session ids the session-cleanup sweep (issue #190) must NOT delete:
2058
2869
  * exactly those with a live (dispatched-but-not-done / paused) turn, i.e. a
@@ -2114,7 +2925,7 @@ var ChannelDriver = class {
2114
2925
  await this.sleep(step);
2115
2926
  }
2116
2927
  }
2117
- while (this.hasInFlightWatchers()) {
2928
+ while (this.hasInFlightWatchers() || this.syncingFiles) {
2118
2929
  if (this.now() >= deadline) return false;
2119
2930
  await this.sleep(step);
2120
2931
  }
@@ -2139,9 +2950,7 @@ var ChannelDriver = class {
2139
2950
  if (!stillLive) return;
2140
2951
  }
2141
2952
  }
2142
- // -------------------------------------------------------------------------
2143
2953
  // Conversation processing (WI-3 — async dispatch)
2144
- // -------------------------------------------------------------------------
2145
2954
  /**
2146
2955
  * Dispatch each pending message for a conversation to opencode's native queue
2147
2956
  * via `prompt_async` (Task 3.2) and register it with the conversation's
@@ -2151,10 +2960,15 @@ var ChannelDriver = class {
2151
2960
  * @returns the count of messages NEWLY dispatched (not already in-flight).
2152
2961
  */
2153
2962
  async processConversation(conv) {
2154
- const sessionId = await this.ensureSession(conv);
2963
+ const { sessionId, refusedSessionId } = await this.ensureSession(conv);
2155
2964
  const messages = await this.getPendingMessages(conv.id);
2156
2965
  let dispatched = 0;
2157
2966
  let skippedAlreadyDispatched = 0;
2967
+ if (refusedSessionId && messages.length > 0) {
2968
+ void this.postSignal(conv.id, messages[0].id, "session_superseded", {
2969
+ superseded_session_id: refusedSessionId
2970
+ });
2971
+ }
2158
2972
  for (const message of messages) {
2159
2973
  if (this.stopped) break;
2160
2974
  if (this.dispatched.has(message.id)) {
@@ -2168,41 +2982,67 @@ var ChannelDriver = class {
2168
2982
  let opencodeMessageId;
2169
2983
  try {
2170
2984
  this.log({
2171
- level: "info",
2172
- message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
2985
+ level: "info",
2986
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
2987
+ conversation_id: conv.id,
2988
+ message_id: message.id
2989
+ });
2990
+ const sendAttachments = this.buildSendAttachments(conv, message);
2991
+ opencodeMessageId = await this.dispatchLocked(
2992
+ sessionId,
2993
+ () => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
2994
+ );
2995
+ } catch (err) {
2996
+ if (err instanceof ChannelAuthError) throw err;
2997
+ this.dispatched.delete(message.id);
2998
+ const exists = await sessionExists(this.port, sessionId);
2999
+ if (exists === false) {
3000
+ this.sessions.delete(conv.id);
3001
+ this.log({
3002
+ level: "warn",
3003
+ 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.`,
3004
+ conversation_id: conv.id,
3005
+ message_id: message.id
3006
+ });
3007
+ break;
3008
+ }
3009
+ if (exists === null) {
3010
+ this.log({
3011
+ level: "warn",
3012
+ message: `Message ${message.id.slice(0, 8)} dispatch failed and session (${sessionId.slice(0, 8)}) existence could not be confirmed (opencode momentarily unreachable) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick rather than treating it as a genuine failure.`,
3013
+ conversation_id: conv.id,
3014
+ message_id: message.id
3015
+ });
3016
+ break;
3017
+ }
3018
+ const errorMessage = err instanceof Error ? err.message : String(err);
3019
+ this.sessions.delete(conv.id);
3020
+ this.supersede(conv.id, sessionId);
3021
+ this.log({
3022
+ level: "warn",
3023
+ message: `Abandoning OpenCode session ${sessionId.slice(0, 8)} as the binding for conversation ${conv.id.slice(0, 8)} (it exists but failed to run a turn) \u2014 a fresh session is created on the next tick, whatever the persisted binding says by then.`,
2173
3024
  conversation_id: conv.id,
2174
3025
  message_id: message.id
2175
3026
  });
2176
- opencodeMessageId = await this.dispatchLocked(
2177
- sessionId,
2178
- () => sendPromptAsync(this.port, sessionId, message.content, options)
2179
- );
2180
- } catch (err) {
2181
- if (err instanceof ChannelAuthError) throw err;
2182
- this.dispatched.delete(message.id);
2183
- if (await sessionExists(this.port, sessionId) === false) {
2184
- this.sessions.delete(conv.id);
3027
+ await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
2185
3028
  this.log({
2186
- level: "info",
2187
- 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.`,
3029
+ level: "warn",
3030
+ message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
2188
3031
  conversation_id: conv.id,
2189
3032
  message_id: message.id
2190
3033
  });
2191
- break;
2192
- }
2193
- await this.markFailed(conv.id, message.id).catch(() => {
2194
3034
  });
2195
3035
  this.log({
2196
3036
  level: "error",
2197
- message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
3037
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
2198
3038
  conversation_id: conv.id,
2199
3039
  message_id: message.id
2200
3040
  });
2201
- continue;
3041
+ break;
2202
3042
  }
2203
3043
  if (opencodeMessageId === null) {
2204
3044
  this.log({
2205
- level: "error",
3045
+ level: "warn",
2206
3046
  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
3047
  conversation_id: conv.id,
2208
3048
  message_id: message.id
@@ -2216,7 +3056,7 @@ var ChannelDriver = class {
2216
3056
  }
2217
3057
  if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
2218
3058
  this.log({
2219
- level: "error",
3059
+ level: "warn",
2220
3060
  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
3061
  conversation_id: conv.id
2222
3062
  });
@@ -2224,23 +3064,57 @@ var ChannelDriver = class {
2224
3064
  this.ensureWatcherRunning(sessionId);
2225
3065
  return dispatched;
2226
3066
  }
3067
+ /**
3068
+ * Record that `sessionId` is no longer a valid binding for `conversationId`
3069
+ * (#553). Keyed by conversation and hard-capped, so it cannot grow with the
3070
+ * number of failures — see the `supersededSessions` field doc.
3071
+ */
3072
+ supersede(conversationId, sessionId) {
3073
+ this.supersededSessions.delete(conversationId);
3074
+ this.supersededSessions.set(conversationId, sessionId);
3075
+ while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
3076
+ const oldest = this.supersededSessions.keys().next().value;
3077
+ if (oldest === void 0) return;
3078
+ this.supersededSessions.delete(oldest);
3079
+ }
3080
+ }
3081
+ /** Whether `sessionId` is the session this conversation has abandoned (#553). */
3082
+ isSuperseded(conversationId, sessionId) {
3083
+ return this.supersededSessions.get(conversationId) === sessionId;
3084
+ }
3085
+ /**
3086
+ * Resolve the opencode session to run this conversation's turns in.
3087
+ *
3088
+ * `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
3089
+ * binding was an id this runner had abandoned, so a resurrection genuinely
3090
+ * happened and a fresh session was bound instead. The caller reports it.
3091
+ */
2227
3092
  async ensureSession(conv) {
2228
3093
  const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
3094
+ if (bound && this.isSuperseded(conv.id, bound)) {
3095
+ this.log({
3096
+ level: "warn",
3097
+ message: `OpenCode session ${bound.slice(0, 8)} was abandoned for conversation ${conv.id.slice(0, 8)} after a failed dispatch but is still bound to it (the persisted id was written back by a turn already in flight) \u2014 ignoring it and binding a fresh session.`,
3098
+ conversation_id: conv.id
3099
+ });
3100
+ this.sessions.delete(conv.id);
3101
+ return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
3102
+ }
2229
3103
  if (bound) {
2230
3104
  const exists = await sessionExists(this.port, bound);
2231
3105
  if (exists === false) {
2232
3106
  this.log({
2233
- level: "info",
3107
+ level: "debug",
2234
3108
  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
3109
  conversation_id: conv.id
2236
3110
  });
2237
3111
  this.sessions.delete(conv.id);
2238
- return this.createAndBindSession(conv.id);
3112
+ return { sessionId: await this.createAndBindSession(conv.id) };
2239
3113
  }
2240
3114
  this.sessions.set(conv.id, bound);
2241
- return bound;
3115
+ return { sessionId: bound };
2242
3116
  }
2243
- return this.createAndBindSession(conv.id);
3117
+ return { sessionId: await this.createAndBindSession(conv.id) };
2244
3118
  }
2245
3119
  /**
2246
3120
  * Create a fresh OpenCode session for a conversation, cache the binding, and
@@ -2265,15 +3139,13 @@ var ChannelDriver = class {
2265
3139
  this.opencodeDirectory = await getOpenCodeDirectory(this.port);
2266
3140
  if (!this.opencodeDirectory) {
2267
3141
  this.log({
2268
- level: "info",
3142
+ level: "warn",
2269
3143
  message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
2270
3144
  });
2271
3145
  }
2272
3146
  return this.opencodeDirectory;
2273
3147
  }
2274
- // -------------------------------------------------------------------------
2275
3148
  // Per-session watcher (WI-3)
2276
- // -------------------------------------------------------------------------
2277
3149
  /**
2278
3150
  * Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
2279
3151
  * opencode session (Task 2.1a), so two dispatches into the SAME session can
@@ -2293,6 +3165,130 @@ var ChannelDriver = class {
2293
3165
  );
2294
3166
  return run2;
2295
3167
  }
3168
+ // Inbound image attachments (#255, WI-8)
3169
+ /**
3170
+ * Build the `SendAttachmentsInput` for a message's inbound images, or
3171
+ * `undefined` when the message has none (so a text-only turn is unchanged).
3172
+ *
3173
+ * The driver OWNS the two channel-facing concerns the session module cannot:
3174
+ * - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
3175
+ * (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
3176
+ * other combinedAuth callback — the CLI NEVER talks to Slack directly;
3177
+ * - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
3178
+ * existing callback surface when any image was skipped/failed.
3179
+ * `sendPromptAsync` applies the capability gate + appends the `file` parts and
3180
+ * reports outcomes back via `onOutcomes`.
3181
+ */
3182
+ buildSendAttachments(conv, message) {
3183
+ const refs = message.attachments;
3184
+ if (!refs || refs.length === 0) return void 0;
3185
+ return {
3186
+ inputs: refs.map((a, index) => ({
3187
+ index,
3188
+ mime: a.mime,
3189
+ ...a.filename ? { filename: a.filename } : {}
3190
+ })),
3191
+ fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
3192
+ onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
3193
+ };
3194
+ }
3195
+ /**
3196
+ * Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
3197
+ * (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
3198
+ * existing authenticated fetch, and base64-encode into a
3199
+ * `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
3200
+ *
3201
+ * The endpoint streams the source bytes verbatim (200), or returns 404
3202
+ * (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
3203
+ * (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
3204
+ * OMITS that one image and the text turn still sends — NEVER throws the turn.
3205
+ * A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
3206
+ * a Slack `files:read` scope problem via `files.info`) instead resolves the
3207
+ * `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
3208
+ * user to reconnect Slack instead of a generic "unavailable". Failures are
3209
+ * logged with context (no silent swallow).
3210
+ */
3211
+ async fetchAttachmentDataUrl(messageId, index, mime) {
3212
+ try {
3213
+ const res = await this.fetchImpl(
3214
+ `${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
3215
+ { headers: { Authorization: this.getAuthHeader() } }
3216
+ );
3217
+ if (!res.ok) {
3218
+ let reason;
3219
+ try {
3220
+ const body = await res.json();
3221
+ if (body && typeof body.reason === "string") reason = body.reason;
3222
+ } catch (parseErr) {
3223
+ this.log({
3224
+ level: "debug",
3225
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index}: error body was not JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}) \u2014 treating as a plain failure`,
3226
+ message_id: messageId
3227
+ });
3228
+ }
3229
+ if (reason === "needs_reauth") {
3230
+ this.log({
3231
+ level: "error",
3232
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 server confirmed a Slack reauth/scope problem \u2014 omitting this image (text turn proceeds)`,
3233
+ message_id: messageId
3234
+ });
3235
+ return { needsReauth: true };
3236
+ }
3237
+ this.log({
3238
+ level: "error",
3239
+ message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
3240
+ message_id: messageId
3241
+ });
3242
+ return null;
3243
+ }
3244
+ const buf = await res.arrayBuffer();
3245
+ const base64 = Buffer.from(buf).toString("base64");
3246
+ const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
3247
+ return `data:${dataMime};base64,${base64}`;
3248
+ } catch (err) {
3249
+ this.log({
3250
+ level: "error",
3251
+ 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)}`,
3252
+ message_id: messageId
3253
+ });
3254
+ return null;
3255
+ }
3256
+ }
3257
+ /**
3258
+ * On any skipped/failed image, post an in-thread note to Evident over the
3259
+ * EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
3260
+ * Evident routes the note to source via `conversation.deliver`.
3261
+ *
3262
+ * The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
3263
+ * `messageSignalSchema`) and turns it into an in-thread note delivered through
3264
+ * `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
3265
+ * reaches the channel.
3266
+ *
3267
+ * Fire-and-forget: never throws into the send/tick (logs its own failure).
3268
+ */
3269
+ signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
3270
+ const skipped = outcomes.filter((o) => o.status === "skipped").length;
3271
+ const failed = outcomes.filter((o) => o.status === "failed").length;
3272
+ if (skipped === 0 && failed === 0) return;
3273
+ if (this.attachmentsSkippedSignalled.has(messageId)) return;
3274
+ this.attachmentsSkippedSignalled.add(messageId);
3275
+ const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
3276
+ const failedReason = outcomes.some(
3277
+ (o) => o.status === "failed" && o.reason === "needs_reauth"
3278
+ ) ? "needs_reauth" : void 0;
3279
+ this.log({
3280
+ level: "info",
3281
+ 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`,
3282
+ conversation_id: conversationId,
3283
+ message_id: messageId
3284
+ });
3285
+ void this.postSignal(conversationId, messageId, "attachments_skipped", {
3286
+ skipped,
3287
+ failed,
3288
+ ...skipped > 0 ? { skipped_reason: skippedReason } : {},
3289
+ ...failedReason ? { failed_reason: failedReason } : {}
3290
+ });
3291
+ }
2296
3292
  /** Register a freshly-dispatched message with its session's watcher state. */
2297
3293
  registerInFlight(conv, sessionId, message, opencodeMessageId) {
2298
3294
  let watcher = this.watchers.get(sessionId);
@@ -2321,6 +3317,8 @@ var ChannelDriver = class {
2321
3317
  stuckReported: false,
2322
3318
  lastAliveAt: 0,
2323
3319
  aliveInFlight: false,
3320
+ titleSynced: false,
3321
+ titleSyncInFlight: false,
2324
3322
  awaitingHumanLatched: false,
2325
3323
  pausedOnQuestion: false,
2326
3324
  pausedOnPermission: false,
@@ -2394,6 +3392,8 @@ var ChannelDriver = class {
2394
3392
  // with no extra `re_adopted` signal needed (folds old WI-6).
2395
3393
  lastAliveAt: 0,
2396
3394
  aliveInFlight: false,
3395
+ titleSynced: false,
3396
+ titleSyncInFlight: false,
2397
3397
  awaitingHumanLatched: false,
2398
3398
  pausedOnQuestion: false,
2399
3399
  pausedOnPermission: false,
@@ -2530,18 +3530,20 @@ var ChannelDriver = class {
2530
3530
  const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
2531
3531
  const awaitingHuman = observedOpen || latchedPaused;
2532
3532
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
3533
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
2533
3534
  let claimed;
2534
3535
  try {
2535
3536
  claimed = await this.markProcessing(
2536
3537
  conv.id,
2537
3538
  inFlight.evidentMessageId,
2538
3539
  sessionId,
2539
- inFlight.opencodeMessageId
3540
+ inFlight.opencodeMessageId,
3541
+ title
2540
3542
  );
2541
3543
  } catch (err) {
2542
3544
  if (err instanceof ChannelAuthError) throw err;
2543
3545
  this.log({
2544
- level: "error",
3546
+ level: "warn",
2545
3547
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2546
3548
  conversation_id: conv.id,
2547
3549
  message_id: inFlight.evidentMessageId
@@ -2551,7 +3553,7 @@ var ChannelDriver = class {
2551
3553
  inFlight.started = true;
2552
3554
  if (!claimed) {
2553
3555
  this.log({
2554
- level: "info",
3556
+ level: "debug",
2555
3557
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
2556
3558
  conversation_id: conv.id,
2557
3559
  message_id: inFlight.evidentMessageId
@@ -2567,18 +3569,22 @@ var ChannelDriver = class {
2567
3569
  conversation_id: conv.id,
2568
3570
  message_id: inFlight.evidentMessageId
2569
3571
  });
3572
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3573
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2570
3574
  try {
2571
3575
  await this.markDone(
2572
3576
  conv.id,
2573
3577
  inFlight.evidentMessageId,
2574
3578
  sessionId,
2575
- inFlight.opencodeMessageId
3579
+ inFlight.opencodeMessageId,
3580
+ title,
3581
+ usage
2576
3582
  );
2577
3583
  } catch (err) {
2578
3584
  if (err instanceof ChannelAuthError) throw err;
2579
3585
  if (err instanceof ChannelTerminalError) {
2580
3586
  this.log({
2581
- level: "error",
3587
+ level: "warn",
2582
3588
  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
3589
  conversation_id: conv.id,
2584
3590
  message_id: inFlight.evidentMessageId
@@ -2588,7 +3594,7 @@ var ChannelDriver = class {
2588
3594
  }
2589
3595
  if (this.now() >= inFlight.deadline) {
2590
3596
  this.log({
2591
- level: "error",
3597
+ level: "warn",
2592
3598
  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
3599
  conversation_id: conv.id,
2594
3600
  message_id: inFlight.evidentMessageId
@@ -2597,7 +3603,7 @@ var ChannelDriver = class {
2597
3603
  return;
2598
3604
  }
2599
3605
  this.log({
2600
- level: "error",
3606
+ level: "warn",
2601
3607
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2602
3608
  conversation_id: conv.id,
2603
3609
  message_id: inFlight.evidentMessageId
@@ -2619,13 +3625,14 @@ var ChannelDriver = class {
2619
3625
  conversation_id: conv.id,
2620
3626
  message_id: inFlight.evidentMessageId
2621
3627
  });
3628
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
2622
3629
  try {
2623
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
3630
+ await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
2624
3631
  } catch (err) {
2625
3632
  if (err instanceof ChannelAuthError) throw err;
2626
3633
  if (err instanceof ChannelTerminalError) {
2627
3634
  this.log({
2628
- level: "error",
3635
+ level: "warn",
2629
3636
  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
3637
  conversation_id: conv.id,
2631
3638
  message_id: inFlight.evidentMessageId
@@ -2635,7 +3642,7 @@ var ChannelDriver = class {
2635
3642
  }
2636
3643
  if (this.now() >= inFlight.deadline) {
2637
3644
  this.log({
2638
- level: "error",
3645
+ level: "warn",
2639
3646
  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
3647
  conversation_id: conv.id,
2641
3648
  message_id: inFlight.evidentMessageId
@@ -2644,7 +3651,7 @@ var ChannelDriver = class {
2644
3651
  return;
2645
3652
  }
2646
3653
  this.log({
2647
- level: "error",
3654
+ level: "warn",
2648
3655
  message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
2649
3656
  conversation_id: conv.id,
2650
3657
  message_id: inFlight.evidentMessageId
@@ -2667,7 +3674,7 @@ var ChannelDriver = class {
2667
3674
  const activelyRunning = state === "running" && !awaitingHuman;
2668
3675
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
2669
3676
  this.log({
2670
- level: "error",
3677
+ level: "warn",
2671
3678
  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
3679
  conversation_id: conv.id,
2673
3680
  message_id: inFlight.evidentMessageId
@@ -2684,6 +3691,18 @@ var ChannelDriver = class {
2684
3691
  inFlight.aliveInFlight = false;
2685
3692
  if (ok) inFlight.lastAliveAt = this.now();
2686
3693
  });
3694
+ if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
3695
+ inFlight.titleSyncInFlight = true;
3696
+ void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
3697
+ if (!title) {
3698
+ inFlight.titleSyncInFlight = false;
3699
+ return;
3700
+ }
3701
+ const ok = await this.patchConversationTitle(conv.id, title);
3702
+ inFlight.titleSyncInFlight = false;
3703
+ if (ok) inFlight.titleSynced = true;
3704
+ });
3705
+ }
2687
3706
  }
2688
3707
  if (awaitingHuman) {
2689
3708
  if (!inFlight.awaitingHumanLatched) {
@@ -2710,7 +3729,7 @@ var ChannelDriver = class {
2710
3729
  const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
2711
3730
  if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
2712
3731
  this.log({
2713
- level: "info",
3732
+ level: "debug",
2714
3733
  message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
2715
3734
  conversation_id: conv.id,
2716
3735
  message_id: inFlight.evidentMessageId
@@ -2721,9 +3740,7 @@ var ChannelDriver = class {
2721
3740
  this.removeInFlight(watcher, inFlight.evidentMessageId);
2722
3741
  }
2723
3742
  }
2724
- // -------------------------------------------------------------------------
2725
3743
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
2726
- // -------------------------------------------------------------------------
2727
3744
  /**
2728
3745
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
2729
3746
  *
@@ -2753,7 +3770,7 @@ var ChannelDriver = class {
2753
3770
  this.readoptPollUnresolvedSignalled.delete(id);
2754
3771
  if (cleared || clearedUndeliverable) {
2755
3772
  this.log({
2756
- level: "info",
3773
+ level: "debug",
2757
3774
  message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
2758
3775
  message_id: id
2759
3776
  });
@@ -2766,7 +3783,7 @@ var ChannelDriver = class {
2766
3783
  for (const row of rows) {
2767
3784
  if (!row.opencode_session_id) {
2768
3785
  this.log({
2769
- level: "error",
3786
+ level: "warn",
2770
3787
  message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
2771
3788
  conversation_id: row.conversation_id,
2772
3789
  message_id: row.id
@@ -2783,7 +3800,7 @@ var ChannelDriver = class {
2783
3800
  const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
2784
3801
  if (!res.ok) {
2785
3802
  this.log({
2786
- level: "error",
3803
+ level: "warn",
2787
3804
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
2788
3805
  });
2789
3806
  continue;
@@ -2791,7 +3808,7 @@ var ChannelDriver = class {
2791
3808
  const body = await res.json();
2792
3809
  if (!Array.isArray(body)) {
2793
3810
  this.log({
2794
- level: "error",
3811
+ level: "warn",
2795
3812
  message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
2796
3813
  });
2797
3814
  continue;
@@ -2799,7 +3816,7 @@ var ChannelDriver = class {
2799
3816
  messages = body;
2800
3817
  } catch (err) {
2801
3818
  this.log({
2802
- level: "error",
3819
+ level: "warn",
2803
3820
  message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
2804
3821
  });
2805
3822
  continue;
@@ -2832,7 +3849,7 @@ var ChannelDriver = class {
2832
3849
  async readoptOne(sessionId, row, messages, sessionOngoing) {
2833
3850
  if (this.isTracked(sessionId, row.id)) {
2834
3851
  this.log({
2835
- level: "info",
3852
+ level: "debug",
2836
3853
  message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
2837
3854
  conversation_id: row.conversation_id,
2838
3855
  message_id: row.id
@@ -2844,7 +3861,7 @@ var ChannelDriver = class {
2844
3861
  if (state === "done") {
2845
3862
  if (this.doneUndeliverable.has(row.id)) {
2846
3863
  this.log({
2847
- level: "info",
3864
+ level: "debug",
2848
3865
  message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
2849
3866
  conversation_id: row.conversation_id,
2850
3867
  message_id: row.id
@@ -2858,13 +3875,15 @@ var ChannelDriver = class {
2858
3875
  message_id: row.id
2859
3876
  });
2860
3877
  try {
2861
- await this.markDone(row.conversation_id, row.id, sessionId, ocId);
3878
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
3879
+ const usage = messageUsage(messages, ocId ?? "");
3880
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
2862
3881
  } catch (err) {
2863
3882
  if (err instanceof ChannelAuthError) throw err;
2864
3883
  if (err instanceof ChannelTerminalError) {
2865
3884
  this.doneUndeliverable.add(row.id);
2866
3885
  this.log({
2867
- level: "error",
3886
+ level: "warn",
2868
3887
  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
3888
  conversation_id: row.conversation_id,
2870
3889
  message_id: row.id
@@ -2873,7 +3892,7 @@ var ChannelDriver = class {
2873
3892
  return;
2874
3893
  }
2875
3894
  this.log({
2876
- level: "error",
3895
+ level: "warn",
2877
3896
  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
3897
  conversation_id: row.conversation_id,
2879
3898
  message_id: row.id
@@ -2886,6 +3905,7 @@ var ChannelDriver = class {
2886
3905
  }
2887
3906
  if (state === "failed") {
2888
3907
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3908
+ const usage = messageUsage(messages, ocId ?? "");
2889
3909
  this.log({
2890
3910
  level: "error",
2891
3911
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -2893,13 +3913,13 @@ var ChannelDriver = class {
2893
3913
  message_id: row.id
2894
3914
  });
2895
3915
  try {
2896
- await this.markFailed(row.conversation_id, row.id, sessionId, error2);
3916
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
2897
3917
  } catch (err) {
2898
3918
  if (err instanceof ChannelAuthError) throw err;
2899
3919
  if (err instanceof ChannelTerminalError) {
2900
3920
  this.doneUndeliverable.add(row.id);
2901
3921
  this.log({
2902
- level: "error",
3922
+ level: "warn",
2903
3923
  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
3924
  conversation_id: row.conversation_id,
2905
3925
  message_id: row.id
@@ -2908,7 +3928,7 @@ var ChannelDriver = class {
2908
3928
  return;
2909
3929
  }
2910
3930
  this.log({
2911
- level: "error",
3931
+ level: "warn",
2912
3932
  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
3933
  conversation_id: row.conversation_id,
2914
3934
  message_id: row.id
@@ -2921,7 +3941,7 @@ var ChannelDriver = class {
2921
3941
  }
2922
3942
  if (this.dontRedispatch.has(row.id)) {
2923
3943
  this.log({
2924
- level: "info",
3944
+ level: "debug",
2925
3945
  message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
2926
3946
  conversation_id: row.conversation_id,
2927
3947
  message_id: row.id
@@ -2946,7 +3966,7 @@ var ChannelDriver = class {
2946
3966
  }
2947
3967
  if (ongoing === true) {
2948
3968
  this.log({
2949
- level: "info",
3969
+ level: "debug",
2950
3970
  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
3971
  conversation_id: row.conversation_id,
2952
3972
  message_id: row.id
@@ -2954,7 +3974,7 @@ var ChannelDriver = class {
2954
3974
  } else {
2955
3975
  if (shape === "b1") {
2956
3976
  this.log({
2957
- level: "info",
3977
+ level: "debug",
2958
3978
  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
3979
  conversation_id: row.conversation_id,
2960
3980
  message_id: row.id
@@ -2966,7 +3986,7 @@ var ChannelDriver = class {
2966
3986
  return;
2967
3987
  }
2968
3988
  this.log({
2969
- level: "info",
3989
+ level: "debug",
2970
3990
  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
3991
  conversation_id: row.conversation_id,
2972
3992
  message_id: row.id
@@ -2977,7 +3997,7 @@ var ChannelDriver = class {
2977
3997
  const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
2978
3998
  if (descendantAlive === true) {
2979
3999
  this.log({
2980
- level: "info",
4000
+ level: "debug",
2981
4001
  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
4002
  conversation_id: row.conversation_id,
2983
4003
  message_id: row.id
@@ -3001,7 +4021,7 @@ var ChannelDriver = class {
3001
4021
  this.readopted.add(row.id);
3002
4022
  this.ensureWatcherRunning(sessionId);
3003
4023
  this.log({
3004
- level: "info",
4024
+ level: "debug",
3005
4025
  message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
3006
4026
  conversation_id: row.conversation_id,
3007
4027
  message_id: row.id
@@ -3035,7 +4055,7 @@ var ChannelDriver = class {
3035
4055
  async forceReadoptRun(sessionId, row) {
3036
4056
  if (this.stopped) {
3037
4057
  this.log({
3038
- level: "info",
4058
+ level: "debug",
3039
4059
  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
4060
  conversation_id: row.conversation_id,
3041
4061
  message_id: row.id
@@ -3044,7 +4064,7 @@ var ChannelDriver = class {
3044
4064
  }
3045
4065
  if (this.awaitingReadopt.has(row.id)) {
3046
4066
  this.log({
3047
- level: "info",
4067
+ level: "debug",
3048
4068
  message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
3049
4069
  conversation_id: row.conversation_id,
3050
4070
  message_id: row.id
@@ -3054,7 +4074,7 @@ var ChannelDriver = class {
3054
4074
  if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
3055
4075
  this.dontRedispatch.add(row.id);
3056
4076
  this.log({
3057
- level: "info",
4077
+ level: "debug",
3058
4078
  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
4079
  conversation_id: row.conversation_id,
3060
4080
  message_id: row.id
@@ -3073,17 +4093,20 @@ var ChannelDriver = class {
3073
4093
  message_id: row.id
3074
4094
  });
3075
4095
  this.awaitingReadopt.add(row.id);
4096
+ const readoptConv = this.convForRow(sessionId, row);
4097
+ const readoptMessage = this.queuedMessageForRow(row);
4098
+ const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
3076
4099
  let ocId;
3077
4100
  try {
3078
4101
  ocId = await this.dispatchLocked(
3079
4102
  sessionId,
3080
- () => sendPromptAsync(this.port, sessionId, row.content, options)
4103
+ () => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
3081
4104
  );
3082
4105
  } catch (err) {
3083
4106
  this.awaitingReadopt.delete(row.id);
3084
4107
  if (err instanceof ChannelAuthError) throw err;
3085
4108
  this.log({
3086
- level: "error",
4109
+ level: "warn",
3087
4110
  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
4111
  conversation_id: row.conversation_id,
3089
4112
  message_id: row.id
@@ -3094,7 +4117,7 @@ var ChannelDriver = class {
3094
4117
  if (ocId === null) {
3095
4118
  this.awaitingReadopt.delete(row.id);
3096
4119
  this.log({
3097
- level: "error",
4120
+ level: "warn",
3098
4121
  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
4122
  conversation_id: row.conversation_id,
3100
4123
  message_id: row.id
@@ -3102,9 +4125,7 @@ var ChannelDriver = class {
3102
4125
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
3103
4126
  return;
3104
4127
  }
3105
- const conv = this.convForRow(sessionId, row);
3106
- const message = this.queuedMessageForRow(row);
3107
- this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
4128
+ this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
3108
4129
  this.dispatched.add(row.id);
3109
4130
  this.readopted.add(row.id);
3110
4131
  this.awaitingReadopt.delete(row.id);
@@ -3158,7 +4179,8 @@ var ChannelDriver = class {
3158
4179
  opencode_agent: row.opencode_agent,
3159
4180
  opencode_model: row.opencode_model,
3160
4181
  source_message_id: row.source_message_id,
3161
- slack_user_id: row.slack_user_id
4182
+ slack_user_id: row.slack_user_id,
4183
+ attachments: row.attachments ?? null
3162
4184
  };
3163
4185
  }
3164
4186
  /**
@@ -3179,7 +4201,7 @@ var ChannelDriver = class {
3179
4201
  if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
3180
4202
  this.dontRedispatch.add(evidentMessageId);
3181
4203
  this.log({
3182
- level: "info",
4204
+ level: "debug",
3183
4205
  message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
3184
4206
  conversation_id: watcher.conv.id,
3185
4207
  message_id: evidentMessageId
@@ -3319,6 +4341,116 @@ var ChannelDriver = class {
3319
4341
  if (parent !== void 0) this.sessionParents.set(sessionId, parent);
3320
4342
  return parent;
3321
4343
  }
4344
+ /**
4345
+ * OpenCode's synchronous default session title (e.g.
4346
+ * `"New session - 1737800000000"`), assigned immediately when a session is
4347
+ * created — before OpenCode's async LLM-based auto-titling later renames it
4348
+ * mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
4349
+ * timestamp suffix's exact format is deliberately NOT matched, since the prefix
4350
+ * alone is the stable, cheap signal and over-anchoring on the timestamp
4351
+ * representation risks silently breaking if OpenCode ever changes it. Accepted
4352
+ * trade-off: a genuine LLM-assigned title that happens to literally start with
4353
+ * this prefix would also fail to latch (see `resolveSessionTitle`) —
4354
+ * vanishingly unlikely in practice, and deliberately not engineered around.
4355
+ */
4356
+ static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
4357
+ /**
4358
+ * Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
4359
+ * status PATCH can carry it into the "Live sessions" list. Driver-level cache so
4360
+ * BOTH the watcher completion path and the restart-recovery re-adopt path (which
4361
+ * has no watcher) can use it. `conversationId` is passed only for log context.
4362
+ * Best-effort:
4363
+ * - a resolved NON-EMPTY title that does NOT match
4364
+ * `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
4365
+ * won't later un-name), so we do NOT re-GET `/session/:id` every tick;
4366
+ * - while the title is still absent, empty, or matches the OpenCode
4367
+ * placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
4368
+ * asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
4369
+ * the cache unresolved and re-fetch on the next need so a later call (e.g. at
4370
+ * `done`) picks up the name assigned in the meantime. Such a call returns
4371
+ * `null` (omit the title on THIS PATCH) without caching. If a session is
4372
+ * never renamed, the title is omitted forever rather than ever persisting
4373
+ * the placeholder as a last resort;
4374
+ * - a failed request likewise leaves the cache unresolved (retry next need)
4375
+ * and returns `null` — it must NEVER throw or block completion.
4376
+ * A failure is logged with agent/session context (no silent catch).
4377
+ */
4378
+ async resolveSessionTitle(sessionId, conversationId) {
4379
+ const cached = this.sessionTitles.get(sessionId);
4380
+ if (cached != null) return cached;
4381
+ try {
4382
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
4383
+ if (res.ok) {
4384
+ const body = await res.json();
4385
+ const title = body && typeof body.title === "string" ? body.title.trim() : "";
4386
+ if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
4387
+ this.sessionTitles.set(sessionId, title);
4388
+ return title;
4389
+ }
4390
+ return null;
4391
+ }
4392
+ this.log({
4393
+ level: "debug",
4394
+ message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
4395
+ conversation_id: conversationId
4396
+ });
4397
+ } catch (err) {
4398
+ this.log({
4399
+ level: "debug",
4400
+ 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)}`,
4401
+ conversation_id: conversationId
4402
+ });
4403
+ }
4404
+ return null;
4405
+ }
4406
+ /**
4407
+ * Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
4408
+ * session title onto the conversation via the PLAIN conversation-update
4409
+ * endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
4410
+ * message-status endpoint `markProcessing`/`markDone` use. Deliberately a
4411
+ * separate, lighter call: it carries no `status`, so it cannot re-trigger the
4412
+ * `processing`/`done` transition side effects (Slack notices, activity-log
4413
+ * rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
4414
+ * ever touches `conversations.title`. That route (`routes/conversations.ts`)
4415
+ * skips a title write matching the stored value, so a redundant call with the
4416
+ * same title is a real no-op — it does not bump `updated_at`, which the
4417
+ * conversation list sorts and paginates on. (Note this is a DIFFERENT guard
4418
+ * from `threads.ts`'s "non-empty AND changed" one, which only covers the
4419
+ * message-status PATCH; the non-empty half is enforced here instead, by
4420
+ * `resolveSessionTitle` never returning an empty/placeholder title.)
4421
+ *
4422
+ * Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
4423
+ * is logged and the title is simply retried on the next heartbeat tick (the
4424
+ * caller only latches `titleSynced` on `true`).
4425
+ */
4426
+ async patchConversationTitle(conversationId, title) {
4427
+ try {
4428
+ const res = await this.fetchImpl(
4429
+ `${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
4430
+ {
4431
+ method: "PATCH",
4432
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
4433
+ body: JSON.stringify({ title })
4434
+ }
4435
+ );
4436
+ if (!res.ok) {
4437
+ this.log({
4438
+ level: "debug",
4439
+ message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
4440
+ conversation_id: conversationId
4441
+ });
4442
+ return false;
4443
+ }
4444
+ return true;
4445
+ } catch (err) {
4446
+ this.log({
4447
+ level: "debug",
4448
+ message: `Best-effort mid-turn title sync PATCH failed for conversation ${conversationId.slice(0, 8)} (will retry next heartbeat): ${err instanceof Error ? err.message : String(err)}`,
4449
+ conversation_id: conversationId
4450
+ });
4451
+ return false;
4452
+ }
4453
+ }
3322
4454
  /**
3323
4455
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
3324
4456
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -3362,7 +4494,7 @@ var ChannelDriver = class {
3362
4494
  const sessions = await listSessions(this.port);
3363
4495
  if (!sessions) {
3364
4496
  this.log({
3365
- level: "error",
4497
+ level: "warn",
3366
4498
  message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
3367
4499
  });
3368
4500
  return null;
@@ -3446,12 +4578,10 @@ var ChannelDriver = class {
3446
4578
  }
3447
4579
  return inFlight.sort(byOldest)[0];
3448
4580
  }
3449
- // -------------------------------------------------------------------------
3450
4581
  // Evident API calls (combinedAuth thread routes)
3451
- // -------------------------------------------------------------------------
3452
4582
  async getPendingConversations() {
3453
4583
  const res = await this.fetchImpl(
3454
- `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
4584
+ `${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
3455
4585
  {
3456
4586
  headers: { Authorization: this.getAuthHeader() }
3457
4587
  }
@@ -3469,7 +4599,7 @@ var ChannelDriver = class {
3469
4599
  }
3470
4600
  async getPendingMessages(conversationId) {
3471
4601
  const res = await this.fetchImpl(
3472
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
4602
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
3473
4603
  { headers: { Authorization: this.getAuthHeader() } }
3474
4604
  );
3475
4605
  this.assertAuth(res, "fetching pending messages");
@@ -3493,7 +4623,7 @@ var ChannelDriver = class {
3493
4623
  */
3494
4624
  async getProcessingMessages() {
3495
4625
  const res = await this.fetchImpl(
3496
- `${this.apiUrl}/agents/${this.agentId}/conversations/processing`,
4626
+ `${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
3497
4627
  { headers: { Authorization: this.getAuthHeader() } }
3498
4628
  );
3499
4629
  this.assertAuth(res, "fetching processing messages");
@@ -3507,6 +4637,32 @@ var ChannelDriver = class {
3507
4637
  }
3508
4638
  return messages;
3509
4639
  }
4640
+ /**
4641
+ * The `opencode_session_id` fragment of a status PATCH body — `{}` when this
4642
+ * conversation has ABANDONED that session (#553). The field is optional
4643
+ * server-side and an absent one leaves the persisted binding untouched, so
4644
+ * omitting it is how a routine status write stops resurrecting it.
4645
+ *
4646
+ * ONLY for writes whose sole cost is a lost deep link. The `processing` notice
4647
+ * degrades to no "View in Evident" link (the reaction swap still fires) and the
4648
+ * turn-failure notice is built from the PATCH's own `error` text with a link off
4649
+ * the persisted row — neither loses content the user came for. `markDone`
4650
+ * deliberately does NOT use this helper: the server fetches the reply text
4651
+ * THROUGH the session id it is given, so suppressing there would replace the
4652
+ * agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
4653
+ * `ensureSession` guard, not this suppression, is what makes the self-heal
4654
+ * stick.
4655
+ */
4656
+ sessionIdBody(sessionId, conversationId, messageId, status) {
4657
+ if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
4658
+ this.log({
4659
+ level: "debug",
4660
+ message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
4661
+ conversation_id: conversationId,
4662
+ message_id: messageId
4663
+ });
4664
+ return {};
4665
+ }
3510
4666
  /**
3511
4667
  * EXISTING combinedAuth route — now fired by the watcher on queued→running
3512
4668
  * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
@@ -3528,16 +4684,17 @@ var ChannelDriver = class {
3528
4684
  * A single attempt (no internal retry): the watcher's per-tick loop is the
3529
4685
  * retry vehicle for the swap-to-running.
3530
4686
  */
3531
- async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
4687
+ async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
3532
4688
  const res = await this.fetchImpl(
3533
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4689
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3534
4690
  {
3535
4691
  method: "PATCH",
3536
4692
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
3537
4693
  body: JSON.stringify({
3538
4694
  status: "processing",
3539
- opencode_session_id: sessionId,
3540
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
4695
+ ...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
4696
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
4697
+ ...title ? { title } : {}
3541
4698
  })
3542
4699
  }
3543
4700
  );
@@ -3576,16 +4733,23 @@ var ChannelDriver = class {
3576
4733
  * watcher retries next tick within the
3577
4734
  * deadline, Finding 4).
3578
4735
  */
3579
- async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
4736
+ async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
3580
4737
  const res = await this.fetchImpl(
3581
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4738
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3582
4739
  {
3583
4740
  method: "PATCH",
3584
4741
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
3585
4742
  body: JSON.stringify({
3586
4743
  status: "done",
4744
+ // ALWAYS sent, even for a session this conversation has abandoned
4745
+ // (#553): the server reads the reply text back out of THIS session id
4746
+ // to deliver it. Omitting it would leave the user with "✅ Done!"
4747
+ // instead of the answer — a worse regression than the resurrection it
4748
+ // would prevent, which `ensureSession`'s guard handles anyway.
3587
4749
  opencode_session_id: sessionId,
3588
- ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
4750
+ ...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
4751
+ ...title ? { title } : {},
4752
+ ...usage ? usage : {}
3589
4753
  })
3590
4754
  }
3591
4755
  );
@@ -3598,19 +4762,29 @@ var ChannelDriver = class {
3598
4762
  }
3599
4763
  /**
3600
4764
  * Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
3601
- * when provided (issue #182): a bare `markFailed(conv, msg)` sends
3602
- * `{status:'failed'}` unchanged (the dispatch-failure path), while an errored
3603
- * OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
3604
- * failure reason reaches the channel.
4765
+ * when provided (issue #182). Three states for `sessionId`:
4766
+ * - omitted (`undefined`) → don't send the field, leave the persisted
4767
+ * session untouched (unused today; kept for API symmetry).
4768
+ * - a real id (`string`) → send it, update the persisted session (the
4769
+ * turn-failure call sites: an errored OpenCode turn).
4770
+ * - explicit `null` → send it, CLEAR the persisted session (issue
4771
+ * #485's dispatch-handoff-failure call site: the session id still
4772
+ * exists but is wedged, so the next attempt must get a fresh one
4773
+ * instead of reusing it — see WI-1's server-side null-clearing PATCH).
3605
4774
  */
3606
- async markFailed(conversationId, messageId, sessionId, error2) {
4775
+ async markFailed(conversationId, messageId, sessionId, error2, usage) {
3607
4776
  const body = { status: "failed" };
3608
- if (sessionId !== void 0) body.opencode_session_id = sessionId;
4777
+ if (sessionId === null) {
4778
+ body.opencode_session_id = null;
4779
+ } else if (sessionId !== void 0) {
4780
+ Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
4781
+ }
3609
4782
  if (error2 !== void 0) body.error = error2;
4783
+ if (usage) Object.assign(body, usage);
3610
4784
  await this.callWithRetry(
3611
4785
  "marking message as failed",
3612
4786
  () => this.fetchImpl(
3613
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
4787
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
3614
4788
  {
3615
4789
  method: "PATCH",
3616
4790
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3637,7 +4811,7 @@ var ChannelDriver = class {
3637
4811
  async postSignal(conversationId, messageId, signal, extra) {
3638
4812
  try {
3639
4813
  const res = await this.fetchImpl(
3640
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
4814
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
3641
4815
  {
3642
4816
  method: "POST",
3643
4817
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3646,7 +4820,7 @@ var ChannelDriver = class {
3646
4820
  );
3647
4821
  if (!res.ok) {
3648
4822
  this.log({
3649
- level: "error",
4823
+ level: "warn",
3650
4824
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
3651
4825
  conversation_id: conversationId,
3652
4826
  message_id: messageId
@@ -3656,7 +4830,7 @@ var ChannelDriver = class {
3656
4830
  return true;
3657
4831
  } catch (err) {
3658
4832
  this.log({
3659
- level: "error",
4833
+ level: "warn",
3660
4834
  message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
3661
4835
  conversation_id: conversationId,
3662
4836
  message_id: messageId
@@ -3666,7 +4840,7 @@ var ChannelDriver = class {
3666
4840
  }
3667
4841
  async persistSession(conversationId, sessionId) {
3668
4842
  const res = await this.fetchImpl(
3669
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
4843
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
3670
4844
  {
3671
4845
  method: "PATCH",
3672
4846
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3692,7 +4866,7 @@ var ChannelDriver = class {
3692
4866
  await this.callWithRetry(
3693
4867
  "reporting interactive event",
3694
4868
  () => this.fetchImpl(
3695
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
4869
+ `${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
3696
4870
  {
3697
4871
  method: "POST",
3698
4872
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
@@ -3718,9 +4892,7 @@ var ChannelDriver = class {
3718
4892
  return false;
3719
4893
  }
3720
4894
  }
3721
- // -------------------------------------------------------------------------
3722
4895
  // Retry wrapper
3723
- // -------------------------------------------------------------------------
3724
4896
  /**
3725
4897
  * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
3726
4898
  * network errors) with exponential backoff + jitter (capped). Auth failures
@@ -3919,7 +5091,7 @@ async function resolveAgentIdFromKey(authHeader) {
3919
5091
  if (!response.ok) {
3920
5092
  const serverMessage = await readErrorMessage(response);
3921
5093
  return {
3922
- error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
5094
+ error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
3923
5095
  };
3924
5096
  }
3925
5097
  const data = await response.json();
@@ -3927,19 +5099,49 @@ async function resolveAgentIdFromKey(authHeader) {
3927
5099
  return { agent_id: data.agent_id };
3928
5100
  }
3929
5101
  return {
3930
- error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
5102
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
3931
5103
  };
3932
5104
  } catch (error2) {
3933
5105
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3934
- return { error: `Failed to resolve agent from key: ${message}` };
5106
+ return { error: `Failed to resolve runner from key: ${message}` };
3935
5107
  }
3936
5108
  }
5109
+ var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
3937
5110
  async function notifyAgentDisconnected(agentId, authHeader) {
3938
5111
  const apiUrl = getApiUrlConfig();
3939
5112
  try {
3940
- const response = await fetch(`${apiUrl}/agents/${agentId}/disconnect`, {
5113
+ const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
3941
5114
  method: "POST",
3942
- headers: { Authorization: authHeader }
5115
+ headers: { Authorization: authHeader },
5116
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5117
+ });
5118
+ if (!response.ok) {
5119
+ const serverMessage = await readErrorMessage(response);
5120
+ return {
5121
+ ok: false,
5122
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5123
+ };
5124
+ }
5125
+ return { ok: true };
5126
+ } catch (error2) {
5127
+ return { ok: false, error: describeBestEffortError(error2) };
5128
+ }
5129
+ }
5130
+ function describeBestEffortError(error2) {
5131
+ const name = error2?.name;
5132
+ if (name === "TimeoutError" || name === "AbortError") {
5133
+ return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5134
+ }
5135
+ return error2 instanceof Error ? error2.message : String(error2);
5136
+ }
5137
+ async function reportMicrovmId(agentId, authHeader, microvmId) {
5138
+ try {
5139
+ const apiUrl = getApiUrlConfig();
5140
+ const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5141
+ method: "POST",
5142
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
5143
+ body: JSON.stringify({ microvm_id: microvmId }),
5144
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
3943
5145
  });
3944
5146
  if (!response.ok) {
3945
5147
  const serverMessage = await readErrorMessage(response);
@@ -3950,13 +5152,13 @@ async function notifyAgentDisconnected(agentId, authHeader) {
3950
5152
  }
3951
5153
  return { ok: true };
3952
5154
  } catch (error2) {
3953
- return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
5155
+ return { ok: false, error: describeBestEffortError(error2) };
3954
5156
  }
3955
5157
  }
3956
5158
  async function getAgentInfo(agentId, authHeader) {
3957
5159
  const apiUrl = getApiUrlConfig();
3958
5160
  try {
3959
- const response = await fetch(`${apiUrl}/agents/${agentId}`, {
5161
+ const response = await fetch(`${apiUrl}/runners/${agentId}`, {
3960
5162
  headers: { Authorization: authHeader }
3961
5163
  });
3962
5164
  if (response.status === 401) {
@@ -3967,12 +5169,12 @@ async function getAgentInfo(agentId, authHeader) {
3967
5169
  const serverMessage = await readErrorMessage(response);
3968
5170
  return {
3969
5171
  valid: false,
3970
- error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
5172
+ error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
3971
5173
  };
3972
5174
  }
3973
5175
  if (response.status === 404) {
3974
5176
  const serverMessage = await readErrorMessage(response);
3975
- return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
5177
+ return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
3976
5178
  }
3977
5179
  if (!response.ok) {
3978
5180
  const serverMessage = await readErrorMessage(response);
@@ -3985,13 +5187,13 @@ async function getAgentInfo(agentId, authHeader) {
3985
5187
  if (agent.agent_type !== "local") {
3986
5188
  return {
3987
5189
  valid: false,
3988
- error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
5190
+ error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
3989
5191
  };
3990
5192
  }
3991
5193
  return { valid: true, agent };
3992
5194
  } catch (error2) {
3993
5195
  const message = error2 instanceof Error ? error2.message : "Unknown error";
3994
- return { valid: false, error: `Failed to validate agent: ${message}` };
5196
+ return { valid: false, error: `Failed to validate runner: ${message}` };
3995
5197
  }
3996
5198
  }
3997
5199
 
@@ -4000,23 +5202,82 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
4000
5202
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
4001
5203
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
4002
5204
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
4003
- function log2(state, message, isError = false) {
5205
+ var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
5206
+ function resolveLogLevel(options) {
5207
+ const accepted = Object.keys(LOG_LEVELS);
5208
+ const validate = (value, source) => {
5209
+ const normalized = value.trim().toLowerCase();
5210
+ if (!accepted.includes(normalized)) {
5211
+ throw new Error(
5212
+ `Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
5213
+ );
5214
+ }
5215
+ return normalized;
5216
+ };
5217
+ if (options.logLevel !== void 0) {
5218
+ return validate(options.logLevel, " (--log-level)");
5219
+ }
5220
+ if (options.verbose) {
5221
+ return "debug";
5222
+ }
5223
+ const env = process.env.EVIDENT_LOG_LEVEL;
5224
+ if (env !== void 0 && env !== "") {
5225
+ return validate(env, " (EVIDENT_LOG_LEVEL)");
5226
+ }
5227
+ return "info";
5228
+ }
5229
+ function resolveFileSyncDirectories(raw, homeDir) {
5230
+ const directories = [];
5231
+ for (const entry of raw ?? []) {
5232
+ const trimmed = entry.trim();
5233
+ if (trimmed === "") {
5234
+ throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
5235
+ }
5236
+ const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
5237
+ if (!isAbsolute2(expanded)) {
5238
+ throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
5239
+ }
5240
+ const normalized = resolvePath(expanded);
5241
+ if (parse(normalized).root === normalized) {
5242
+ throw new Error(
5243
+ `--enable-file-sync-to will not allow-list the filesystem root ("${entry}"); name the specific directory the credentials belong in (for example ~/.claude)`
5244
+ );
5245
+ }
5246
+ if (!directories.includes(normalized)) {
5247
+ directories.push(normalized);
5248
+ }
5249
+ }
5250
+ if (directories.length > MAX_FILE_SYNC_DIRECTORIES) {
5251
+ throw new Error(
5252
+ `--enable-file-sync-to accepts at most ${MAX_FILE_SYNC_DIRECTORIES} directories; got ${directories.length}`
5253
+ );
5254
+ }
5255
+ return directories;
5256
+ }
5257
+ function meetsThreshold(state, level) {
5258
+ return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
5259
+ }
5260
+ function log2(state, message, level = "info") {
5261
+ if (!meetsThreshold(state, level)) return;
4004
5262
  if (state.json) {
4005
5263
  console.log(
4006
5264
  JSON.stringify({
4007
5265
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4008
- level: isError ? "error" : "info",
5266
+ level,
4009
5267
  message
4010
5268
  })
4011
5269
  );
4012
5270
  } else if (!state.interactive) {
4013
- const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
5271
+ const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
4014
5272
  console.log(`${prefix} ${message}`);
4015
5273
  }
4016
5274
  }
4017
5275
  function logActivity(state, entry) {
5276
+ const level = entry.level ?? (entry.type === "error" ? "error" : "info");
5277
+ if (!meetsThreshold(state, level)) return;
4018
5278
  const fullEntry = {
4019
5279
  ...entry,
5280
+ level,
4020
5281
  timestamp: /* @__PURE__ */ new Date()
4021
5282
  };
4022
5283
  state.activityLog.push(fullEntry);
@@ -4025,9 +5286,9 @@ function logActivity(state, entry) {
4025
5286
  }
4026
5287
  if (!state.interactive) {
4027
5288
  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);
5289
+ log2(state, entry.error ?? "Unknown error", level);
5290
+ } else if (entry.message) {
5291
+ log2(state, entry.message, level);
4031
5292
  }
4032
5293
  }
4033
5294
  }
@@ -4114,18 +5375,29 @@ async function handleAuthError(state, error2) {
4114
5375
  async function driveChannels(state, driver) {
4115
5376
  let idlePolls = 0;
4116
5377
  let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
5378
+ let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
4117
5379
  while (state.running) {
4118
5380
  if (state.connection?.reconnecting && state.connection.reconnectPromise) {
4119
5381
  logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
4120
5382
  if (state.interactive) displayStatus(state);
4121
5383
  await state.connection.reconnectPromise;
4122
5384
  }
5385
+ const carriedOverFileSync = driver.fileSyncActivity().inFlight;
5386
+ void driver.syncPendingFiles().catch(
5387
+ (error2) => logActivity(state, {
5388
+ type: "error",
5389
+ error: `Runner file sync failed: ${error2 instanceof Error ? error2.message : String(error2)}`
5390
+ })
5391
+ );
4123
5392
  try {
4124
5393
  const processed = await driver.drainPending();
4125
5394
  state.messageCount += processed;
4126
5395
  const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
4127
5396
  lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
4128
- if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity) {
5397
+ const appliedFiles = driver.fileSyncActivity().appliedFiles;
5398
+ const fileActivity = carriedOverFileSync || appliedFiles !== lastSeenAppliedFiles;
5399
+ lastSeenAppliedFiles = appliedFiles;
5400
+ if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
4129
5401
  idlePolls = 0;
4130
5402
  if (processed > 0 && state.interactive) displayStatus(state);
4131
5403
  } else if (state.idleTimeout !== null) {
@@ -4154,7 +5426,7 @@ async function driveChannels(state, driver) {
4154
5426
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
4155
5427
  if (state.interactive) displayStatus(state);
4156
5428
  }
4157
- await new Promise((resolve2) => setTimeout(resolve2, CHANNEL_POLL_INTERVAL_MS));
5429
+ await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
4158
5430
  if (state.idleTimeout !== null && idlePolls >= 2) {
4159
5431
  const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
4160
5432
  if (idleMs > state.idleTimeout * 1e3) {
@@ -4226,7 +5498,7 @@ function scheduleSessionCleanup(state, driver, options) {
4226
5498
  process.env
4227
5499
  );
4228
5500
  for (const warning2 of config2.warnings) {
4229
- logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
5501
+ logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
4230
5502
  }
4231
5503
  if (!config2.enabled) return;
4232
5504
  logActivity(state, {
@@ -4248,7 +5520,7 @@ async function notifyOffline(state) {
4248
5520
  }
4249
5521
  const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
4250
5522
  if (result.ok) {
4251
- log2(state, "Notified Evident the agent is going offline");
5523
+ log2(state, "Notified Evident the runner is going offline");
4252
5524
  } else {
4253
5525
  logActivity(state, {
4254
5526
  type: "error",
@@ -4257,7 +5529,18 @@ async function notifyOffline(state) {
4257
5529
  if (state.interactive) displayStatus(state);
4258
5530
  }
4259
5531
  }
5532
+ async function timeShutdownPhase(state, durations, name, run2) {
5533
+ const startedAt = Date.now();
5534
+ try {
5535
+ return await run2();
5536
+ } finally {
5537
+ const elapsedMs = Date.now() - startedAt;
5538
+ durations[name] = elapsedMs;
5539
+ log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
5540
+ }
5541
+ }
4260
5542
  async function cleanup(state, opts = {}) {
5543
+ const durations = {};
4261
5544
  state.running = false;
4262
5545
  for (const timer of state.sessionCleanupTimers) {
4263
5546
  clearInterval(timer);
@@ -4271,7 +5554,13 @@ async function cleanup(state, opts = {}) {
4271
5554
  logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
4272
5555
  displayStatus(state);
4273
5556
  }
4274
- const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
5557
+ const driver = state.channelDriver;
5558
+ const settled = await timeShutdownPhase(
5559
+ state,
5560
+ durations,
5561
+ "drain",
5562
+ () => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
5563
+ );
4275
5564
  if (!settled) {
4276
5565
  logActivity(state, {
4277
5566
  type: "info",
@@ -4280,13 +5569,15 @@ async function cleanup(state, opts = {}) {
4280
5569
  if (state.interactive) displayStatus(state);
4281
5570
  }
4282
5571
  }
4283
- await notifyOffline(state);
5572
+ await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
4284
5573
  if (state.connection) {
4285
- state.connection.close();
5574
+ const connection = state.connection;
5575
+ await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
4286
5576
  state.connection = null;
4287
5577
  }
4288
5578
  if (state.opencodeProcess) {
4289
- stopOpenCode(state.opencodeProcess);
5579
+ const opencodeProcess = state.opencodeProcess;
5580
+ await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
4290
5581
  if (state.interactive) {
4291
5582
  logActivity(state, { type: "info", message: "Stopped OpenCode process" });
4292
5583
  displayStatus(state);
@@ -4295,17 +5586,35 @@ async function cleanup(state, opts = {}) {
4295
5586
  }
4296
5587
  state.opencodeProcess = null;
4297
5588
  }
5589
+ return durations;
4298
5590
  }
4299
5591
  async function run(options) {
4300
5592
  const interactive = isInteractive(options.json);
5593
+ let logLevel;
5594
+ let fileSyncDirectories;
5595
+ try {
5596
+ logLevel = resolveLogLevel(options);
5597
+ fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
5598
+ } catch (error2) {
5599
+ const message = error2 instanceof Error ? error2.message : String(error2);
5600
+ if (options.json) {
5601
+ console.log(JSON.stringify({ status: "error", error: message }));
5602
+ } else {
5603
+ printError(message);
5604
+ }
5605
+ await shutdownTelemetry();
5606
+ process.exit(1);
5607
+ return;
5608
+ }
4301
5609
  const state = {
4302
- agentId: options.agent || "",
5610
+ agentId: options.runner || options.agent || "",
4303
5611
  agentName: null,
4304
5612
  port: options.port ?? 4096,
4305
5613
  conversationFilter: options.conversation ?? null,
4306
5614
  idleTimeout: options.idleTimeout ?? null,
4307
5615
  json: options.json ?? false,
4308
5616
  interactive,
5617
+ logLevel,
4309
5618
  connected: false,
4310
5619
  opencodeConnected: false,
4311
5620
  opencodeVersion: null,
@@ -4320,24 +5629,66 @@ async function run(options) {
4320
5629
  sessionCleanupTimers: [],
4321
5630
  authHeader: ""
4322
5631
  };
5632
+ if (fileSyncDirectories.length > 0) {
5633
+ log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
5634
+ } else {
5635
+ log2(state, "File sync is disabled (no --enable-file-sync-to given)", "debug");
5636
+ }
5637
+ if (!options.runner && options.agent) {
5638
+ telemetry.info(
5639
+ EventTypes.DEPRECATED_AGENT_FLAG_USED,
5640
+ "Deprecated --agent flag used instead of --runner",
5641
+ { command: "run" },
5642
+ state.agentId
5643
+ );
5644
+ const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
5645
+ log2(state, agentFlagNotice, "warn");
5646
+ if (state.interactive && !state.json) {
5647
+ logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
5648
+ }
5649
+ }
4323
5650
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
4324
5651
  log2(
4325
5652
  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
5653
+ "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.",
5654
+ "warn"
4328
5655
  );
4329
5656
  }
4330
5657
  const handleSignal = async () => {
4331
5658
  if (state.shuttingDown) return;
4332
5659
  state.shuttingDown = true;
5660
+ const shutdownStartedAt = Date.now();
4333
5661
  if (state.interactive) {
4334
5662
  logActivity(state, { type: "info", message: "Shutting down..." });
4335
5663
  displayStatus(state);
4336
5664
  } else {
4337
5665
  log2(state, "Shutting down...");
4338
5666
  }
4339
- await cleanup(state, { graceful: true });
4340
- await shutdownTelemetry();
5667
+ const durations = await cleanup(state, { graceful: true });
5668
+ const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
5669
+ await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
5670
+ let timer;
5671
+ const flushed = shutdownTelemetry().then(
5672
+ () => true,
5673
+ (error2) => {
5674
+ log2(
5675
+ state,
5676
+ `Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
5677
+ "warn"
5678
+ );
5679
+ return true;
5680
+ }
5681
+ );
5682
+ const timedOut = new Promise((resolve3) => {
5683
+ timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
5684
+ });
5685
+ if (!await Promise.race([flushed, timedOut])) {
5686
+ log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
5687
+ }
5688
+ clearTimeout(timer);
5689
+ });
5690
+ const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
5691
+ log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
4341
5692
  process.exit(0);
4342
5693
  };
4343
5694
  process.on("SIGINT", handleSignal);
@@ -4348,7 +5699,9 @@ async function run(options) {
4348
5699
  if (!interactive) {
4349
5700
  printError("Authentication required");
4350
5701
  blank();
4351
- console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
5702
+ console.log(
5703
+ chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
5704
+ );
4352
5705
  console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
4353
5706
  blank();
4354
5707
  process.exit(1);
@@ -4362,26 +5715,51 @@ async function run(options) {
4362
5715
  );
4363
5716
  }
4364
5717
  state.authHeader = getAuthHeader(credentials2);
5718
+ if (credentials2.notice) {
5719
+ log2(state, credentials2.notice, "warn");
5720
+ if (state.interactive && !state.json) {
5721
+ logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
5722
+ }
5723
+ }
5724
+ if (credentials2.keySource === "agent_key") {
5725
+ telemetry.info(
5726
+ EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
5727
+ "Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
5728
+ { command: "run" },
5729
+ state.agentId
5730
+ );
5731
+ const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
5732
+ log2(state, agentKeyNotice, "warn");
5733
+ if (state.interactive && !state.json) {
5734
+ logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
5735
+ }
5736
+ }
4365
5737
  if (!state.agentId) {
4366
5738
  if (credentials2.authType === "agent_key") {
4367
5739
  const resolved = await resolveAgentIdFromKey(state.authHeader);
4368
5740
  if (resolved.agent_id) {
4369
5741
  state.agentId = resolved.agent_id;
4370
- log2(state, `Resolved agent ID from key: ${state.agentId}`);
5742
+ log2(state, `Resolved runner ID from key: ${state.agentId}`);
4371
5743
  if (state.interactive && !state.json) {
4372
5744
  logActivity(state, {
4373
5745
  type: "info",
4374
- message: `Agent ID resolved from key: ${state.agentId}`
5746
+ message: `Runner ID resolved from key: ${state.agentId}`
4375
5747
  });
4376
5748
  }
4377
5749
  } else {
4378
- printError(resolved.error || "Failed to resolve agent ID from key");
5750
+ printError(resolved.error || "Failed to resolve runner ID from key");
4379
5751
  process.exit(1);
4380
5752
  }
4381
5753
  } else {
4382
- printError("--agent is required when not using EVIDENT_AGENT_KEY");
5754
+ printError(
5755
+ "--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
5756
+ );
4383
5757
  blank();
4384
- console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
5758
+ console.log(
5759
+ chalk6.dim(
5760
+ "Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
5761
+ )
5762
+ );
4385
5763
  blank();
4386
5764
  process.exit(1);
4387
5765
  }
@@ -4403,7 +5781,7 @@ async function run(options) {
4403
5781
  console.log(chalk6.bold("Evident Run"));
4404
5782
  console.log(chalk6.dim("-".repeat(40)));
4405
5783
  }
4406
- const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
5784
+ const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
4407
5785
  let validation = await getAgentInfo(state.agentId, state.authHeader);
4408
5786
  if (!validation.valid && validation.authFailed && interactive) {
4409
5787
  spinner?.fail("Authentication failed");
@@ -4415,15 +5793,30 @@ async function run(options) {
4415
5793
  "Login successful! Retrying..."
4416
5794
  );
4417
5795
  state.authHeader = getAuthHeader(credentials2);
4418
- spinner?.start("Validating agent...");
5796
+ spinner?.start("Validating runner...");
4419
5797
  validation = await getAgentInfo(state.agentId, state.authHeader);
4420
5798
  }
4421
5799
  if (!validation.valid) {
4422
- spinner?.fail(`Agent validation failed: ${validation.error}`);
5800
+ spinner?.fail(`Runner validation failed: ${validation.error}`);
4423
5801
  throw new Error(validation.error);
4424
5802
  }
4425
- spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
5803
+ spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
4426
5804
  state.agentName = validation.agent.name;
5805
+ const microvmId = process.env.MICROVM_ID?.trim();
5806
+ if (microvmId) {
5807
+ const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
5808
+ if (reported.ok) {
5809
+ log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
5810
+ } else {
5811
+ const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
5812
+ log2(state, message, "warn");
5813
+ if (state.interactive && !state.json) {
5814
+ logActivity(state, { type: "info", level: "warn", message });
5815
+ }
5816
+ }
5817
+ } else {
5818
+ log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
5819
+ }
4427
5820
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
4428
5821
  try {
4429
5822
  const oc = await ensureOpenCodeRunning({
@@ -4440,9 +5833,24 @@ async function run(options) {
4440
5833
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
4441
5834
  const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
4442
5835
  if (versionWarning) {
4443
- log2(state, versionWarning, false);
5836
+ log2(state, versionWarning, "warn");
4444
5837
  if (state.interactive && !state.json) {
4445
- logActivity(state, { type: "info", message: versionWarning });
5838
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
5839
+ }
5840
+ }
5841
+ const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
5842
+ if (noProviderWarning) {
5843
+ log2(state, noProviderWarning, "warn");
5844
+ if (state.interactive && !state.json) {
5845
+ logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
5846
+ blank();
5847
+ console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
5848
+ console.log(
5849
+ chalk6.dim(
5850
+ `Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
5851
+ )
5852
+ );
5853
+ blank();
4446
5854
  }
4447
5855
  }
4448
5856
  } catch (error2) {
@@ -4457,11 +5865,21 @@ async function run(options) {
4457
5865
  getAuthHeader: () => state.authHeader,
4458
5866
  conversationFilter: state.conversationFilter,
4459
5867
  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
- })
5868
+ // #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
5869
+ // REJECTED with `file_sync_disabled` on the ack, not silently ignored.
5870
+ fileSyncDirectories,
5871
+ homeDir: homedir2(),
5872
+ log: (entry) => (
5873
+ // Thread the driver's real level straight through so `debug`/`warn`
5874
+ // survive the sink filter (they no longer collapse to info). `type`
5875
+ // stays the coarse error/non-error split the activity log renders with.
5876
+ logActivity(state, {
5877
+ type: entry.level === "error" ? "error" : "info",
5878
+ level: entry.level,
5879
+ message: entry.message,
5880
+ error: entry.level === "error" ? entry.message : void 0
5881
+ })
5882
+ )
4465
5883
  });
4466
5884
  state.channelDriver = channelDriver;
4467
5885
  const connection = new RunnerConnection({
@@ -4475,7 +5893,7 @@ async function run(options) {
4475
5893
  state.agentId = agentId;
4476
5894
  logActivity(state, {
4477
5895
  type: "info",
4478
- message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
5896
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
4479
5897
  });
4480
5898
  emitAgentConnected(state.agentId, {
4481
5899
  port: state.port,
@@ -4532,6 +5950,12 @@ async function run(options) {
4532
5950
  onDrainPing: () => {
4533
5951
  if (!state.running) return;
4534
5952
  logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
5953
+ void channelDriver.syncPendingFiles().catch(
5954
+ (error2) => logActivity(state, {
5955
+ type: "error",
5956
+ error: `Runner file sync failed on ping: ${error2 instanceof Error ? error2.message : String(error2)}`
5957
+ })
5958
+ );
4535
5959
  channelDriver.drainPending().then((processed) => {
4536
5960
  if (processed > 0) {
4537
5961
  state.messageCount += processed;
@@ -4590,7 +6014,7 @@ async function run(options) {
4590
6014
  }
4591
6015
  telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
4592
6016
  command: "run",
4593
- agentId: options.agent
6017
+ agentId: options.runner || options.agent
4594
6018
  });
4595
6019
  await shutdownTelemetry();
4596
6020
  process.exit(1);
@@ -4615,7 +6039,13 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
4615
6039
  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
6040
  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
6041
  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(
6042
+ program.command("run").description("Connect to Evident and process messages").option("--runner [id]", "Runner ID to connect to (optional when EVIDENT_RUNNER_KEY is set)").option(
6043
+ "-a, --agent [id]",
6044
+ "Deprecated alias for --runner (still supported; --runner wins if both are given)"
6045
+ ).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
6046
+ "--log-level <level>",
6047
+ "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
6048
+ ).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
6049
  "--session-cleanup-max-age <duration>",
4620
6050
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
4621
6051
  ).option(
@@ -4624,11 +6054,20 @@ program.command("run").description("Connect to Evident and process messages").op
4624
6054
  ).option(
4625
6055
  "--session-cleanup-interval <duration>",
4626
6056
  "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
6057
+ ).option(
6058
+ "--enable-file-sync-to <dir>",
6059
+ "Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
6060
+ (value, previous) => previous.concat([value]),
6061
+ []
4627
6062
  ).action(
4628
6063
  (options) => {
4629
6064
  run({
4630
6065
  agent: options.agent,
6066
+ runner: options.runner,
4631
6067
  port: parseInt(options.port, 10),
6068
+ // Raw string — validation/precedence is single-sourced in run.ts's
6069
+ // resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
6070
+ logLevel: options.logLevel,
4632
6071
  verbose: options.verbose,
4633
6072
  conversation: options.conversation,
4634
6073
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
@@ -4636,7 +6075,10 @@ program.command("run").description("Connect to Evident and process messages").op
4636
6075
  // Raw strings — the resolver in run.ts single-sources parsing (M1).
4637
6076
  sessionCleanupMaxAge: options.sessionCleanupMaxAge,
4638
6077
  sessionCleanupMaxCount: options.sessionCleanupMaxCount,
4639
- sessionCleanupInterval: options.sessionCleanupInterval
6078
+ sessionCleanupInterval: options.sessionCleanupInterval,
6079
+ // Raw values — expansion/validation is single-sourced in run.ts's
6080
+ // resolveFileSyncDirectories.
6081
+ enableFileSyncTo: options.enableFileSyncTo
4640
6082
  });
4641
6083
  }
4642
6084
  );