@evident-ai/cli 3.0.0 → 3.0.1-dev.0590aa5

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
@@ -32,10 +32,10 @@ function setTunnelUrl(url) {
32
32
  tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
33
33
  }
34
34
  function getApiUrl() {
35
- return process.env.EVIDENT_API_URL ?? endpointOverride ?? defaults.apiUrl;
35
+ return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
36
36
  }
37
37
  function getTunnelUrl() {
38
- return process.env.EVIDENT_TUNNEL_URL ?? tunnelOverride ?? defaults.tunnelUrl;
38
+ return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
39
39
  }
40
40
  var config = new Conf({
41
41
  projectName: "evident",
@@ -54,19 +54,28 @@ function getApiUrlConfig() {
54
54
  function getTunnelUrlConfig() {
55
55
  return getTunnelUrl();
56
56
  }
57
+ function credentialsKey() {
58
+ return getApiUrl();
59
+ }
57
60
  function getCredentials() {
58
- return {
59
- token: credentials.get("token"),
60
- user: credentials.get("user"),
61
- expiresAt: credentials.get("expiresAt")
62
- };
61
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
62
+ return byEndpoint[credentialsKey()] ?? {};
63
63
  }
64
64
  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);
65
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
66
+ byEndpoint[credentialsKey()] = {
67
+ token: creds.token,
68
+ user: creds.user,
69
+ expiresAt: creds.expiresAt
70
+ };
71
+ credentials.set("byEndpoint", byEndpoint);
68
72
  }
69
73
  function clearCredentials() {
74
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
75
+ delete byEndpoint[credentialsKey()];
76
+ credentials.set("byEndpoint", byEndpoint);
77
+ }
78
+ function clearAllCredentials() {
70
79
  credentials.clear();
71
80
  }
72
81
  function getCliName() {
@@ -176,7 +185,6 @@ var api = {
176
185
 
177
186
  // src/lib/keychain.ts
178
187
  var SERVICE_NAME = "evident-cli";
179
- var ACCOUNT_NAME = "default";
180
188
  async function getKeytar() {
181
189
  try {
182
190
  const keytar = await import("keytar");
@@ -188,10 +196,13 @@ async function getKeytar() {
188
196
  return null;
189
197
  }
190
198
  }
199
+ function keychainAccount() {
200
+ return getApiUrlConfig();
201
+ }
191
202
  async function storeToken(credentials2) {
192
203
  const keytar = await getKeytar();
193
204
  if (keytar) {
194
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
205
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
195
206
  } else {
196
207
  setCredentials({
197
208
  token: credentials2.token,
@@ -203,12 +214,13 @@ async function storeToken(credentials2) {
203
214
  async function getToken() {
204
215
  const keytar = await getKeytar();
205
216
  if (keytar) {
206
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
217
+ const account = keychainAccount();
218
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
207
219
  if (stored) {
208
220
  try {
209
221
  return JSON.parse(stored);
210
222
  } catch {
211
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
223
+ await keytar.deletePassword(SERVICE_NAME, account);
212
224
  return null;
213
225
  }
214
226
  }
@@ -223,12 +235,26 @@ async function getToken() {
223
235
  }
224
236
  return null;
225
237
  }
226
- async function deleteToken() {
238
+ async function deleteToken(options = {}) {
227
239
  const keytar = await getKeytar();
228
240
  if (keytar) {
229
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
241
+ if (options.all) {
242
+ const all = await keytar.findCredentials(SERVICE_NAME).catch(() => []);
243
+ await Promise.all(
244
+ all.map(
245
+ (entry) => keytar.deletePassword(SERVICE_NAME, entry.account).catch(() => {
246
+ })
247
+ )
248
+ );
249
+ } else {
250
+ await keytar.deletePassword(SERVICE_NAME, keychainAccount());
251
+ }
252
+ }
253
+ if (options.all) {
254
+ clearAllCredentials();
255
+ } else {
256
+ clearCredentials();
230
257
  }
231
- clearCredentials();
232
258
  }
233
259
 
234
260
  // src/utils/ui.ts
@@ -396,25 +422,32 @@ async function login(options) {
396
422
  }
397
423
 
398
424
  // src/commands/logout.ts
399
- async function logout() {
425
+ async function logout(options = {}) {
426
+ if (options.all) {
427
+ await deleteToken({ all: true });
428
+ printSuccess("Logged out of all endpoints.");
429
+ return;
430
+ }
400
431
  const credentials2 = await getToken();
401
432
  if (!credentials2) {
402
- printWarning("You are not logged in.");
433
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
403
434
  return;
404
435
  }
405
436
  await deleteToken();
406
- printSuccess("Logged out successfully.");
437
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
407
438
  }
408
439
 
409
440
  // src/commands/whoami.ts
410
441
  import chalk3 from "chalk";
411
442
  async function whoami() {
443
+ const apiUrl = getApiUrlConfig();
412
444
  const credentials2 = await getToken();
413
445
  if (!credentials2) {
414
- printError("Not logged in. Run the `login` command to authenticate.");
446
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
415
447
  process.exit(1);
416
448
  }
417
449
  blank();
450
+ console.log(keyValue("Endpoint", apiUrl));
418
451
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
419
452
  console.log(keyValue("User ID", credentials2.user.id));
420
453
  if (credentials2.expiresAt) {
@@ -449,6 +482,7 @@ var TelemetryEventTypes = {
449
482
 
450
483
  // ../../packages/types/src/tunnel/index.ts
451
484
  var MAX_FRAME_BYTES = 256 * 1024;
485
+ var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
452
486
 
453
487
  // src/lib/telemetry.ts
454
488
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -650,6 +684,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
650
684
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
651
685
  }
652
686
 
687
+ // src/lib/opencode/opencode-version-gate.ts
688
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
689
+ function isQueueValidatedVersion(version) {
690
+ if (!version) return false;
691
+ return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version);
692
+ }
693
+ function buildOpenCodeVersionWarning(version) {
694
+ if (isQueueValidatedVersion(version)) return null;
695
+ const detected = version ? `v${version}` : "unknown";
696
+ const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
697
+ 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.`;
698
+ }
699
+
653
700
  // src/lib/opencode/process.ts
654
701
  import { execSync, spawn } from "child_process";
655
702
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -940,20 +987,68 @@ async function promptOpenCodeInstall(interactive) {
940
987
  }
941
988
 
942
989
  // src/lib/opencode/session.ts
943
- async function createOpenCodeSession(port) {
944
- const response = await fetch(`http://localhost:${port}/session`, {
990
+ function opencodeBase(port) {
991
+ return `http://127.0.0.1:${port}`;
992
+ }
993
+ async function getOpenCodeDirectory(port) {
994
+ try {
995
+ const res = await fetch(`${opencodeBase(port)}/path`);
996
+ if (!res.ok) return null;
997
+ const body = await res.json();
998
+ 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;
999
+ return dir && dir.trim() ? dir.trim() : null;
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }
1004
+ function roleOf(m) {
1005
+ if (!m || typeof m !== "object") return void 0;
1006
+ if (typeof m.role === "string") return m.role;
1007
+ const infoRole = m.info?.role;
1008
+ return typeof infoRole === "string" ? infoRole : void 0;
1009
+ }
1010
+ function completedOf(m) {
1011
+ if (!m || typeof m !== "object") return void 0;
1012
+ return m.info?.time?.completed;
1013
+ }
1014
+ function idOf(m) {
1015
+ if (!m || typeof m !== "object") return void 0;
1016
+ if (typeof m.id === "string") return m.id;
1017
+ const infoId = m.info?.id;
1018
+ return typeof infoId === "string" ? infoId : void 0;
1019
+ }
1020
+ function parentIdOf(m) {
1021
+ if (!m || typeof m !== "object") return void 0;
1022
+ if (typeof m.parentID === "string") return m.parentID;
1023
+ const infoParent = m.info?.parentID;
1024
+ return typeof infoParent === "string" ? infoParent : void 0;
1025
+ }
1026
+ function finishOf(m) {
1027
+ if (!m || typeof m !== "object") return void 0;
1028
+ if (typeof m.finish === "string") return m.finish;
1029
+ const infoFinish = m.info?.finish;
1030
+ return typeof infoFinish === "string" ? infoFinish : void 0;
1031
+ }
1032
+ async function createOpenCodeSession(port, directory) {
1033
+ const url = new URL(`${opencodeBase(port)}/session`);
1034
+ if (directory && directory.trim()) {
1035
+ url.searchParams.set("directory", directory.trim());
1036
+ }
1037
+ const response = await fetch(url, {
945
1038
  method: "POST",
946
1039
  headers: { "Content-Type": "application/json" },
947
1040
  body: JSON.stringify({})
948
1041
  });
949
1042
  if (!response.ok) {
950
- throw new Error(`Failed to create session: HTTP ${response.status}`);
1043
+ const text = await response.text().catch(() => "");
1044
+ throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
951
1045
  }
952
1046
  const data = await response.json();
953
1047
  return data.id;
954
1048
  }
955
- async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1049
+ async function sendPromptAsync(port, sessionId, content, options, messageId) {
956
1050
  const body = {
1051
+ messageID: messageId,
957
1052
  parts: [{ type: "text", text: content }]
958
1053
  };
959
1054
  if (options?.agent) {
@@ -968,76 +1063,60 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
968
1063
  };
969
1064
  }
970
1065
  }
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;
1066
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
1067
+ method: "POST",
1068
+ headers: { "Content-Type": "application/json" },
1069
+ body: JSON.stringify(body)
1070
+ });
1071
+ if (res.status < 200 || res.status >= 300) {
1072
+ const text = await res.text().catch(() => "");
1073
+ throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1074
+ }
1075
+ }
1076
+ function findAssistantReplyAfter(messages, userMessageId) {
1077
+ if (!messages || messages.length === 0) return null;
1078
+ const byParent = messages.find(
1079
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1080
+ );
1081
+ if (byParent) return byParent;
1082
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1083
+ if (userIndex === -1) return null;
1084
+ for (let i = userIndex + 1; i < messages.length; i++) {
1085
+ if (roleOf(messages[i]) === "assistant") return messages[i];
1086
+ }
1087
+ return null;
1088
+ }
1089
+ function findLastAssistantReplyFor(messages, userMessageId) {
1090
+ if (!messages || messages.length === 0) return null;
1091
+ for (let i = messages.length - 1; i >= 0; i--) {
1092
+ const m = messages[i];
1093
+ if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1094
+ }
1095
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1096
+ if (userIndex === -1) return null;
1097
+ let last = null;
1098
+ for (let i = userIndex + 1; i < messages.length; i++) {
1099
+ const role = roleOf(messages[i]);
1100
+ if (role === "user") break;
1101
+ if (role === "assistant") last = messages[i];
1102
+ }
1103
+ return last;
1104
+ }
1105
+ function messageRunState(messages, userMessageId) {
1106
+ if (!messages || messages.length === 0) return "unknown";
1107
+ const hasUser = messages.some((m) => idOf(m) === userMessageId);
1108
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1109
+ if (!hasUser) {
1110
+ if (!reply) return "unknown";
1111
+ }
1112
+ if (!reply) return "queued";
1113
+ if (completedOf(reply) == null) return "running";
1114
+ if (finishOf(reply) === "tool-calls") return "running";
1115
+ return "done";
1116
+ }
1117
+ function opencodeMessageIdFor(queuedMessageId) {
1118
+ const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
1119
+ return `msg_${sanitized}`;
1041
1120
  }
1042
1121
 
1043
1122
  // src/lib/tunnel/connection.ts
@@ -1108,6 +1187,12 @@ var StreamForwarder = class {
1108
1187
  }
1109
1188
  async handleOpen(frame) {
1110
1189
  const { sid, method, path, headers, has_body } = frame;
1190
+ if (path === TUNNEL_DRAIN_PING_PATH) {
1191
+ this.callbacks.onDrainPing?.();
1192
+ this.send({ type: "head", sid, status: 204, headers: {} });
1193
+ this.send({ type: "res_end", sid });
1194
+ return;
1195
+ }
1111
1196
  const ac = new AbortController();
1112
1197
  let bodyPromise;
1113
1198
  let pushBody;
@@ -1224,7 +1309,8 @@ function connectTunnel(options) {
1224
1309
  onError,
1225
1310
  onRequest,
1226
1311
  onResponse,
1227
- onInfo
1312
+ onInfo,
1313
+ onDrainPing
1228
1314
  } = options;
1229
1315
  const tunnelUrl = getTunnelUrlConfig();
1230
1316
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
@@ -1237,6 +1323,7 @@ function connectTunnel(options) {
1237
1323
  const streamStartTimes = /* @__PURE__ */ new Map();
1238
1324
  const forwarder = new StreamForwarder(ws, port, {
1239
1325
  onOpen: (sid, method, path) => {
1326
+ if (path === TUNNEL_DRAIN_PING_PATH) return;
1240
1327
  streamStartTimes.set(sid, Date.now());
1241
1328
  onRequest?.(method, path, sid);
1242
1329
  },
@@ -1244,7 +1331,8 @@ function connectTunnel(options) {
1244
1331
  const startedAt = streamStartTimes.get(sid);
1245
1332
  streamStartTimes.delete(sid);
1246
1333
  onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1247
- }
1334
+ },
1335
+ onDrainPing: () => onDrainPing?.()
1248
1336
  });
1249
1337
  const connectionTimeout = setTimeout(() => {
1250
1338
  ws.close();
@@ -1386,6 +1474,7 @@ var RunnerConnection = class {
1386
1474
  },
1387
1475
  onError: (error2) => events.onError?.(error2),
1388
1476
  onResponse: () => events.onResponse?.(),
1477
+ onDrainPing: () => events.onDrainPing?.(),
1389
1478
  onInfo: (message) => events.onInfo?.(message)
1390
1479
  });
1391
1480
  return;
@@ -1406,17 +1495,34 @@ var RunnerConnection = class {
1406
1495
  };
1407
1496
 
1408
1497
  // src/lib/channels/driver.ts
1498
+ function messageIdOf(m) {
1499
+ if (!m || typeof m !== "object") return void 0;
1500
+ if (typeof m.id === "string") return m.id;
1501
+ const infoId = m.info?.id;
1502
+ return typeof infoId === "string" ? infoId : void 0;
1503
+ }
1409
1504
  var DEFAULT_RETRY_POLICY = {
1410
1505
  maxAttempts: 6,
1411
1506
  baseDelayMs: 500,
1412
1507
  maxDelayMs: 3e4
1413
1508
  };
1509
+ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1510
+ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1511
+ var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1414
1512
  var ChannelAuthError = class extends Error {
1415
1513
  constructor(message) {
1416
1514
  super(message);
1417
1515
  this.name = "ChannelAuthError";
1418
1516
  }
1419
1517
  };
1518
+ var ChannelTerminalError = class extends Error {
1519
+ status;
1520
+ constructor(message, status) {
1521
+ super(message);
1522
+ this.name = "ChannelTerminalError";
1523
+ this.status = status;
1524
+ }
1525
+ };
1420
1526
  function backoffDelay(attempt, policy) {
1421
1527
  const exp = policy.baseDelayMs * Math.pow(2, attempt);
1422
1528
  const capped = Math.min(policy.maxDelayMs, exp);
@@ -1435,8 +1541,35 @@ var ChannelDriver = class {
1435
1541
  log;
1436
1542
  fetchImpl;
1437
1543
  sleep;
1544
+ pausedPollIntervalMs;
1545
+ pausedMaxWaitMs;
1546
+ dispatchConfirmMs;
1547
+ now;
1438
1548
  /** Cache of conversationId → opencode sessionId. */
1439
1549
  sessions = /* @__PURE__ */ new Map();
1550
+ /**
1551
+ * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1552
+ * session: one polling loop services all of that session's in-flight messages.
1553
+ * A session entry exists while it has any in-flight (dispatched-but-not-done)
1554
+ * message; it is removed once its in-flight set empties.
1555
+ */
1556
+ watchers = /* @__PURE__ */ new Map();
1557
+ /**
1558
+ * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
1559
+ * dispatched and are still in-flight. A message in this set is never
1560
+ * re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
1561
+ * Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
1562
+ * idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
1563
+ * a steady-state-poll re-dispatch will not double-run the message.
1564
+ */
1565
+ dispatched = /* @__PURE__ */ new Set();
1566
+ /**
1567
+ * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1568
+ * first session creation so drain-created sessions are rooted at the project
1569
+ * directory and thus visible in `opencode web`'s session list. `undefined` =
1570
+ * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1571
+ */
1572
+ opencodeDirectory = void 0;
1440
1573
  /** Serialises drains so a reconnect during a drain doesn't double-process. */
1441
1574
  draining = false;
1442
1575
  constructor(config2) {
@@ -1450,6 +1583,10 @@ var ChannelDriver = class {
1450
1583
  });
1451
1584
  this.fetchImpl = config2.fetchImpl ?? fetch;
1452
1585
  this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1586
+ this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1587
+ this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1588
+ this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1589
+ this.now = config2.now ?? (() => Date.now());
1453
1590
  }
1454
1591
  /** The IPv4-loopback base URL for the local `opencode serve`. */
1455
1592
  get opencodeBase() {
@@ -1459,80 +1596,115 @@ var ChannelDriver = class {
1459
1596
  // Public API
1460
1597
  // -------------------------------------------------------------------------
1461
1598
  /**
1462
- * Drain all pending channel conversations once: poll → processcallback.
1599
+ * Drain all pending channel conversations once: poll → dispatchregister.
1463
1600
  * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1464
1601
  * Re-entrant calls while a drain is in flight are skipped (return 0).
1465
1602
  *
1466
- * @returns the number of messages processed.
1603
+ * @returns the number of messages NEWLY dispatched to opencode's native queue.
1467
1604
  */
1468
1605
  async drainPending() {
1469
1606
  if (this.draining) return 0;
1470
1607
  this.draining = true;
1471
- let processed = 0;
1608
+ let dispatched = 0;
1472
1609
  try {
1473
1610
  const conversations = await this.getPendingConversations();
1611
+ if (conversations.length > 0) {
1612
+ const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
1613
+ this.log({
1614
+ level: "info",
1615
+ message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
1616
+ });
1617
+ }
1474
1618
  for (const conv of conversations) {
1475
- processed += await this.processConversation(conv);
1619
+ dispatched += await this.processConversation(conv);
1476
1620
  }
1477
1621
  } finally {
1478
1622
  this.draining = false;
1479
1623
  }
1480
- return processed;
1624
+ return dispatched;
1625
+ }
1626
+ /**
1627
+ * True while any per-session watcher has a non-empty in-flight dispatched set
1628
+ * (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
1629
+ * the process while a dispatched message is still queued/running — which would
1630
+ * kill the turn and orphan its reply.
1631
+ */
1632
+ hasInFlightWatchers() {
1633
+ for (const watcher of this.watchers.values()) {
1634
+ if (watcher.inFlight.size > 0) return true;
1635
+ }
1636
+ return false;
1637
+ }
1638
+ /**
1639
+ * Await all outstanding per-session watchers (WI-3).
1640
+ *
1641
+ * In production the watcher loops are deliberately started-not-awaited so the
1642
+ * drain loop never blocks on them and process exit is not held up (the cron
1643
+ * recovers any abandoned ones). This helper exists primarily for deterministic
1644
+ * tests that need to observe a watcher's effect (the `processing`/`done` PATCH
1645
+ * or its giving up) after a non-blocking `drainPending`. Watcher loops never
1646
+ * reject, so this resolves.
1647
+ */
1648
+ async flushPausedWatchers() {
1649
+ while (true) {
1650
+ const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
1651
+ if (loops.length === 0) return;
1652
+ await Promise.all(loops);
1653
+ const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
1654
+ if (!stillLive) return;
1655
+ }
1481
1656
  }
1482
1657
  // -------------------------------------------------------------------------
1483
- // Conversation processing
1658
+ // Conversation processing (WI-3 — async dispatch)
1484
1659
  // -------------------------------------------------------------------------
1660
+ /**
1661
+ * Dispatch each pending message for a conversation to opencode's native queue
1662
+ * via `prompt_async` (Task 3.2) and register it with the conversation's
1663
+ * per-session watcher. Does NOT block on the turn and does NOT call
1664
+ * `markProcessing` here — that fires from the watcher on running-start.
1665
+ *
1666
+ * @returns the count of messages NEWLY dispatched (not already in-flight).
1667
+ */
1485
1668
  async processConversation(conv) {
1486
1669
  const sessionId = await this.ensureSession(conv);
1487
1670
  const messages = await this.getPendingMessages(conv.id);
1488
- let processed = 0;
1671
+ let dispatched = 0;
1489
1672
  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
- });
1673
+ if (this.dispatched.has(message.id)) {
1498
1674
  continue;
1499
1675
  }
1676
+ const opencodeMessageId = opencodeMessageIdFor(message.id);
1677
+ const options = {
1678
+ agent: message.opencode_agent ?? void 0,
1679
+ model: message.opencode_model ?? void 0
1680
+ };
1500
1681
  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
1682
  this.log({
1518
1683
  level: "info",
1519
- message: `Message ${message.id.slice(0, 8)} processed`,
1684
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
1520
1685
  conversation_id: conv.id,
1521
1686
  message_id: message.id
1522
1687
  });
1688
+ await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1523
1689
  } catch (err) {
1524
1690
  if (err instanceof ChannelAuthError) throw err;
1691
+ this.dispatched.delete(message.id);
1525
1692
  await this.markFailed(conv.id, message.id).catch(() => {
1526
1693
  });
1527
1694
  this.log({
1528
1695
  level: "error",
1529
- message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1696
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1530
1697
  conversation_id: conv.id,
1531
1698
  message_id: message.id
1532
1699
  });
1700
+ continue;
1533
1701
  }
1702
+ this.dispatched.add(message.id);
1703
+ this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1704
+ dispatched += 1;
1534
1705
  }
1535
- return processed;
1706
+ this.ensureWatcherRunning(sessionId);
1707
+ return dispatched;
1536
1708
  }
1537
1709
  async ensureSession(conv) {
1538
1710
  const cached = this.sessions.get(conv.id);
@@ -1541,30 +1713,372 @@ var ChannelDriver = class {
1541
1713
  this.sessions.set(conv.id, conv.opencode_session_id);
1542
1714
  return conv.opencode_session_id;
1543
1715
  }
1544
- const sessionId = await createOpenCodeSession(this.port);
1716
+ const directory = await this.resolveOpenCodeDirectory();
1717
+ const sessionId = await createOpenCodeSession(this.port, directory);
1545
1718
  this.sessions.set(conv.id, sessionId);
1546
1719
  await this.persistSession(conv.id, sessionId).catch(() => {
1547
1720
  });
1548
1721
  return sessionId;
1549
1722
  }
1550
1723
  /**
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).
1724
+ * Lazily resolve (and cache) opencode's root directory via `GET /path`.
1725
+ * Resolved once per driver: `undefined` until first lookup, then the directory
1726
+ * string or `null` if unavailable (we don't keep retrying a missing `/path`).
1554
1727
  */
1555
- async confirmCompletion(sessionId) {
1728
+ async resolveOpenCodeDirectory() {
1729
+ if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
1730
+ this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1731
+ if (!this.opencodeDirectory) {
1732
+ this.log({
1733
+ level: "info",
1734
+ message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1735
+ });
1736
+ }
1737
+ return this.opencodeDirectory;
1738
+ }
1739
+ // -------------------------------------------------------------------------
1740
+ // Per-session watcher (WI-3)
1741
+ // -------------------------------------------------------------------------
1742
+ /** Register a freshly-dispatched message with its session's watcher state. */
1743
+ registerInFlight(conv, sessionId, message, opencodeMessageId) {
1744
+ let watcher = this.watchers.get(sessionId);
1745
+ if (!watcher) {
1746
+ watcher = {
1747
+ conv,
1748
+ inFlight: /* @__PURE__ */ new Map(),
1749
+ loop: null,
1750
+ reportedQuestions: /* @__PURE__ */ new Set(),
1751
+ reportedPermissions: /* @__PURE__ */ new Set()
1752
+ };
1753
+ this.watchers.set(sessionId, watcher);
1754
+ }
1755
+ const now = this.now();
1756
+ watcher.inFlight.set(message.id, {
1757
+ evidentMessageId: message.id,
1758
+ opencodeMessageId,
1759
+ message,
1760
+ dispatchedAt: now,
1761
+ deadline: now + this.pausedMaxWaitMs,
1762
+ started: false,
1763
+ done: false
1764
+ });
1765
+ }
1766
+ /**
1767
+ * Start (but do NOT await) the per-session watcher loop if it has in-flight
1768
+ * work and is not already running. Single-flight per session. The loop is
1769
+ * tracked on the watcher and cleared when it settles; it never rejects (fully
1770
+ * guarded), so a failed poll/callback can never crash the run loop — the cron
1771
+ * stays as the safety net.
1772
+ */
1773
+ ensureWatcherRunning(sessionId) {
1774
+ const watcher = this.watchers.get(sessionId);
1775
+ if (!watcher) return;
1776
+ if (watcher.loop) return;
1777
+ if (watcher.inFlight.size === 0) {
1778
+ this.watchers.delete(sessionId);
1779
+ return;
1780
+ }
1781
+ const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
1782
+ watcher.loop = null;
1783
+ if (watcher.inFlight.size === 0) {
1784
+ this.watchers.delete(sessionId);
1785
+ }
1786
+ });
1787
+ watcher.loop = loop;
1788
+ }
1789
+ /**
1790
+ * The per-session polling loop (WI-3). Once per tick it:
1791
+ * 1. polls `GET /session/:id/message` once and, per in-flight message,
1792
+ * computes `messageRunState` and fires markProcessing (queued→running) /
1793
+ * markDone (done) exactly once per transition;
1794
+ * 2. applies the idle-path re-dispatch guard (a dispatched message that never
1795
+ * APPEARS → re-dispatch — D1 obligation 2);
1796
+ * 3. polls `/question` + `/permission` (scoped to the session) and surfaces
1797
+ * NEW ones via `reportInteraction`, carrying the PAUSED message's own
1798
+ * `source_message_id`;
1799
+ * 4. drops messages that completed or timed out from the in-flight set.
1800
+ * Exits when the in-flight set empties. Never throws.
1801
+ */
1802
+ async runWatcherLoop(sessionId, watcher) {
1556
1803
  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) {
1804
+ while (watcher.inFlight.size > 0) {
1805
+ await this.sleep(this.pausedPollIntervalMs);
1806
+ let messages = null;
1807
+ try {
1808
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1809
+ if (res.ok) {
1810
+ const body = await res.json();
1811
+ messages = Array.isArray(body) ? body : null;
1812
+ }
1813
+ } catch {
1814
+ continue;
1815
+ }
1816
+ for (const inFlight of [...watcher.inFlight.values()]) {
1817
+ await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
1818
+ }
1819
+ await this.pollInteractions(sessionId, watcher, messages);
1820
+ }
1821
+ } catch (err) {
1822
+ if (err instanceof ChannelAuthError) {
1823
+ this.log({
1824
+ level: "error",
1825
+ 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}`,
1826
+ conversation_id: watcher.conv.id
1827
+ });
1828
+ for (const evidentMessageId of [...watcher.inFlight.keys()]) {
1829
+ this.removeInFlight(watcher, evidentMessageId);
1830
+ }
1831
+ return;
1832
+ }
1833
+ this.log({
1834
+ level: "error",
1835
+ message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1836
+ conversation_id: watcher.conv.id
1837
+ });
1838
+ }
1839
+ }
1840
+ /**
1841
+ * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1842
+ * Fires markProcessing on queued→running and markDone on done (each once),
1843
+ * applies the idle-path re-dispatch guard, and removes the message from the
1844
+ * in-flight set on completion or timeout.
1845
+ */
1846
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1847
+ const conv = watcher.conv;
1848
+ const state = messageRunState(messages, inFlight.opencodeMessageId);
1849
+ if ((state === "running" || state === "done") && !inFlight.started) {
1850
+ let claimed;
1851
+ try {
1852
+ claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
1853
+ } catch (err) {
1854
+ if (err instanceof ChannelAuthError) throw err;
1855
+ this.log({
1856
+ level: "error",
1857
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1858
+ conversation_id: conv.id,
1859
+ message_id: inFlight.evidentMessageId
1860
+ });
1861
+ return;
1862
+ }
1863
+ inFlight.started = true;
1864
+ if (!claimed) {
1865
+ this.log({
1866
+ level: "info",
1867
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
1868
+ conversation_id: conv.id,
1869
+ message_id: inFlight.evidentMessageId
1870
+ });
1871
+ }
1872
+ }
1873
+ if (state === "done") {
1874
+ if (!inFlight.done) {
1561
1875
  this.log({
1562
1876
  level: "info",
1563
- message: `Session ${sessionId.slice(0, 8)} not marked completed on reconcile \u2014 delivering anyway`
1877
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1878
+ conversation_id: conv.id,
1879
+ message_id: inFlight.evidentMessageId
1564
1880
  });
1881
+ try {
1882
+ await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
1883
+ } catch (err) {
1884
+ if (err instanceof ChannelAuthError) throw err;
1885
+ if (err instanceof ChannelTerminalError) {
1886
+ this.log({
1887
+ level: "error",
1888
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
1889
+ conversation_id: conv.id,
1890
+ message_id: inFlight.evidentMessageId
1891
+ });
1892
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1893
+ return;
1894
+ }
1895
+ if (this.now() >= inFlight.deadline) {
1896
+ this.log({
1897
+ level: "error",
1898
+ 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)}`,
1899
+ conversation_id: conv.id,
1900
+ message_id: inFlight.evidentMessageId
1901
+ });
1902
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1903
+ return;
1904
+ }
1905
+ this.log({
1906
+ level: "error",
1907
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1908
+ conversation_id: conv.id,
1909
+ message_id: inFlight.evidentMessageId
1910
+ });
1911
+ return;
1912
+ }
1913
+ inFlight.done = true;
1914
+ }
1915
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1916
+ return;
1917
+ }
1918
+ if (state === "unknown") {
1919
+ if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
1920
+ await this.redispatchInFlight(sessionId, inFlight);
1921
+ }
1922
+ }
1923
+ if (this.now() >= inFlight.deadline) {
1924
+ this.log({
1925
+ level: "info",
1926
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
1927
+ conversation_id: conv.id,
1928
+ message_id: inFlight.evidentMessageId
1929
+ });
1930
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1931
+ }
1932
+ }
1933
+ /**
1934
+ * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
1935
+ * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
1936
+ * fact 9) — one user message + one reply even if the original DID land. Resets
1937
+ * the dispatch timestamp so the guard doesn't immediately fire again.
1938
+ */
1939
+ async redispatchInFlight(sessionId, inFlight) {
1940
+ const options = {
1941
+ agent: inFlight.message.opencode_agent ?? void 0,
1942
+ model: inFlight.message.opencode_model ?? void 0
1943
+ };
1944
+ this.log({
1945
+ level: "info",
1946
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
1947
+ message_id: inFlight.evidentMessageId
1948
+ });
1949
+ try {
1950
+ await sendPromptAsync(
1951
+ this.port,
1952
+ sessionId,
1953
+ inFlight.message.content,
1954
+ options,
1955
+ inFlight.opencodeMessageId
1956
+ );
1957
+ } catch (err) {
1958
+ this.log({
1959
+ level: "error",
1960
+ message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1961
+ message_id: inFlight.evidentMessageId
1962
+ });
1963
+ }
1964
+ inFlight.dispatchedAt = this.now();
1965
+ }
1966
+ /**
1967
+ * Remove a message from the in-flight set AND the authoritative dispatched
1968
+ * set. Once the in-flight set empties, the watcher loop's `while` guard exits
1969
+ * and its `.finally` removes the session entry from `this.watchers`.
1970
+ */
1971
+ removeInFlight(watcher, evidentMessageId) {
1972
+ watcher.inFlight.delete(evidentMessageId);
1973
+ this.dispatched.delete(evidentMessageId);
1974
+ }
1975
+ /**
1976
+ * Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
1977
+ * via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
1978
+ * `source_message_id` so the server @mentions the correct person under
1979
+ * concurrency. Dedups by interaction id across ticks (reused per-session sets).
1980
+ *
1981
+ * The interaction is attributed to the in-flight message it paused on. opencode
1982
+ * stamps a `messageID` on a permission (and `tool.messageID` on a question) =
1983
+ * the assistant message id, whose `parentID` is the user message id — but the
1984
+ * simplest robust attribution here is: the single in-flight message that is
1985
+ * RUNNING (not done) is the one that paused. With one running message that is
1986
+ * unambiguous; with several we prefer an explicit messageID match, else the
1987
+ * oldest running message.
1988
+ */
1989
+ async pollInteractions(sessionId, watcher, messages) {
1990
+ let questions = [];
1991
+ try {
1992
+ const res = await this.fetchImpl(`${this.opencodeBase}/question`);
1993
+ if (res.ok) {
1994
+ const body = await res.json();
1995
+ questions = Array.isArray(body) ? body : [];
1565
1996
  }
1566
1997
  } catch {
1567
1998
  }
1999
+ for (const q of questions) {
2000
+ if (q.sessionID !== sessionId) continue;
2001
+ if (watcher.reportedQuestions.has(q.id)) continue;
2002
+ const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
2003
+ const reported = await this.reportInteraction(
2004
+ watcher.conv.id,
2005
+ "question",
2006
+ q,
2007
+ paused?.message.source_message_id ?? void 0
2008
+ );
2009
+ if (reported) watcher.reportedQuestions.add(q.id);
2010
+ }
2011
+ let permissions = [];
2012
+ try {
2013
+ const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2014
+ if (res.ok) {
2015
+ const body = await res.json();
2016
+ permissions = Array.isArray(body) ? body : [];
2017
+ }
2018
+ } catch {
2019
+ }
2020
+ for (const p of permissions) {
2021
+ if (p.sessionID !== sessionId) continue;
2022
+ if (watcher.reportedPermissions.has(p.id)) continue;
2023
+ const paused = this.attributeInteraction(watcher, p.messageID, messages);
2024
+ const reported = await this.reportInteraction(
2025
+ watcher.conv.id,
2026
+ "permission",
2027
+ p,
2028
+ paused?.message.source_message_id ?? void 0
2029
+ );
2030
+ if (reported) watcher.reportedPermissions.add(p.id);
2031
+ }
2032
+ }
2033
+ /**
2034
+ * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2035
+ *
2036
+ * The interaction carries `interactionMessageId` — the ASSISTANT message id
2037
+ * that raised it (a question's `tool.messageID` / a permission's `messageID`).
2038
+ * That assistant message is the reply to ONE of our minted user messages
2039
+ * (correlated by `parentID`, GATE-B). So when we have the tick's message
2040
+ * snapshot, we resolve each running in-flight message's correlated assistant
2041
+ * reply (`findAssistantReplyAfter`) and match its id against
2042
+ * `interactionMessageId` — giving an EXACT attribution even with several
2043
+ * messages in flight concurrently in one session.
2044
+ *
2045
+ * We fall back to the oldest running message ONLY when no exact match is
2046
+ * possible (the id is absent, the snapshot is missing, or the reply has not yet
2047
+ * been correlated). With a single running message either path is exact. Never
2048
+ * throws.
2049
+ *
2050
+ * Attribution must NOT depend on our own `started` PATCH flag: opencode can
2051
+ * START a turn AND raise a question/permission BEFORE our next tick fires
2052
+ * `markProcessing` (which sets `started`). Relying on `started` would leave the
2053
+ * running set empty in that window and let the server fall back to "newest
2054
+ * processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
2055
+ * person whose active turn actually paused. So we derive "running" from the
2056
+ * tick's `messages` snapshot via `messageRunState` instead.
2057
+ */
2058
+ attributeInteraction(watcher, interactionMessageId, messages) {
2059
+ const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
2060
+ if (inFlight.length === 0) return void 0;
2061
+ if (interactionMessageId && messages) {
2062
+ const exact = inFlight.find((m) => {
2063
+ const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
2064
+ return reply != null && messageIdOf(reply) === interactionMessageId;
2065
+ });
2066
+ if (exact) return exact;
2067
+ }
2068
+ const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
2069
+ if (messages) {
2070
+ const runningPerSnapshot = inFlight.filter(
2071
+ (m) => messageRunState(messages, m.opencodeMessageId) === "running"
2072
+ );
2073
+ if (runningPerSnapshot.length > 0) {
2074
+ return runningPerSnapshot.sort(byOldest)[0];
2075
+ }
2076
+ }
2077
+ const startedRunning = inFlight.filter((m) => m.started);
2078
+ if (startedRunning.length > 0) {
2079
+ return startedRunning.sort(byOldest)[0];
2080
+ }
2081
+ return inFlight.sort(byOldest)[0];
1568
2082
  }
1569
2083
  // -------------------------------------------------------------------------
1570
2084
  // Evident API calls (combinedAuth thread routes)
@@ -1598,36 +2112,86 @@ var ChannelDriver = class {
1598
2112
  }
1599
2113
  return await res.json();
1600
2114
  }
1601
- async markProcessing(conversationId, messageId) {
2115
+ /**
2116
+ * EXISTING combinedAuth route — now fired by the watcher on queued→running
2117
+ * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
2118
+ * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
2119
+ * deep-linked "View in Evident" notice).
2120
+ *
2121
+ * Return/throw contract (consumed by the watcher's swap-to-running guard):
2122
+ * - returns `true` → the server transitioned the row to processing;
2123
+ * - returns `false` → the server gave a DEFINITIVE "already-processing"
2124
+ * answer (a non-retryable, non-auth status — e.g. a
2125
+ * conflict because a duplicate already transitioned it),
2126
+ * so the caller treats it as already-started and does NOT
2127
+ * retry;
2128
+ * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
2129
+ * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
2130
+ * network-level error from `fetch`) — i.e. NO definitive server response —
2131
+ * so the caller leaves the message un-started and retries the swap on the
2132
+ * next tick.
2133
+ * A single attempt (no internal retry): the watcher's per-tick loop is the
2134
+ * retry vehicle for the swap-to-running.
2135
+ */
2136
+ async markProcessing(conversationId, messageId, sessionId) {
1602
2137
  const res = await this.fetchImpl(
1603
2138
  `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1604
2139
  {
1605
2140
  method: "PATCH",
1606
2141
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1607
- body: JSON.stringify({ status: "processing" })
2142
+ body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
1608
2143
  }
1609
2144
  );
1610
2145
  this.assertAuth(res, "marking message as processing");
1611
- return res.ok;
2146
+ if (res.ok) return true;
2147
+ if (isRetryableStatus(res.status)) {
2148
+ throw new Error(`marking message as processing: HTTP ${res.status}`);
2149
+ }
2150
+ return false;
1612
2151
  }
1613
2152
  /**
1614
- * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1615
- * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
2153
+ * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2154
+ * .../messages/:id {status:'done', opencode_session_id}`. The server's
1616
2155
  * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1617
- * for an already-`done` message a no-op (no double Slack post).
2156
+ * for an already-`done` message a no-op (no double Slack post). Fired by the
2157
+ * watcher on per-message completion (Task 3.4) — no `confirmCompletion`
2158
+ * round-trip (we already observed completion via the message list).
2159
+ *
2160
+ * SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
2161
+ * services its in-flight messages SEQUENTIALLY within a tick
2162
+ * (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
2163
+ * backoff here would BLOCK sibling messages in the SAME session/tick: while
2164
+ * message A's done PATCH burned its internal retries, message B could not be
2165
+ * swapped to running even though opencode had already started it. Instead this
2166
+ * does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
2167
+ * handler already relies on, leaning on the per-tick retry across ticks
2168
+ * (bounded by `inFlight.deadline`) rather than an in-call retry:
2169
+ * - resolves (`void`) → the server transitioned the row to done
2170
+ * (or idempotently confirmed already-done);
2171
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
2172
+ * cleanup, Finding 1);
2173
+ * - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
2174
+ * succeed → straight to the cron, Finding 4);
2175
+ * - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
2176
+ * (no definitive server response → the
2177
+ * watcher retries next tick within the
2178
+ * deadline, Finding 4).
1618
2179
  */
1619
2180
  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
- )
2181
+ const res = await this.fetchImpl(
2182
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2183
+ {
2184
+ method: "PATCH",
2185
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2186
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
2187
+ }
1630
2188
  );
2189
+ this.assertAuth(res, "marking message as done");
2190
+ if (res.ok) return;
2191
+ if (isRetryableStatus(res.status)) {
2192
+ throw new Error(`marking message as done: HTTP ${res.status}`);
2193
+ }
2194
+ throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
1631
2195
  }
1632
2196
  async markFailed(conversationId, messageId) {
1633
2197
  await this.callWithRetry(
@@ -1655,10 +2219,17 @@ var ChannelDriver = class {
1655
2219
  }
1656
2220
  /**
1657
2221
  * 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.
2222
+ * `POST .../interactive-event {type, data, source_message_id?}`. The server
2223
+ * persists the interaction and posts a link to the proxied opencode-web
2224
+ * conversation, @mentioning the user who triggered THIS message's turn.
2225
+ *
2226
+ * WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
2227
+ * ts (`message.source_message_id`). The server resolves the @mention from that
2228
+ * message's user FIRST (falling back to the old "newest processing" precedence
2229
+ * only when absent), so the correct person is mentioned under concurrency. It
2230
+ * is OPTIONAL for back-compat with older clients / legacy rows.
1660
2231
  */
1661
- async reportInteraction(conversationId, type, data) {
2232
+ async reportInteraction(conversationId, type, data, sourceMessageId) {
1662
2233
  try {
1663
2234
  await this.callWithRetry(
1664
2235
  "reporting interactive event",
@@ -1667,7 +2238,9 @@ var ChannelDriver = class {
1667
2238
  {
1668
2239
  method: "POST",
1669
2240
  headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1670
- body: JSON.stringify({ type, data })
2241
+ body: JSON.stringify(
2242
+ sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2243
+ )
1671
2244
  }
1672
2245
  )
1673
2246
  );
@@ -1676,6 +2249,7 @@ var ChannelDriver = class {
1676
2249
  message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1677
2250
  conversation_id: conversationId
1678
2251
  });
2252
+ return true;
1679
2253
  } catch (err) {
1680
2254
  if (err instanceof ChannelAuthError) throw err;
1681
2255
  this.log({
@@ -1683,6 +2257,7 @@ var ChannelDriver = class {
1683
2257
  message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1684
2258
  conversation_id: conversationId
1685
2259
  });
2260
+ return false;
1686
2261
  }
1687
2262
  }
1688
2263
  // -------------------------------------------------------------------------
@@ -1721,8 +2296,9 @@ var ChannelDriver = class {
1721
2296
  await this.sleep(backoffDelay(attempt, this.retry));
1722
2297
  continue;
1723
2298
  }
2299
+ break;
1724
2300
  }
1725
- throw new Error(`${context}: HTTP ${res.status}`);
2301
+ throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
1726
2302
  }
1727
2303
  throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1728
2304
  }
@@ -1848,23 +2424,45 @@ Port ${port} is already in use.`));
1848
2424
  spinner.fail("Failed to start OpenCode");
1849
2425
  throw new Error("OpenCode failed to start");
1850
2426
  }
1851
- spinner.succeed(
1852
- `OpenCode running on port ${port}${health.version ? ` (v${health.version})` : ""}`
1853
- );
2427
+ spinner.stop();
1854
2428
  return { port, process: proc, version: health.version ?? null };
1855
2429
  }
1856
2430
  return { port, process: null, version: null };
1857
2431
  }
1858
2432
 
1859
2433
  // src/commands/agent-lookup.ts
2434
+ async function readErrorMessage(response) {
2435
+ const text = await response.text().catch(() => "");
2436
+ if (!text) return response.statusText || void 0;
2437
+ try {
2438
+ const data = JSON.parse(text);
2439
+ const message = data.message ?? data.error;
2440
+ if (typeof message === "string" && message.trim()) {
2441
+ return message;
2442
+ }
2443
+ } catch {
2444
+ }
2445
+ return text.trim() || response.statusText || void 0;
2446
+ }
2447
+ function authFailureHint(apiUrl, serverMessage) {
2448
+ const reason = serverMessage ? `: ${serverMessage}` : "";
2449
+ 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.`;
2450
+ }
1860
2451
  async function resolveAgentIdFromKey(authHeader) {
1861
2452
  const apiUrl = getApiUrlConfig();
1862
2453
  try {
1863
2454
  const response = await fetch(`${apiUrl}/me`, {
1864
2455
  headers: { Authorization: authHeader }
1865
2456
  });
2457
+ if (response.status === 401) {
2458
+ const serverMessage = await readErrorMessage(response);
2459
+ return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2460
+ }
1866
2461
  if (!response.ok) {
1867
- return { error: `Failed to resolve agent from key: HTTP ${response.status}` };
2462
+ const serverMessage = await readErrorMessage(response);
2463
+ return {
2464
+ error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2465
+ };
1868
2466
  }
1869
2467
  const data = await response.json();
1870
2468
  if (data.auth_type === "agent_key" && data.agent_id) {
@@ -1884,14 +2482,27 @@ async function getAgentInfo(agentId, authHeader) {
1884
2482
  const response = await fetch(`${apiUrl}/agents/${agentId}`, {
1885
2483
  headers: { Authorization: authHeader }
1886
2484
  });
1887
- if (response.status === 404) {
1888
- return { valid: false, error: "Agent not found" };
1889
- }
1890
2485
  if (response.status === 401) {
1891
- return { valid: false, error: "Authentication failed", authFailed: true };
2486
+ const serverMessage = await readErrorMessage(response);
2487
+ return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2488
+ }
2489
+ if (response.status === 403) {
2490
+ const serverMessage = await readErrorMessage(response);
2491
+ return {
2492
+ valid: false,
2493
+ error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
2494
+ };
2495
+ }
2496
+ if (response.status === 404) {
2497
+ const serverMessage = await readErrorMessage(response);
2498
+ return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
1892
2499
  }
1893
2500
  if (!response.ok) {
1894
- return { valid: false, error: `API error: ${response.status}` };
2501
+ const serverMessage = await readErrorMessage(response);
2502
+ return {
2503
+ valid: false,
2504
+ error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2505
+ };
1895
2506
  }
1896
2507
  const agent = await response.json();
1897
2508
  if (agent.agent_type !== "local") {
@@ -2032,9 +2643,9 @@ async function driveChannels(state, driver) {
2032
2643
  try {
2033
2644
  const processed = await driver.drainPending();
2034
2645
  state.messageCount += processed;
2035
- if (processed > 0) {
2646
+ if (processed > 0 || driver.hasInFlightWatchers()) {
2036
2647
  idlePolls = 0;
2037
- if (state.interactive) displayStatus(state);
2648
+ if (processed > 0 && state.interactive) displayStatus(state);
2038
2649
  } else if (state.idleTimeout !== null) {
2039
2650
  idlePolls++;
2040
2651
  if (idlePolls === 1) {
@@ -2225,6 +2836,13 @@ async function run(options) {
2225
2836
  state.opencodeConnected = oc.process !== null || oc.version !== null;
2226
2837
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2227
2838
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2839
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
2840
+ if (versionWarning) {
2841
+ log(state, versionWarning, false);
2842
+ if (state.interactive && !state.json) {
2843
+ logActivity(state, { type: "info", message: versionWarning });
2844
+ }
2845
+ }
2228
2846
  } catch (error2) {
2229
2847
  ocSpinner?.fail(error2.message);
2230
2848
  throw error2;
@@ -2258,7 +2876,22 @@ async function run(options) {
2258
2876
  emitAgentConnected(state.agentId, { port: state.port });
2259
2877
  if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2260
2878
  if (state.interactive) displayStatus(state);
2261
- channelDriver.drainPending().catch(() => {
2879
+ channelDriver.drainPending().then((processed) => {
2880
+ if (processed > 0) {
2881
+ state.messageCount += processed;
2882
+ logActivity(state, {
2883
+ type: "info",
2884
+ message: `Drained ${processed} queued message(s) on connect`
2885
+ });
2886
+ if (state.interactive) displayStatus(state);
2887
+ }
2888
+ }).catch((error2) => {
2889
+ const message = error2 instanceof Error ? error2.message : String(error2);
2890
+ logActivity(state, {
2891
+ type: "error",
2892
+ error: `Failed to drain queued messages on connect: ${message}`
2893
+ });
2894
+ if (state.interactive) displayStatus(state);
2262
2895
  });
2263
2896
  },
2264
2897
  onDisconnected: (code, reason) => {
@@ -2278,6 +2911,32 @@ async function run(options) {
2278
2911
  onResponse: () => {
2279
2912
  state.opencodeConnected = true;
2280
2913
  },
2914
+ // A channel message was queued and the api-worker pinged us over the
2915
+ // tunnel to drain immediately instead of waiting for the next poll tick.
2916
+ // Best-effort + non-fatal: mirror the on-connect drain block. A failed
2917
+ // drain here is logged and swallowed — the steady-state poll retries, so
2918
+ // a lost/failed ping can never orphan a message (§2 invariant).
2919
+ onDrainPing: () => {
2920
+ if (!state.running) return;
2921
+ logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
2922
+ channelDriver.drainPending().then((processed) => {
2923
+ if (processed > 0) {
2924
+ state.messageCount += processed;
2925
+ logActivity(state, {
2926
+ type: "info",
2927
+ message: `Drained ${processed} queued message(s) on ping`
2928
+ });
2929
+ if (state.interactive) displayStatus(state);
2930
+ }
2931
+ }).catch((error2) => {
2932
+ const message = error2 instanceof Error ? error2.message : String(error2);
2933
+ logActivity(state, {
2934
+ type: "error",
2935
+ error: `Failed to drain queued messages on ping: ${message}`
2936
+ });
2937
+ if (state.interactive) displayStatus(state);
2938
+ });
2939
+ },
2281
2940
  onInfo: (message) => logActivity(state, { type: "info", message })
2282
2941
  }
2283
2942
  });
@@ -2288,9 +2947,7 @@ async function run(options) {
2288
2947
  if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2289
2948
  throw error2;
2290
2949
  }
2291
- if (interactive && !state.json) {
2292
- displayStatus(state);
2293
- } else {
2950
+ if (!interactive || state.json) {
2294
2951
  log(state, "Driving channel messages...");
2295
2952
  }
2296
2953
  await driveChannels(state, channelDriver);
@@ -2339,7 +2996,7 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
2339
2996
  }
2340
2997
  });
2341
2998
  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);
2999
+ 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
3000
  program.command("whoami").description("Show the currently logged in user").action(whoami);
2344
3001
  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
3002
  (options) => {