@evident-ai/cli 3.0.0 → 3.0.1-dev.0a98dec

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
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { createRequire } from "module";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/commands/login.ts
@@ -32,10 +33,10 @@ function setTunnelUrl(url) {
32
33
  tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
33
34
  }
34
35
  function getApiUrl() {
35
- return process.env.EVIDENT_API_URL ?? endpointOverride ?? defaults.apiUrl;
36
+ return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
36
37
  }
37
38
  function getTunnelUrl() {
38
- return process.env.EVIDENT_TUNNEL_URL ?? tunnelOverride ?? defaults.tunnelUrl;
39
+ return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
39
40
  }
40
41
  var config = new Conf({
41
42
  projectName: "evident",
@@ -54,19 +55,28 @@ function getApiUrlConfig() {
54
55
  function getTunnelUrlConfig() {
55
56
  return getTunnelUrl();
56
57
  }
58
+ function credentialsKey() {
59
+ return getApiUrl();
60
+ }
57
61
  function getCredentials() {
58
- return {
59
- token: credentials.get("token"),
60
- user: credentials.get("user"),
61
- expiresAt: credentials.get("expiresAt")
62
- };
62
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
63
+ return byEndpoint[credentialsKey()] ?? {};
63
64
  }
64
65
  function setCredentials(creds) {
65
- if (creds.token) credentials.set("token", creds.token);
66
- if (creds.user) credentials.set("user", creds.user);
67
- if (creds.expiresAt) credentials.set("expiresAt", creds.expiresAt);
66
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
67
+ byEndpoint[credentialsKey()] = {
68
+ token: creds.token,
69
+ user: creds.user,
70
+ expiresAt: creds.expiresAt
71
+ };
72
+ credentials.set("byEndpoint", byEndpoint);
68
73
  }
69
74
  function clearCredentials() {
75
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
76
+ delete byEndpoint[credentialsKey()];
77
+ credentials.set("byEndpoint", byEndpoint);
78
+ }
79
+ function clearAllCredentials() {
70
80
  credentials.clear();
71
81
  }
72
82
  function getCliName() {
@@ -176,7 +186,6 @@ var api = {
176
186
 
177
187
  // src/lib/keychain.ts
178
188
  var SERVICE_NAME = "evident-cli";
179
- var ACCOUNT_NAME = "default";
180
189
  async function getKeytar() {
181
190
  try {
182
191
  const keytar = await import("keytar");
@@ -188,10 +197,13 @@ async function getKeytar() {
188
197
  return null;
189
198
  }
190
199
  }
200
+ function keychainAccount() {
201
+ return getApiUrlConfig();
202
+ }
191
203
  async function storeToken(credentials2) {
192
204
  const keytar = await getKeytar();
193
205
  if (keytar) {
194
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
206
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
195
207
  } else {
196
208
  setCredentials({
197
209
  token: credentials2.token,
@@ -203,12 +215,13 @@ async function storeToken(credentials2) {
203
215
  async function getToken() {
204
216
  const keytar = await getKeytar();
205
217
  if (keytar) {
206
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
218
+ const account = keychainAccount();
219
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
207
220
  if (stored) {
208
221
  try {
209
222
  return JSON.parse(stored);
210
223
  } catch {
211
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
224
+ await keytar.deletePassword(SERVICE_NAME, account);
212
225
  return null;
213
226
  }
214
227
  }
@@ -223,12 +236,26 @@ async function getToken() {
223
236
  }
224
237
  return null;
225
238
  }
226
- async function deleteToken() {
239
+ async function deleteToken(options = {}) {
227
240
  const keytar = await getKeytar();
228
241
  if (keytar) {
229
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
242
+ if (options.all) {
243
+ const all = await keytar.findCredentials(SERVICE_NAME).catch(() => []);
244
+ await Promise.all(
245
+ all.map(
246
+ (entry) => keytar.deletePassword(SERVICE_NAME, entry.account).catch(() => {
247
+ })
248
+ )
249
+ );
250
+ } else {
251
+ await keytar.deletePassword(SERVICE_NAME, keychainAccount());
252
+ }
253
+ }
254
+ if (options.all) {
255
+ clearAllCredentials();
256
+ } else {
257
+ clearCredentials();
230
258
  }
231
- clearCredentials();
232
259
  }
233
260
 
234
261
  // src/utils/ui.ts
@@ -396,25 +423,32 @@ async function login(options) {
396
423
  }
397
424
 
398
425
  // src/commands/logout.ts
399
- async function logout() {
426
+ async function logout(options = {}) {
427
+ if (options.all) {
428
+ await deleteToken({ all: true });
429
+ printSuccess("Logged out of all endpoints.");
430
+ return;
431
+ }
400
432
  const credentials2 = await getToken();
401
433
  if (!credentials2) {
402
- printWarning("You are not logged in.");
434
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
403
435
  return;
404
436
  }
405
437
  await deleteToken();
406
- printSuccess("Logged out successfully.");
438
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
407
439
  }
408
440
 
409
441
  // src/commands/whoami.ts
410
442
  import chalk3 from "chalk";
411
443
  async function whoami() {
444
+ const apiUrl = getApiUrlConfig();
412
445
  const credentials2 = await getToken();
413
446
  if (!credentials2) {
414
- printError("Not logged in. Run the `login` command to authenticate.");
447
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
415
448
  process.exit(1);
416
449
  }
417
450
  blank();
451
+ console.log(keyValue("Endpoint", apiUrl));
418
452
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
419
453
  console.log(keyValue("User ID", credentials2.user.id));
420
454
  if (credentials2.expiresAt) {
@@ -437,6 +471,12 @@ import chalk6 from "chalk";
437
471
  import ora3 from "ora";
438
472
  import { select as select3 } from "@inquirer/prompts";
439
473
 
474
+ // ../../packages/types/src/opencode/index.ts
475
+ function opencodeMessageIdFor(queuedMessageId) {
476
+ const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
477
+ return `msg_${sanitized}`;
478
+ }
479
+
440
480
  // ../../packages/types/src/telemetry/index.ts
441
481
  var TelemetryEventTypes = {
442
482
  // Agent activity events (shown in web UI activity log)
@@ -449,6 +489,30 @@ var TelemetryEventTypes = {
449
489
 
450
490
  // ../../packages/types/src/tunnel/index.ts
451
491
  var MAX_FRAME_BYTES = 256 * 1024;
492
+ var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
493
+
494
+ // ../../packages/types/src/logging/index.ts
495
+ var CORRELATION_ID_HEADER = "x-evident-correlation-id";
496
+ function log(level, event, fields) {
497
+ const method = level === "debug" ? "log" : level;
498
+ try {
499
+ console[method]("[evident]", JSON.stringify({ level, event, ...fields }));
500
+ } catch (err) {
501
+ console.error(
502
+ "[evident] log_serialize_failed",
503
+ event,
504
+ err instanceof Error ? err.message : String(err)
505
+ );
506
+ }
507
+ }
508
+ function stripQuery(url) {
509
+ try {
510
+ return new URL(url).pathname;
511
+ } catch {
512
+ const q = url.indexOf("?");
513
+ return q === -1 ? url : url.slice(0, q);
514
+ }
515
+ }
452
516
 
453
517
  // src/lib/telemetry.ts
454
518
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -650,6 +714,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
650
714
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
651
715
  }
652
716
 
717
+ // src/lib/opencode/opencode-version-gate.ts
718
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
719
+ function isQueueValidatedVersion(version2) {
720
+ if (!version2) return false;
721
+ return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
722
+ }
723
+ function buildOpenCodeVersionWarning(version2) {
724
+ if (isQueueValidatedVersion(version2)) return null;
725
+ const detected = version2 ? `v${version2}` : "unknown";
726
+ const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
727
+ 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.`;
728
+ }
729
+
653
730
  // src/lib/opencode/process.ts
654
731
  import { execSync, spawn } from "child_process";
655
732
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -940,20 +1017,68 @@ async function promptOpenCodeInstall(interactive) {
940
1017
  }
941
1018
 
942
1019
  // src/lib/opencode/session.ts
943
- async function createOpenCodeSession(port) {
944
- const response = await fetch(`http://localhost:${port}/session`, {
1020
+ function opencodeBase(port) {
1021
+ return `http://127.0.0.1:${port}`;
1022
+ }
1023
+ async function getOpenCodeDirectory(port) {
1024
+ try {
1025
+ const res = await fetch(`${opencodeBase(port)}/path`);
1026
+ if (!res.ok) return null;
1027
+ const body = await res.json();
1028
+ const dir = typeof body.directory === "string" && body.directory || typeof body.worktree === "string" && body.worktree || typeof body.path?.cwd === "string" && body.path.cwd || typeof body.path?.directory === "string" && body.path.directory || null;
1029
+ return dir && dir.trim() ? dir.trim() : null;
1030
+ } catch {
1031
+ return null;
1032
+ }
1033
+ }
1034
+ function roleOf(m) {
1035
+ if (!m || typeof m !== "object") return void 0;
1036
+ if (typeof m.role === "string") return m.role;
1037
+ const infoRole = m.info?.role;
1038
+ return typeof infoRole === "string" ? infoRole : void 0;
1039
+ }
1040
+ function completedOf(m) {
1041
+ if (!m || typeof m !== "object") return void 0;
1042
+ return m.info?.time?.completed;
1043
+ }
1044
+ function idOf(m) {
1045
+ if (!m || typeof m !== "object") return void 0;
1046
+ if (typeof m.id === "string") return m.id;
1047
+ const infoId = m.info?.id;
1048
+ return typeof infoId === "string" ? infoId : void 0;
1049
+ }
1050
+ function parentIdOf(m) {
1051
+ if (!m || typeof m !== "object") return void 0;
1052
+ if (typeof m.parentID === "string") return m.parentID;
1053
+ const infoParent = m.info?.parentID;
1054
+ return typeof infoParent === "string" ? infoParent : void 0;
1055
+ }
1056
+ function finishOf(m) {
1057
+ if (!m || typeof m !== "object") return void 0;
1058
+ if (typeof m.finish === "string") return m.finish;
1059
+ const infoFinish = m.info?.finish;
1060
+ return typeof infoFinish === "string" ? infoFinish : void 0;
1061
+ }
1062
+ async function createOpenCodeSession(port, directory) {
1063
+ const url = new URL(`${opencodeBase(port)}/session`);
1064
+ if (directory && directory.trim()) {
1065
+ url.searchParams.set("directory", directory.trim());
1066
+ }
1067
+ const response = await fetch(url, {
945
1068
  method: "POST",
946
1069
  headers: { "Content-Type": "application/json" },
947
1070
  body: JSON.stringify({})
948
1071
  });
949
1072
  if (!response.ok) {
950
- throw new Error(`Failed to create session: HTTP ${response.status}`);
1073
+ const text = await response.text().catch(() => "");
1074
+ throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
951
1075
  }
952
1076
  const data = await response.json();
953
1077
  return data.id;
954
1078
  }
955
- async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1079
+ async function sendPromptAsync(port, sessionId, content, options, messageId) {
956
1080
  const body = {
1081
+ messageID: messageId,
957
1082
  parts: [{ type: "text", text: content }]
958
1083
  };
959
1084
  if (options?.agent) {
@@ -968,76 +1093,59 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
968
1093
  };
969
1094
  }
970
1095
  }
971
- let pollDone = false;
972
- const reportedQuestions = /* @__PURE__ */ new Set();
973
- const reportedPermissions = /* @__PURE__ */ new Set();
974
- const pollInteractive = async () => {
975
- while (!pollDone) {
976
- await new Promise((resolve) => setTimeout(resolve, 1e3));
977
- if (pollDone) break;
978
- if (hooks?.onQuestion) {
979
- try {
980
- const res = await fetch(`http://localhost:${port}/question`);
981
- if (res.ok) {
982
- const questions = await res.json();
983
- for (const q of questions) {
984
- if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
985
- reportedQuestions.add(q.id);
986
- await hooks.onQuestion(q);
987
- }
988
- }
989
- }
990
- } catch {
991
- }
992
- }
993
- if (hooks?.onPermission) {
994
- try {
995
- const res = await fetch(`http://localhost:${port}/permission`);
996
- if (res.ok) {
997
- const permissions = await res.json();
998
- for (const p of permissions) {
999
- if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
1000
- reportedPermissions.add(p.id);
1001
- await hooks.onPermission(p);
1002
- }
1003
- }
1004
- }
1005
- } catch {
1006
- }
1007
- }
1008
- }
1009
- };
1010
- const sendMessage = async () => {
1011
- const controller = new AbortController();
1012
- const timer = setTimeout(() => controller.abort(), maxWaitMs);
1013
- try {
1014
- const res = await fetch(`http://localhost:${port}/session/${sessionId}/message`, {
1015
- method: "POST",
1016
- headers: { "Content-Type": "application/json" },
1017
- body: JSON.stringify(body),
1018
- signal: controller.signal
1019
- });
1020
- if (!res.ok) {
1021
- const text = await res.text().catch(() => "");
1022
- throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1023
- }
1024
- const sessionRes = await fetch(`http://localhost:${port}/session/${sessionId}`).catch(
1025
- () => null
1026
- );
1027
- const session = sessionRes?.ok ? await sessionRes.json() : null;
1028
- return { title: session?.title };
1029
- } catch (err) {
1030
- if (err instanceof Error && err.name === "AbortError") {
1031
- throw new Error("Message processing timed out");
1032
- }
1033
- throw err;
1034
- } finally {
1035
- clearTimeout(timer);
1036
- pollDone = true;
1037
- }
1038
- };
1039
- const [result] = await Promise.all([sendMessage(), pollInteractive()]);
1040
- return result;
1096
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
1097
+ method: "POST",
1098
+ headers: { "Content-Type": "application/json" },
1099
+ body: JSON.stringify(body)
1100
+ });
1101
+ if (res.status < 200 || res.status >= 300) {
1102
+ const text = await res.text().catch(() => "");
1103
+ throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1104
+ }
1105
+ }
1106
+ function findAssistantReplyAfter(messages, userMessageId) {
1107
+ if (!messages || messages.length === 0) return null;
1108
+ const byParent = messages.find(
1109
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1110
+ );
1111
+ if (byParent) return byParent;
1112
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1113
+ if (userIndex === -1) return null;
1114
+ for (let i = userIndex + 1; i < messages.length; i++) {
1115
+ if (roleOf(messages[i]) === "assistant") return messages[i];
1116
+ }
1117
+ return null;
1118
+ }
1119
+ function findLastAssistantReplyFor(messages, userMessageId) {
1120
+ if (!messages || messages.length === 0) return null;
1121
+ for (let i = messages.length - 1; i >= 0; i--) {
1122
+ const m = messages[i];
1123
+ if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1124
+ }
1125
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1126
+ if (userIndex === -1) return null;
1127
+ let last = null;
1128
+ for (let i = userIndex + 1; i < messages.length; i++) {
1129
+ const role = roleOf(messages[i]);
1130
+ if (role === "user") break;
1131
+ if (role === "assistant") last = messages[i];
1132
+ }
1133
+ return last;
1134
+ }
1135
+ function messageRunState(messages, userMessageId) {
1136
+ if (!messages || messages.length === 0) return "unknown";
1137
+ const hasUser = messages.some((m) => idOf(m) === userMessageId);
1138
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1139
+ if (!hasUser) {
1140
+ if (!reply) return "unknown";
1141
+ }
1142
+ if (!reply) return "queued";
1143
+ if (completedOf(reply) == null) return "running";
1144
+ if (finishOf(reply) === "tool-calls") return "running";
1145
+ return "done";
1146
+ }
1147
+ function opencodeMessageIdFor2(queuedMessageId) {
1148
+ return opencodeMessageIdFor(queuedMessageId);
1041
1149
  }
1042
1150
 
1043
1151
  // src/lib/tunnel/connection.ts
@@ -1108,6 +1216,20 @@ var StreamForwarder = class {
1108
1216
  }
1109
1217
  async handleOpen(frame) {
1110
1218
  const { sid, method, path, headers, has_body } = frame;
1219
+ const correlationId = headers?.[CORRELATION_ID_HEADER];
1220
+ const startedAt = Date.now();
1221
+ if (path === TUNNEL_DRAIN_PING_PATH) {
1222
+ this.callbacks.onDrainPing?.();
1223
+ this.send({ type: "head", sid, status: 204, headers: {} });
1224
+ this.send({ type: "res_end", sid });
1225
+ return;
1226
+ }
1227
+ log("info", "agent_request", {
1228
+ correlation_id: correlationId,
1229
+ sid,
1230
+ method,
1231
+ path: stripQuery(path)
1232
+ });
1111
1233
  const ac = new AbortController();
1112
1234
  let bodyPromise;
1113
1235
  let pushBody;
@@ -1154,6 +1276,12 @@ var StreamForwarder = class {
1154
1276
  if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1155
1277
  });
1156
1278
  this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1279
+ log("info", "agent_response", {
1280
+ correlation_id: correlationId,
1281
+ sid,
1282
+ status: upstream.status,
1283
+ duration_ms: Date.now() - startedAt
1284
+ });
1157
1285
  this.callbacks.onHead?.(sid, upstream.status);
1158
1286
  try {
1159
1287
  if (upstream.body) {
@@ -1224,7 +1352,8 @@ function connectTunnel(options) {
1224
1352
  onError,
1225
1353
  onRequest,
1226
1354
  onResponse,
1227
- onInfo
1355
+ onInfo,
1356
+ onDrainPing
1228
1357
  } = options;
1229
1358
  const tunnelUrl = getTunnelUrlConfig();
1230
1359
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
@@ -1237,6 +1366,7 @@ function connectTunnel(options) {
1237
1366
  const streamStartTimes = /* @__PURE__ */ new Map();
1238
1367
  const forwarder = new StreamForwarder(ws, port, {
1239
1368
  onOpen: (sid, method, path) => {
1369
+ if (path === TUNNEL_DRAIN_PING_PATH) return;
1240
1370
  streamStartTimes.set(sid, Date.now());
1241
1371
  onRequest?.(method, path, sid);
1242
1372
  },
@@ -1244,7 +1374,8 @@ function connectTunnel(options) {
1244
1374
  const startedAt = streamStartTimes.get(sid);
1245
1375
  streamStartTimes.delete(sid);
1246
1376
  onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1247
- }
1377
+ },
1378
+ onDrainPing: () => onDrainPing?.()
1248
1379
  });
1249
1380
  const connectionTimeout = setTimeout(() => {
1250
1381
  ws.close();
@@ -1386,6 +1517,7 @@ var RunnerConnection = class {
1386
1517
  },
1387
1518
  onError: (error2) => events.onError?.(error2),
1388
1519
  onResponse: () => events.onResponse?.(),
1520
+ onDrainPing: () => events.onDrainPing?.(),
1389
1521
  onInfo: (message) => events.onInfo?.(message)
1390
1522
  });
1391
1523
  return;
@@ -1406,17 +1538,34 @@ var RunnerConnection = class {
1406
1538
  };
1407
1539
 
1408
1540
  // src/lib/channels/driver.ts
1541
+ function messageIdOf(m) {
1542
+ if (!m || typeof m !== "object") return void 0;
1543
+ if (typeof m.id === "string") return m.id;
1544
+ const infoId = m.info?.id;
1545
+ return typeof infoId === "string" ? infoId : void 0;
1546
+ }
1409
1547
  var DEFAULT_RETRY_POLICY = {
1410
1548
  maxAttempts: 6,
1411
1549
  baseDelayMs: 500,
1412
1550
  maxDelayMs: 3e4
1413
1551
  };
1552
+ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1553
+ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1554
+ var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1414
1555
  var ChannelAuthError = class extends Error {
1415
1556
  constructor(message) {
1416
1557
  super(message);
1417
1558
  this.name = "ChannelAuthError";
1418
1559
  }
1419
1560
  };
1561
+ var ChannelTerminalError = class extends Error {
1562
+ status;
1563
+ constructor(message, status) {
1564
+ super(message);
1565
+ this.name = "ChannelTerminalError";
1566
+ this.status = status;
1567
+ }
1568
+ };
1420
1569
  function backoffDelay(attempt, policy) {
1421
1570
  const exp = policy.baseDelayMs * Math.pow(2, attempt);
1422
1571
  const capped = Math.min(policy.maxDelayMs, exp);
@@ -1435,8 +1584,35 @@ var ChannelDriver = class {
1435
1584
  log;
1436
1585
  fetchImpl;
1437
1586
  sleep;
1587
+ pausedPollIntervalMs;
1588
+ pausedMaxWaitMs;
1589
+ dispatchConfirmMs;
1590
+ now;
1438
1591
  /** Cache of conversationId → opencode sessionId. */
1439
1592
  sessions = /* @__PURE__ */ new Map();
1593
+ /**
1594
+ * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1595
+ * session: one polling loop services all of that session's in-flight messages.
1596
+ * A session entry exists while it has any in-flight (dispatched-but-not-done)
1597
+ * message; it is removed once its in-flight set empties.
1598
+ */
1599
+ watchers = /* @__PURE__ */ new Map();
1600
+ /**
1601
+ * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
1602
+ * dispatched and are still in-flight. A message in this set is never
1603
+ * re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
1604
+ * Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
1605
+ * idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
1606
+ * a steady-state-poll re-dispatch will not double-run the message.
1607
+ */
1608
+ dispatched = /* @__PURE__ */ new Set();
1609
+ /**
1610
+ * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1611
+ * first session creation so drain-created sessions are rooted at the project
1612
+ * directory and thus visible in `opencode web`'s session list. `undefined` =
1613
+ * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1614
+ */
1615
+ opencodeDirectory = void 0;
1440
1616
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1441
1617
  draining = false;
1442
1618
  constructor(config2) {
@@ -1450,6 +1626,10 @@ var ChannelDriver = class {
1450
1626
  });
1451
1627
  this.fetchImpl = config2.fetchImpl ?? fetch;
1452
1628
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1629
+ this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1630
+ this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1631
+ this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1632
+ this.now = config2.now ?? (() => Date.now());
1453
1633
  }
1454
1634
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1455
1635
  get opencodeBase() {
@@ -1459,80 +1639,124 @@ var ChannelDriver = class {
1459
1639
  // Public API
1460
1640
  // -------------------------------------------------------------------------
1461
1641
  /**
1462
- * Drain all pending channel conversations once: poll → processcallback.
1642
+ * Drain all pending channel conversations once: poll → dispatchregister.
1463
1643
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1464
1644
  * Re-entrant calls while a drain is in flight are skipped (return 0).
1465
1645
  *
1466
- * @returns the number of messages processed.
1646
+ * @returns the number of messages NEWLY dispatched to opencode's native queue.
1467
1647
  */
1468
1648
  async drainPending() {
1469
1649
  if (this.draining) return 0;
1470
1650
  this.draining = true;
1471
- let processed = 0;
1651
+ let dispatched = 0;
1472
1652
  try {
1473
1653
  const conversations = await this.getPendingConversations();
1654
+ if (conversations.length > 0) {
1655
+ const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
1656
+ this.log({
1657
+ level: "info",
1658
+ message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
1659
+ });
1660
+ }
1474
1661
  for (const conv of conversations) {
1475
- processed += await this.processConversation(conv);
1662
+ dispatched += await this.processConversation(conv);
1476
1663
  }
1477
1664
  } finally {
1478
1665
  this.draining = false;
1479
1666
  }
1480
- return processed;
1667
+ return dispatched;
1668
+ }
1669
+ /**
1670
+ * True while any per-session watcher has a non-empty in-flight dispatched set
1671
+ * (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
1672
+ * the process while a dispatched message is still queued/running — which would
1673
+ * kill the turn and orphan its reply.
1674
+ */
1675
+ hasInFlightWatchers() {
1676
+ for (const watcher of this.watchers.values()) {
1677
+ if (watcher.inFlight.size > 0) return true;
1678
+ }
1679
+ return false;
1680
+ }
1681
+ /**
1682
+ * Await all outstanding per-session watchers (WI-3).
1683
+ *
1684
+ * In production the watcher loops are deliberately started-not-awaited so the
1685
+ * drain loop never blocks on them and process exit is not held up (the cron
1686
+ * recovers any abandoned ones). This helper exists primarily for deterministic
1687
+ * tests that need to observe a watcher's effect (the `processing`/`done` PATCH
1688
+ * or its giving up) after a non-blocking `drainPending`. Watcher loops never
1689
+ * reject, so this resolves.
1690
+ */
1691
+ async flushPausedWatchers() {
1692
+ while (true) {
1693
+ const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
1694
+ if (loops.length === 0) return;
1695
+ await Promise.all(loops);
1696
+ const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
1697
+ if (!stillLive) return;
1698
+ }
1481
1699
  }
1482
1700
  // -------------------------------------------------------------------------
1483
- // Conversation processing
1701
+ // Conversation processing (WI-3 — async dispatch)
1484
1702
  // -------------------------------------------------------------------------
1703
+ /**
1704
+ * Dispatch each pending message for a conversation to opencode's native queue
1705
+ * via `prompt_async` (Task 3.2) and register it with the conversation's
1706
+ * per-session watcher. Does NOT block on the turn and does NOT call
1707
+ * `markProcessing` here — that fires from the watcher on running-start.
1708
+ *
1709
+ * @returns the count of messages NEWLY dispatched (not already in-flight).
1710
+ */
1485
1711
  async processConversation(conv) {
1486
1712
  const sessionId = await this.ensureSession(conv);
1487
1713
  const messages = await this.getPendingMessages(conv.id);
1488
- let processed = 0;
1714
+ let dispatched = 0;
1715
+ let skippedAlreadyDispatched = 0;
1489
1716
  for (const message of messages) {
1490
- const claimed = await this.markProcessing(conv.id, message.id);
1491
- if (!claimed) {
1492
- this.log({
1493
- level: "info",
1494
- message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
1495
- conversation_id: conv.id,
1496
- message_id: message.id
1497
- });
1717
+ if (this.dispatched.has(message.id)) {
1718
+ skippedAlreadyDispatched += 1;
1498
1719
  continue;
1499
1720
  }
1721
+ const opencodeMessageId = opencodeMessageIdFor2(message.id);
1722
+ const options = {
1723
+ agent: message.opencode_agent ?? void 0,
1724
+ model: message.opencode_model ?? void 0
1725
+ };
1500
1726
  try {
1501
- await sendMessageToOpenCode(
1502
- this.port,
1503
- sessionId,
1504
- message.content,
1505
- {
1506
- agent: message.opencode_agent ?? void 0,
1507
- model: message.opencode_model ?? void 0
1508
- },
1509
- {
1510
- onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1511
- onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1512
- }
1513
- );
1514
- await this.confirmCompletion(sessionId);
1515
- await this.markDone(conv.id, message.id, sessionId);
1516
- processed += 1;
1517
1727
  this.log({
1518
1728
  level: "info",
1519
- message: `Message ${message.id.slice(0, 8)} processed`,
1729
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
1520
1730
  conversation_id: conv.id,
1521
1731
  message_id: message.id
1522
1732
  });
1733
+ await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1523
1734
  } catch (err) {
1524
1735
  if (err instanceof ChannelAuthError) throw err;
1736
+ this.dispatched.delete(message.id);
1525
1737
  await this.markFailed(conv.id, message.id).catch(() => {
1526
1738
  });
1527
1739
  this.log({
1528
1740
  level: "error",
1529
- message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1741
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1530
1742
  conversation_id: conv.id,
1531
1743
  message_id: message.id
1532
1744
  });
1745
+ continue;
1533
1746
  }
1747
+ this.dispatched.add(message.id);
1748
+ this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1749
+ dispatched += 1;
1534
1750
  }
1535
- return processed;
1751
+ if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
1752
+ this.log({
1753
+ level: "error",
1754
+ 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).`,
1755
+ conversation_id: conv.id
1756
+ });
1757
+ }
1758
+ this.ensureWatcherRunning(sessionId);
1759
+ return dispatched;
1536
1760
  }
1537
1761
  async ensureSession(conv) {
1538
1762
  const cached = this.sessions.get(conv.id);
@@ -1541,30 +1765,372 @@ var ChannelDriver = class {
1541
1765
  this.sessions.set(conv.id, conv.opencode_session_id);
1542
1766
  return conv.opencode_session_id;
1543
1767
  }
1544
- const sessionId = await createOpenCodeSession(this.port);
1768
+ const directory = await this.resolveOpenCodeDirectory();
1769
+ const sessionId = await createOpenCodeSession(this.port, directory);
1545
1770
  this.sessions.set(conv.id, sessionId);
1546
1771
  await this.persistSession(conv.id, sessionId).catch(() => {
1547
1772
  });
1548
1773
  return sessionId;
1549
1774
  }
1550
1775
  /**
1551
- * Local reconcile: re-query `GET /session/:id` and check `time.completed`.
1552
- * Best-effort if opencode is unreachable or the field is absent we proceed
1553
- * to mark done anyway (the blocking call already returned).
1776
+ * Lazily resolve (and cache) opencode's root directory via `GET /path`.
1777
+ * Resolved once per driver: `undefined` until first lookup, then the directory
1778
+ * string or `null` if unavailable (we don't keep retrying a missing `/path`).
1554
1779
  */
1555
- async confirmCompletion(sessionId) {
1780
+ async resolveOpenCodeDirectory() {
1781
+ if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
1782
+ this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1783
+ if (!this.opencodeDirectory) {
1784
+ this.log({
1785
+ level: "info",
1786
+ message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1787
+ });
1788
+ }
1789
+ return this.opencodeDirectory;
1790
+ }
1791
+ // -------------------------------------------------------------------------
1792
+ // Per-session watcher (WI-3)
1793
+ // -------------------------------------------------------------------------
1794
+ /** Register a freshly-dispatched message with its session's watcher state. */
1795
+ registerInFlight(conv, sessionId, message, opencodeMessageId) {
1796
+ let watcher = this.watchers.get(sessionId);
1797
+ if (!watcher) {
1798
+ watcher = {
1799
+ conv,
1800
+ inFlight: /* @__PURE__ */ new Map(),
1801
+ loop: null,
1802
+ reportedQuestions: /* @__PURE__ */ new Set(),
1803
+ reportedPermissions: /* @__PURE__ */ new Set()
1804
+ };
1805
+ this.watchers.set(sessionId, watcher);
1806
+ }
1807
+ const now = this.now();
1808
+ watcher.inFlight.set(message.id, {
1809
+ evidentMessageId: message.id,
1810
+ opencodeMessageId,
1811
+ message,
1812
+ dispatchedAt: now,
1813
+ deadline: now + this.pausedMaxWaitMs,
1814
+ started: false,
1815
+ done: false
1816
+ });
1817
+ }
1818
+ /**
1819
+ * Start (but do NOT await) the per-session watcher loop if it has in-flight
1820
+ * work and is not already running. Single-flight per session. The loop is
1821
+ * tracked on the watcher and cleared when it settles; it never rejects (fully
1822
+ * guarded), so a failed poll/callback can never crash the run loop — the cron
1823
+ * stays as the safety net.
1824
+ */
1825
+ ensureWatcherRunning(sessionId) {
1826
+ const watcher = this.watchers.get(sessionId);
1827
+ if (!watcher) return;
1828
+ if (watcher.loop) return;
1829
+ if (watcher.inFlight.size === 0) {
1830
+ this.watchers.delete(sessionId);
1831
+ return;
1832
+ }
1833
+ const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
1834
+ watcher.loop = null;
1835
+ if (watcher.inFlight.size === 0) {
1836
+ this.watchers.delete(sessionId);
1837
+ }
1838
+ });
1839
+ watcher.loop = loop;
1840
+ }
1841
+ /**
1842
+ * The per-session polling loop (WI-3). Once per tick it:
1843
+ * 1. polls `GET /session/:id/message` once and, per in-flight message,
1844
+ * computes `messageRunState` and fires markProcessing (queued→running) /
1845
+ * markDone (done) exactly once per transition;
1846
+ * 2. applies the idle-path re-dispatch guard (a dispatched message that never
1847
+ * APPEARS → re-dispatch — D1 obligation 2);
1848
+ * 3. polls `/question` + `/permission` (scoped to the session) and surfaces
1849
+ * NEW ones via `reportInteraction`, carrying the PAUSED message's own
1850
+ * `source_message_id`;
1851
+ * 4. drops messages that completed or timed out from the in-flight set.
1852
+ * Exits when the in-flight set empties. Never throws.
1853
+ */
1854
+ async runWatcherLoop(sessionId, watcher) {
1556
1855
  try {
1557
- const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
1558
- if (!res.ok) return;
1559
- const session = await res.json();
1560
- if (session.time && session.time.completed == null) {
1856
+ while (watcher.inFlight.size > 0) {
1857
+ await this.sleep(this.pausedPollIntervalMs);
1858
+ let messages = null;
1859
+ try {
1860
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1861
+ if (res.ok) {
1862
+ const body = await res.json();
1863
+ messages = Array.isArray(body) ? body : null;
1864
+ }
1865
+ } catch {
1866
+ continue;
1867
+ }
1868
+ for (const inFlight of [...watcher.inFlight.values()]) {
1869
+ await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
1870
+ }
1871
+ await this.pollInteractions(sessionId, watcher, messages);
1872
+ }
1873
+ } catch (err) {
1874
+ if (err instanceof ChannelAuthError) {
1875
+ this.log({
1876
+ level: "error",
1877
+ message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
1878
+ conversation_id: watcher.conv.id
1879
+ });
1880
+ for (const evidentMessageId of [...watcher.inFlight.keys()]) {
1881
+ this.removeInFlight(watcher, evidentMessageId);
1882
+ }
1883
+ return;
1884
+ }
1885
+ this.log({
1886
+ level: "error",
1887
+ message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1888
+ conversation_id: watcher.conv.id
1889
+ });
1890
+ }
1891
+ }
1892
+ /**
1893
+ * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1894
+ * Fires markProcessing on queued→running and markDone on done (each once),
1895
+ * applies the idle-path re-dispatch guard, and removes the message from the
1896
+ * in-flight set on completion or timeout.
1897
+ */
1898
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1899
+ const conv = watcher.conv;
1900
+ const state = messageRunState(messages, inFlight.opencodeMessageId);
1901
+ if ((state === "running" || state === "done") && !inFlight.started) {
1902
+ let claimed;
1903
+ try {
1904
+ claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
1905
+ } catch (err) {
1906
+ if (err instanceof ChannelAuthError) throw err;
1907
+ this.log({
1908
+ level: "error",
1909
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1910
+ conversation_id: conv.id,
1911
+ message_id: inFlight.evidentMessageId
1912
+ });
1913
+ return;
1914
+ }
1915
+ inFlight.started = true;
1916
+ if (!claimed) {
1561
1917
  this.log({
1562
1918
  level: "info",
1563
- message: `Session ${sessionId.slice(0, 8)} not marked completed on reconcile \u2014 delivering anyway`
1919
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
1920
+ conversation_id: conv.id,
1921
+ message_id: inFlight.evidentMessageId
1564
1922
  });
1565
1923
  }
1924
+ }
1925
+ if (state === "done") {
1926
+ if (!inFlight.done) {
1927
+ this.log({
1928
+ level: "info",
1929
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1930
+ conversation_id: conv.id,
1931
+ message_id: inFlight.evidentMessageId
1932
+ });
1933
+ try {
1934
+ await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
1935
+ } catch (err) {
1936
+ if (err instanceof ChannelAuthError) throw err;
1937
+ if (err instanceof ChannelTerminalError) {
1938
+ this.log({
1939
+ level: "error",
1940
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
1941
+ conversation_id: conv.id,
1942
+ message_id: inFlight.evidentMessageId
1943
+ });
1944
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1945
+ return;
1946
+ }
1947
+ if (this.now() >= inFlight.deadline) {
1948
+ this.log({
1949
+ level: "error",
1950
+ 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)}`,
1951
+ conversation_id: conv.id,
1952
+ message_id: inFlight.evidentMessageId
1953
+ });
1954
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1955
+ return;
1956
+ }
1957
+ this.log({
1958
+ level: "error",
1959
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1960
+ conversation_id: conv.id,
1961
+ message_id: inFlight.evidentMessageId
1962
+ });
1963
+ return;
1964
+ }
1965
+ inFlight.done = true;
1966
+ }
1967
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1968
+ return;
1969
+ }
1970
+ if (state === "unknown") {
1971
+ if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
1972
+ await this.redispatchInFlight(sessionId, inFlight);
1973
+ }
1974
+ }
1975
+ if (this.now() >= inFlight.deadline) {
1976
+ this.log({
1977
+ level: "info",
1978
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
1979
+ conversation_id: conv.id,
1980
+ message_id: inFlight.evidentMessageId
1981
+ });
1982
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1983
+ }
1984
+ }
1985
+ /**
1986
+ * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
1987
+ * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
1988
+ * fact 9) — one user message + one reply even if the original DID land. Resets
1989
+ * the dispatch timestamp so the guard doesn't immediately fire again.
1990
+ */
1991
+ async redispatchInFlight(sessionId, inFlight) {
1992
+ const options = {
1993
+ agent: inFlight.message.opencode_agent ?? void 0,
1994
+ model: inFlight.message.opencode_model ?? void 0
1995
+ };
1996
+ this.log({
1997
+ level: "info",
1998
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
1999
+ message_id: inFlight.evidentMessageId
2000
+ });
2001
+ try {
2002
+ await sendPromptAsync(
2003
+ this.port,
2004
+ sessionId,
2005
+ inFlight.message.content,
2006
+ options,
2007
+ inFlight.opencodeMessageId
2008
+ );
2009
+ } catch (err) {
2010
+ this.log({
2011
+ level: "error",
2012
+ message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
2013
+ message_id: inFlight.evidentMessageId
2014
+ });
2015
+ }
2016
+ inFlight.dispatchedAt = this.now();
2017
+ }
2018
+ /**
2019
+ * Remove a message from the in-flight set AND the authoritative dispatched
2020
+ * set. Once the in-flight set empties, the watcher loop's `while` guard exits
2021
+ * and its `.finally` removes the session entry from `this.watchers`.
2022
+ */
2023
+ removeInFlight(watcher, evidentMessageId) {
2024
+ watcher.inFlight.delete(evidentMessageId);
2025
+ this.dispatched.delete(evidentMessageId);
2026
+ }
2027
+ /**
2028
+ * Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
2029
+ * via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
2030
+ * `source_message_id` so the server @mentions the correct person under
2031
+ * concurrency. Dedups by interaction id across ticks (reused per-session sets).
2032
+ *
2033
+ * The interaction is attributed to the in-flight message it paused on. opencode
2034
+ * stamps a `messageID` on a permission (and `tool.messageID` on a question) =
2035
+ * the assistant message id, whose `parentID` is the user message id — but the
2036
+ * simplest robust attribution here is: the single in-flight message that is
2037
+ * RUNNING (not done) is the one that paused. With one running message that is
2038
+ * unambiguous; with several we prefer an explicit messageID match, else the
2039
+ * oldest running message.
2040
+ */
2041
+ async pollInteractions(sessionId, watcher, messages) {
2042
+ let questions = [];
2043
+ try {
2044
+ const res = await this.fetchImpl(`${this.opencodeBase}/question`);
2045
+ if (res.ok) {
2046
+ const body = await res.json();
2047
+ questions = Array.isArray(body) ? body : [];
2048
+ }
1566
2049
  } catch {
1567
2050
  }
2051
+ for (const q of questions) {
2052
+ if (q.sessionID !== sessionId) continue;
2053
+ if (watcher.reportedQuestions.has(q.id)) continue;
2054
+ const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
2055
+ const reported = await this.reportInteraction(
2056
+ watcher.conv.id,
2057
+ "question",
2058
+ q,
2059
+ paused?.message.source_message_id ?? void 0
2060
+ );
2061
+ if (reported) watcher.reportedQuestions.add(q.id);
2062
+ }
2063
+ let permissions = [];
2064
+ try {
2065
+ const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2066
+ if (res.ok) {
2067
+ const body = await res.json();
2068
+ permissions = Array.isArray(body) ? body : [];
2069
+ }
2070
+ } catch {
2071
+ }
2072
+ for (const p of permissions) {
2073
+ if (p.sessionID !== sessionId) continue;
2074
+ if (watcher.reportedPermissions.has(p.id)) continue;
2075
+ const paused = this.attributeInteraction(watcher, p.messageID, messages);
2076
+ const reported = await this.reportInteraction(
2077
+ watcher.conv.id,
2078
+ "permission",
2079
+ p,
2080
+ paused?.message.source_message_id ?? void 0
2081
+ );
2082
+ if (reported) watcher.reportedPermissions.add(p.id);
2083
+ }
2084
+ }
2085
+ /**
2086
+ * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2087
+ *
2088
+ * The interaction carries `interactionMessageId` — the ASSISTANT message id
2089
+ * that raised it (a question's `tool.messageID` / a permission's `messageID`).
2090
+ * That assistant message is the reply to ONE of our minted user messages
2091
+ * (correlated by `parentID`, GATE-B). So when we have the tick's message
2092
+ * snapshot, we resolve each running in-flight message's correlated assistant
2093
+ * reply (`findAssistantReplyAfter`) and match its id against
2094
+ * `interactionMessageId` — giving an EXACT attribution even with several
2095
+ * messages in flight concurrently in one session.
2096
+ *
2097
+ * We fall back to the oldest running message ONLY when no exact match is
2098
+ * possible (the id is absent, the snapshot is missing, or the reply has not yet
2099
+ * been correlated). With a single running message either path is exact. Never
2100
+ * throws.
2101
+ *
2102
+ * Attribution must NOT depend on our own `started` PATCH flag: opencode can
2103
+ * START a turn AND raise a question/permission BEFORE our next tick fires
2104
+ * `markProcessing` (which sets `started`). Relying on `started` would leave the
2105
+ * running set empty in that window and let the server fall back to "newest
2106
+ * processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
2107
+ * person whose active turn actually paused. So we derive "running" from the
2108
+ * tick's `messages` snapshot via `messageRunState` instead.
2109
+ */
2110
+ attributeInteraction(watcher, interactionMessageId, messages) {
2111
+ const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
2112
+ if (inFlight.length === 0) return void 0;
2113
+ if (interactionMessageId && messages) {
2114
+ const exact = inFlight.find((m) => {
2115
+ const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
2116
+ return reply != null && messageIdOf(reply) === interactionMessageId;
2117
+ });
2118
+ if (exact) return exact;
2119
+ }
2120
+ const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
2121
+ if (messages) {
2122
+ const runningPerSnapshot = inFlight.filter(
2123
+ (m) => messageRunState(messages, m.opencodeMessageId) === "running"
2124
+ );
2125
+ if (runningPerSnapshot.length > 0) {
2126
+ return runningPerSnapshot.sort(byOldest)[0];
2127
+ }
2128
+ }
2129
+ const startedRunning = inFlight.filter((m) => m.started);
2130
+ if (startedRunning.length > 0) {
2131
+ return startedRunning.sort(byOldest)[0];
2132
+ }
2133
+ return inFlight.sort(byOldest)[0];
1568
2134
  }
1569
2135
  // -------------------------------------------------------------------------
1570
2136
  // Evident API calls (combinedAuth thread routes)
@@ -1598,36 +2164,86 @@ var ChannelDriver = class {
1598
2164
  }
1599
2165
  return await res.json();
1600
2166
  }
1601
- async markProcessing(conversationId, messageId) {
2167
+ /**
2168
+ * EXISTING combinedAuth route — now fired by the watcher on queued→running
2169
+ * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
2170
+ * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
2171
+ * deep-linked "View in Evident" notice).
2172
+ *
2173
+ * Return/throw contract (consumed by the watcher's swap-to-running guard):
2174
+ * - returns `true` → the server transitioned the row to processing;
2175
+ * - returns `false` → the server gave a DEFINITIVE "already-processing"
2176
+ * answer (a non-retryable, non-auth status — e.g. a
2177
+ * conflict because a duplicate already transitioned it),
2178
+ * so the caller treats it as already-started and does NOT
2179
+ * retry;
2180
+ * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
2181
+ * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
2182
+ * network-level error from `fetch`) — i.e. NO definitive server response —
2183
+ * so the caller leaves the message un-started and retries the swap on the
2184
+ * next tick.
2185
+ * A single attempt (no internal retry): the watcher's per-tick loop is the
2186
+ * retry vehicle for the swap-to-running.
2187
+ */
2188
+ async markProcessing(conversationId, messageId, sessionId) {
1602
2189
  const res = await this.fetchImpl(
1603
2190
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1604
2191
  {
1605
2192
  method: "PATCH",
1606
2193
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1607
- body: JSON.stringify({ status: "processing" })
2194
+ body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
1608
2195
  }
1609
2196
  );
1610
2197
  this.assertAuth(res, "marking message as processing");
1611
- return res.ok;
2198
+ if (res.ok) return true;
2199
+ if (isRetryableStatus(res.status)) {
2200
+ throw new Error(`marking message as processing: HTTP ${res.status}`);
2201
+ }
2202
+ return false;
1612
2203
  }
1613
2204
  /**
1614
- * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1615
- * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
2205
+ * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2206
+ * .../messages/:id {status:'done', opencode_session_id}`. The server's
1616
2207
  * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1617
- * for an already-`done` message a no-op (no double Slack post).
2208
+ * for an already-`done` message a no-op (no double Slack post). Fired by the
2209
+ * watcher on per-message completion (Task 3.4) — no `confirmCompletion`
2210
+ * round-trip (we already observed completion via the message list).
2211
+ *
2212
+ * SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
2213
+ * services its in-flight messages SEQUENTIALLY within a tick
2214
+ * (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
2215
+ * backoff here would BLOCK sibling messages in the SAME session/tick: while
2216
+ * message A's done PATCH burned its internal retries, message B could not be
2217
+ * swapped to running even though opencode had already started it. Instead this
2218
+ * does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
2219
+ * handler already relies on, leaning on the per-tick retry across ticks
2220
+ * (bounded by `inFlight.deadline`) rather than an in-call retry:
2221
+ * - resolves (`void`) → the server transitioned the row to done
2222
+ * (or idempotently confirmed already-done);
2223
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
2224
+ * cleanup, Finding 1);
2225
+ * - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
2226
+ * succeed → straight to the cron, Finding 4);
2227
+ * - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
2228
+ * (no definitive server response → the
2229
+ * watcher retries next tick within the
2230
+ * deadline, Finding 4).
1618
2231
  */
1619
2232
  async markDone(conversationId, messageId, sessionId) {
1620
- await this.callWithRetry(
1621
- "marking message as done",
1622
- () => this.fetchImpl(
1623
- `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1624
- {
1625
- method: "PATCH",
1626
- headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1627
- body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1628
- }
1629
- )
2233
+ const res = await this.fetchImpl(
2234
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2235
+ {
2236
+ method: "PATCH",
2237
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2238
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
2239
+ }
1630
2240
  );
2241
+ this.assertAuth(res, "marking message as done");
2242
+ if (res.ok) return;
2243
+ if (isRetryableStatus(res.status)) {
2244
+ throw new Error(`marking message as done: HTTP ${res.status}`);
2245
+ }
2246
+ throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
1631
2247
  }
1632
2248
  async markFailed(conversationId, messageId) {
1633
2249
  await this.callWithRetry(
@@ -1655,10 +2271,17 @@ var ChannelDriver = class {
1655
2271
  }
1656
2272
  /**
1657
2273
  * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1658
- * `POST .../interactive-event {type, data}`. The server persists the
1659
- * interaction and posts a link to the proxied opencode-web conversation.
2274
+ * `POST .../interactive-event {type, data, source_message_id?}`. The server
2275
+ * persists the interaction and posts a link to the proxied opencode-web
2276
+ * conversation, @mentioning the user who triggered THIS message's turn.
2277
+ *
2278
+ * WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
2279
+ * ts (`message.source_message_id`). The server resolves the @mention from that
2280
+ * message's user FIRST (falling back to the old "newest processing" precedence
2281
+ * only when absent), so the correct person is mentioned under concurrency. It
2282
+ * is OPTIONAL for back-compat with older clients / legacy rows.
1660
2283
  */
1661
- async reportInteraction(conversationId, type, data) {
2284
+ async reportInteraction(conversationId, type, data, sourceMessageId) {
1662
2285
  try {
1663
2286
  await this.callWithRetry(
1664
2287
  "reporting interactive event",
@@ -1667,7 +2290,9 @@ var ChannelDriver = class {
1667
2290
  {
1668
2291
  method: "POST",
1669
2292
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1670
- body: JSON.stringify({ type, data })
2293
+ body: JSON.stringify(
2294
+ sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2295
+ )
1671
2296
  }
1672
2297
  )
1673
2298
  );
@@ -1676,6 +2301,7 @@ var ChannelDriver = class {
1676
2301
  message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1677
2302
  conversation_id: conversationId
1678
2303
  });
2304
+ return true;
1679
2305
  } catch (err) {
1680
2306
  if (err instanceof ChannelAuthError) throw err;
1681
2307
  this.log({
@@ -1683,6 +2309,7 @@ var ChannelDriver = class {
1683
2309
  message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1684
2310
  conversation_id: conversationId
1685
2311
  });
2312
+ return false;
1686
2313
  }
1687
2314
  }
1688
2315
  // -------------------------------------------------------------------------
@@ -1721,8 +2348,9 @@ var ChannelDriver = class {
1721
2348
  await this.sleep(backoffDelay(attempt, this.retry));
1722
2349
  continue;
1723
2350
  }
2351
+ break;
1724
2352
  }
1725
- throw new Error(`${context}: HTTP ${res.status}`);
2353
+ throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
1726
2354
  }
1727
2355
  throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1728
2356
  }
@@ -1848,23 +2476,45 @@ Port ${port} is already in use.`));
1848
2476
  spinner.fail("Failed to start OpenCode");
1849
2477
  throw new Error("OpenCode failed to start");
1850
2478
  }
1851
- spinner.succeed(
1852
- `OpenCode running on port ${port}${health.version ? ` (v${health.version})` : ""}`
1853
- );
2479
+ spinner.stop();
1854
2480
  return { port, process: proc, version: health.version ?? null };
1855
2481
  }
1856
2482
  return { port, process: null, version: null };
1857
2483
  }
1858
2484
 
1859
2485
  // src/commands/agent-lookup.ts
2486
+ async function readErrorMessage(response) {
2487
+ const text = await response.text().catch(() => "");
2488
+ if (!text) return response.statusText || void 0;
2489
+ try {
2490
+ const data = JSON.parse(text);
2491
+ const message = data.message ?? data.error;
2492
+ if (typeof message === "string" && message.trim()) {
2493
+ return message;
2494
+ }
2495
+ } catch {
2496
+ }
2497
+ return text.trim() || response.statusText || void 0;
2498
+ }
2499
+ function authFailureHint(apiUrl, serverMessage) {
2500
+ const reason = serverMessage ? `: ${serverMessage}` : "";
2501
+ return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
2502
+ }
1860
2503
  async function resolveAgentIdFromKey(authHeader) {
1861
2504
  const apiUrl = getApiUrlConfig();
1862
2505
  try {
1863
2506
  const response = await fetch(`${apiUrl}/me`, {
1864
2507
  headers: { Authorization: authHeader }
1865
2508
  });
2509
+ if (response.status === 401) {
2510
+ const serverMessage = await readErrorMessage(response);
2511
+ return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2512
+ }
1866
2513
  if (!response.ok) {
1867
- return { error: `Failed to resolve agent from key: HTTP ${response.status}` };
2514
+ const serverMessage = await readErrorMessage(response);
2515
+ return {
2516
+ error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2517
+ };
1868
2518
  }
1869
2519
  const data = await response.json();
1870
2520
  if (data.auth_type === "agent_key" && data.agent_id) {
@@ -1884,14 +2534,27 @@ async function getAgentInfo(agentId, authHeader) {
1884
2534
  const response = await fetch(`${apiUrl}/agents/${agentId}`, {
1885
2535
  headers: { Authorization: authHeader }
1886
2536
  });
1887
- if (response.status === 404) {
1888
- return { valid: false, error: "Agent not found" };
1889
- }
1890
2537
  if (response.status === 401) {
1891
- return { valid: false, error: "Authentication failed", authFailed: true };
2538
+ const serverMessage = await readErrorMessage(response);
2539
+ return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2540
+ }
2541
+ if (response.status === 403) {
2542
+ const serverMessage = await readErrorMessage(response);
2543
+ return {
2544
+ valid: false,
2545
+ error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
2546
+ };
2547
+ }
2548
+ if (response.status === 404) {
2549
+ const serverMessage = await readErrorMessage(response);
2550
+ return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
1892
2551
  }
1893
2552
  if (!response.ok) {
1894
- return { valid: false, error: `API error: ${response.status}` };
2553
+ const serverMessage = await readErrorMessage(response);
2554
+ return {
2555
+ valid: false,
2556
+ error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2557
+ };
1895
2558
  }
1896
2559
  const agent = await response.json();
1897
2560
  if (agent.agent_type !== "local") {
@@ -1910,7 +2573,7 @@ async function getAgentInfo(agentId, authHeader) {
1910
2573
  // src/commands/run.ts
1911
2574
  var MAX_ACTIVITY_LOG_ENTRIES = 10;
1912
2575
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
1913
- function log(state, message, isError = false) {
2576
+ function log2(state, message, isError = false) {
1914
2577
  if (state.json) {
1915
2578
  console.log(
1916
2579
  JSON.stringify({
@@ -1935,9 +2598,9 @@ function logActivity(state, entry) {
1935
2598
  }
1936
2599
  if (!state.interactive) {
1937
2600
  if (entry.type === "error") {
1938
- log(state, entry.error ?? "Unknown error", true);
2601
+ log2(state, entry.error ?? "Unknown error", true);
1939
2602
  } else if (entry.type === "info" && entry.message) {
1940
- log(state, entry.message);
2603
+ log2(state, entry.message);
1941
2604
  }
1942
2605
  }
1943
2606
  }
@@ -2023,6 +2686,7 @@ async function handleAuthError(state, error2) {
2023
2686
  }
2024
2687
  async function driveChannels(state, driver) {
2025
2688
  let idlePolls = 0;
2689
+ let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
2026
2690
  while (state.running) {
2027
2691
  if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2028
2692
  logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
@@ -2032,9 +2696,11 @@ async function driveChannels(state, driver) {
2032
2696
  try {
2033
2697
  const processed = await driver.drainPending();
2034
2698
  state.messageCount += processed;
2035
- if (processed > 0) {
2699
+ const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
2700
+ lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
2701
+ if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity) {
2036
2702
  idlePolls = 0;
2037
- if (state.interactive) displayStatus(state);
2703
+ if (processed > 0 && state.interactive) displayStatus(state);
2038
2704
  } else if (state.idleTimeout !== null) {
2039
2705
  idlePolls++;
2040
2706
  if (idlePolls === 1) {
@@ -2084,7 +2750,7 @@ async function cleanup(state) {
2084
2750
  logActivity(state, { type: "info", message: "Stopped OpenCode process" });
2085
2751
  displayStatus(state);
2086
2752
  } else {
2087
- log(state, "Stopped OpenCode process");
2753
+ log2(state, "Stopped OpenCode process");
2088
2754
  }
2089
2755
  state.opencodeProcess = null;
2090
2756
  }
@@ -2107,10 +2773,11 @@ async function run(options) {
2107
2773
  running: true,
2108
2774
  activityLog: [],
2109
2775
  messageCount: 0,
2776
+ lastProxiedActivityAt: null,
2110
2777
  authHeader: ""
2111
2778
  };
2112
2779
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
2113
- log(
2780
+ log2(
2114
2781
  state,
2115
2782
  "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.",
2116
2783
  false
@@ -2121,7 +2788,7 @@ async function run(options) {
2121
2788
  logActivity(state, { type: "info", message: "Shutting down..." });
2122
2789
  displayStatus(state);
2123
2790
  } else {
2124
- log(state, "Shutting down...");
2791
+ log2(state, "Shutting down...");
2125
2792
  }
2126
2793
  await cleanup(state);
2127
2794
  await shutdownTelemetry();
@@ -2154,7 +2821,7 @@ async function run(options) {
2154
2821
  const resolved = await resolveAgentIdFromKey(state.authHeader);
2155
2822
  if (resolved.agent_id) {
2156
2823
  state.agentId = resolved.agent_id;
2157
- log(state, `Resolved agent ID from key: ${state.agentId}`);
2824
+ log2(state, `Resolved agent ID from key: ${state.agentId}`);
2158
2825
  if (state.interactive && !state.json) {
2159
2826
  logActivity(state, {
2160
2827
  type: "info",
@@ -2217,14 +2884,21 @@ async function run(options) {
2217
2884
  port: state.port,
2218
2885
  interactive: state.interactive,
2219
2886
  agentId: state.agentId,
2220
- log: (message) => log(state, message)
2887
+ log: (message) => log2(state, message)
2221
2888
  });
2222
2889
  state.port = oc.port;
2223
2890
  state.opencodeProcess = oc.process;
2224
2891
  state.opencodeVersion = oc.version;
2225
2892
  state.opencodeConnected = oc.process !== null || oc.version !== null;
2226
- const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2227
- ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2893
+ const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2894
+ ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
2895
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
2896
+ if (versionWarning) {
2897
+ log2(state, versionWarning, false);
2898
+ if (state.interactive && !state.json) {
2899
+ logActivity(state, { type: "info", message: versionWarning });
2900
+ }
2901
+ }
2228
2902
  } catch (error2) {
2229
2903
  ocSpinner?.fail(error2.message);
2230
2904
  throw error2;
@@ -2258,7 +2932,22 @@ async function run(options) {
2258
2932
  emitAgentConnected(state.agentId, { port: state.port });
2259
2933
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2260
2934
  if (state.interactive) displayStatus(state);
2261
- channelDriver.drainPending().catch(() => {
2935
+ channelDriver.drainPending().then((processed) => {
2936
+ if (processed > 0) {
2937
+ state.messageCount += processed;
2938
+ logActivity(state, {
2939
+ type: "info",
2940
+ message: `Drained ${processed} queued message(s) on connect`
2941
+ });
2942
+ if (state.interactive) displayStatus(state);
2943
+ }
2944
+ }).catch((error2) => {
2945
+ const message = error2 instanceof Error ? error2.message : String(error2);
2946
+ logActivity(state, {
2947
+ type: "error",
2948
+ error: `Failed to drain queued messages on connect: ${message}`
2949
+ });
2950
+ if (state.interactive) displayStatus(state);
2262
2951
  });
2263
2952
  },
2264
2953
  onDisconnected: (code, reason) => {
@@ -2274,9 +2963,40 @@ async function run(options) {
2274
2963
  logActivity(state, { type: "error", error: error2 });
2275
2964
  if (state.interactive) displayStatus(state);
2276
2965
  },
2277
- // Web traffic is proxied transparently; only note opencode is live.
2966
+ // Web traffic is proxied transparently; note opencode is live and stamp
2967
+ // proxied activity so the idle loop treats interactive proxy use as work.
2968
+ // Fires per forwarded response head (incl. every SSE open) and excludes
2969
+ // the internal drain-ping, so an actively-used proxy keeps the timer
2970
+ // fresh while a lone idle SSE with no follow-up requests still ages out.
2278
2971
  onResponse: () => {
2279
2972
  state.opencodeConnected = true;
2973
+ state.lastProxiedActivityAt = Date.now();
2974
+ },
2975
+ // A channel message was queued and the api-worker pinged us over the
2976
+ // tunnel to drain immediately instead of waiting for the next poll tick.
2977
+ // Best-effort + non-fatal: mirror the on-connect drain block. A failed
2978
+ // drain here is logged and swallowed — the steady-state poll retries, so
2979
+ // a lost/failed ping can never orphan a message (§2 invariant).
2980
+ onDrainPing: () => {
2981
+ if (!state.running) return;
2982
+ logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
2983
+ channelDriver.drainPending().then((processed) => {
2984
+ if (processed > 0) {
2985
+ state.messageCount += processed;
2986
+ logActivity(state, {
2987
+ type: "info",
2988
+ message: `Drained ${processed} queued message(s) on ping`
2989
+ });
2990
+ if (state.interactive) displayStatus(state);
2991
+ }
2992
+ }).catch((error2) => {
2993
+ const message = error2 instanceof Error ? error2.message : String(error2);
2994
+ logActivity(state, {
2995
+ type: "error",
2996
+ error: `Failed to drain queued messages on ping: ${message}`
2997
+ });
2998
+ if (state.interactive) displayStatus(state);
2999
+ });
2280
3000
  },
2281
3001
  onInfo: (message) => logActivity(state, { type: "info", message })
2282
3002
  }
@@ -2288,10 +3008,8 @@ async function run(options) {
2288
3008
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2289
3009
  throw error2;
2290
3010
  }
2291
- if (interactive && !state.json) {
2292
- displayStatus(state);
2293
- } else {
2294
- log(state, "Driving channel messages...");
3011
+ if (!interactive || state.json) {
3012
+ log2(state, "Driving channel messages...");
2295
3013
  }
2296
3014
  await driveChannels(state, channelDriver);
2297
3015
  await cleanup(state);
@@ -2303,7 +3021,7 @@ async function run(options) {
2303
3021
  })
2304
3022
  );
2305
3023
  } else if (!interactive) {
2306
- log(state, `Completed. Processed ${state.messageCount} message(s).`);
3024
+ log2(state, `Completed. Processed ${state.messageCount} message(s).`);
2307
3025
  }
2308
3026
  await shutdownTelemetry();
2309
3027
  process.exit(0);
@@ -2325,8 +3043,9 @@ async function run(options) {
2325
3043
  }
2326
3044
 
2327
3045
  // src/index.ts
3046
+ var { version } = createRequire(import.meta.url)("../package.json");
2328
3047
  var program = new Command();
2329
- program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
3048
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version(version).option(
2330
3049
  "--endpoint <url>",
2331
3050
  "Evident API base URL (default: production; e.g. http://localhost:3001)"
2332
3051
  ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
@@ -2339,7 +3058,7 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
2339
3058
  }
2340
3059
  });
2341
3060
  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);
2342
- program.command("logout").description("Remove stored credentials").action(logout);
3061
+ 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 }));
2343
3062
  program.command("whoami").description("Show the currently logged in user").action(whoami);
2344
3063
  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").action(
2345
3064
  (options) => {