@evident-ai/cli 0.2.1-dev.fab83f9 → 3.0.1-dev.80af045

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
@@ -12,41 +12,30 @@ import chalk2 from "chalk";
12
12
  import Conf from "conf";
13
13
  import { homedir } from "os";
14
14
  import { join } from "path";
15
- var environmentPresets = {
16
- local: {
17
- apiUrl: "http://localhost:3001/v1",
18
- tunnelUrl: "ws://localhost:8787"
19
- },
20
- dev: {
21
- apiUrl: "https://api.dev.evident.run/v1",
22
- tunnelUrl: "wss://tunnel.dev.evident.run"
23
- },
24
- production: {
25
- // Production URLs also have aliases: api.evident.run, tunnel.evident.run
26
- apiUrl: "https://api.production.evident.run/v1",
27
- tunnelUrl: "wss://tunnel.production.evident.run"
28
- }
15
+ var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
16
+ var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
17
+ var defaults = {
18
+ apiUrl: PRODUCTION_API_URL,
19
+ tunnelUrl: PRODUCTION_TUNNEL_URL
29
20
  };
30
- var defaults = environmentPresets.production;
31
- var currentEnvironment = "production";
32
- function setEnvironment(env) {
33
- currentEnvironment = env;
34
- }
35
- function getEnvironment() {
36
- const envVar = process.env.EVIDENT_ENV;
37
- if (envVar && environmentPresets[envVar]) {
38
- return envVar;
21
+ var endpointOverride;
22
+ var tunnelOverride;
23
+ function setEndpoint(url) {
24
+ if (!url) {
25
+ endpointOverride = void 0;
26
+ return;
39
27
  }
40
- return currentEnvironment;
28
+ const trimmed = url.replace(/\/+$/, "");
29
+ endpointOverride = /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
41
30
  }
42
- function getEnvConfig() {
43
- return environmentPresets[getEnvironment()];
31
+ function setTunnelUrl(url) {
32
+ tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
44
33
  }
45
34
  function getApiUrl() {
46
- return process.env.EVIDENT_API_URL ?? getEnvConfig().apiUrl;
35
+ return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
47
36
  }
48
37
  function getTunnelUrl() {
49
- return process.env.EVIDENT_TUNNEL_URL ?? getEnvConfig().tunnelUrl;
38
+ return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
50
39
  }
51
40
  var config = new Conf({
52
41
  projectName: "evident",
@@ -65,19 +54,28 @@ function getApiUrlConfig() {
65
54
  function getTunnelUrlConfig() {
66
55
  return getTunnelUrl();
67
56
  }
57
+ function credentialsKey() {
58
+ return getApiUrl();
59
+ }
68
60
  function getCredentials() {
69
- return {
70
- token: credentials.get("token"),
71
- user: credentials.get("user"),
72
- expiresAt: credentials.get("expiresAt")
73
- };
61
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
62
+ return byEndpoint[credentialsKey()] ?? {};
74
63
  }
75
64
  function setCredentials(creds) {
76
- if (creds.token) credentials.set("token", creds.token);
77
- if (creds.user) credentials.set("user", creds.user);
78
- 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);
79
72
  }
80
73
  function clearCredentials() {
74
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
75
+ delete byEndpoint[credentialsKey()];
76
+ credentials.set("byEndpoint", byEndpoint);
77
+ }
78
+ function clearAllCredentials() {
81
79
  credentials.clear();
82
80
  }
83
81
  function getCliName() {
@@ -187,7 +185,6 @@ var api = {
187
185
 
188
186
  // src/lib/keychain.ts
189
187
  var SERVICE_NAME = "evident-cli";
190
- var ACCOUNT_NAME = "default";
191
188
  async function getKeytar() {
192
189
  try {
193
190
  const keytar = await import("keytar");
@@ -199,10 +196,13 @@ async function getKeytar() {
199
196
  return null;
200
197
  }
201
198
  }
199
+ function keychainAccount() {
200
+ return getApiUrlConfig();
201
+ }
202
202
  async function storeToken(credentials2) {
203
203
  const keytar = await getKeytar();
204
204
  if (keytar) {
205
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
205
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
206
206
  } else {
207
207
  setCredentials({
208
208
  token: credentials2.token,
@@ -214,12 +214,13 @@ async function storeToken(credentials2) {
214
214
  async function getToken() {
215
215
  const keytar = await getKeytar();
216
216
  if (keytar) {
217
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
217
+ const account = keychainAccount();
218
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
218
219
  if (stored) {
219
220
  try {
220
221
  return JSON.parse(stored);
221
222
  } catch {
222
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
223
+ await keytar.deletePassword(SERVICE_NAME, account);
223
224
  return null;
224
225
  }
225
226
  }
@@ -234,12 +235,26 @@ async function getToken() {
234
235
  }
235
236
  return null;
236
237
  }
237
- async function deleteToken() {
238
+ async function deleteToken(options = {}) {
238
239
  const keytar = await getKeytar();
239
240
  if (keytar) {
240
- 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();
241
257
  }
242
- clearCredentials();
243
258
  }
244
259
 
245
260
  // src/utils/ui.ts
@@ -407,25 +422,32 @@ async function login(options) {
407
422
  }
408
423
 
409
424
  // src/commands/logout.ts
410
- 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
+ }
411
431
  const credentials2 = await getToken();
412
432
  if (!credentials2) {
413
- printWarning("You are not logged in.");
433
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
414
434
  return;
415
435
  }
416
436
  await deleteToken();
417
- printSuccess("Logged out successfully.");
437
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
418
438
  }
419
439
 
420
440
  // src/commands/whoami.ts
421
441
  import chalk3 from "chalk";
422
442
  async function whoami() {
443
+ const apiUrl = getApiUrlConfig();
423
444
  const credentials2 = await getToken();
424
445
  if (!credentials2) {
425
- printError("Not logged in. Run the `login` command to authenticate.");
446
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
426
447
  process.exit(1);
427
448
  }
428
449
  blank();
450
+ console.log(keyValue("Endpoint", apiUrl));
429
451
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
430
452
  console.log(keyValue("User ID", credentials2.user.id));
431
453
  if (credentials2.expiresAt) {
@@ -444,9 +466,9 @@ async function whoami() {
444
466
  }
445
467
 
446
468
  // src/commands/run.ts
447
- import chalk5 from "chalk";
448
- import ora2 from "ora";
449
- import { select as select2 } from "@inquirer/prompts";
469
+ import chalk6 from "chalk";
470
+ import ora3 from "ora";
471
+ import { select as select3 } from "@inquirer/prompts";
450
472
 
451
473
  // ../../packages/types/src/telemetry/index.ts
452
474
  var TelemetryEventTypes = {
@@ -459,10 +481,8 @@ var TelemetryEventTypes = {
459
481
  };
460
482
 
461
483
  // ../../packages/types/src/tunnel/index.ts
462
- var TUNNEL_CHUNK_THRESHOLD = 512 * 1024;
463
- var TUNNEL_CHUNK_SIZE = 768 * 1024;
464
- var TUNNEL_MAX_RESPONSE_SIZE = 50 * 1024 * 1024;
465
- var TUNNEL_CHUNK_TIMEOUT_MS = 30 * 1e3;
484
+ var MAX_FRAME_BYTES = 256 * 1024;
485
+ var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
466
486
 
467
487
  // src/lib/telemetry.ts
468
488
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -574,33 +594,6 @@ function emitAgentDisconnected(agentId, metadata) {
574
594
  agent_id: agentId
575
595
  });
576
596
  }
577
- function emitAgentMessageProcessing(agentId, metadata) {
578
- emitEvent({
579
- event_type: TelemetryEventTypes.AGENT_MESSAGE_PROCESSING,
580
- severity: "info",
581
- message: `Processing message ${metadata.message_id.slice(0, 8)}...`,
582
- metadata,
583
- agent_id: agentId
584
- });
585
- }
586
- function emitAgentMessageDone(agentId, metadata) {
587
- emitEvent({
588
- event_type: TelemetryEventTypes.AGENT_MESSAGE_DONE,
589
- severity: "info",
590
- message: `Message ${metadata.message_id.slice(0, 8)} processed`,
591
- metadata,
592
- agent_id: agentId
593
- });
594
- }
595
- function emitAgentMessageFailed(agentId, metadata) {
596
- emitEvent({
597
- event_type: TelemetryEventTypes.AGENT_MESSAGE_FAILED,
598
- severity: "error",
599
- message: metadata.error ? `Message ${metadata.message_id.slice(0, 8)} failed: ${metadata.error}` : `Message ${metadata.message_id.slice(0, 8)} ${metadata.reason || "failed"}`,
600
- metadata,
601
- agent_id: agentId
602
- });
603
- }
604
597
  var EventTypes = {
605
598
  // Tunnel lifecycle
606
599
  TUNNEL_STARTING: "tunnel.starting",
@@ -665,7 +658,7 @@ function isInteractive(jsonOutput) {
665
658
  // src/lib/opencode/health.ts
666
659
  async function checkOpenCodeHealth(port) {
667
660
  try {
668
- const response = await fetch(`http://localhost:${port}/global/health`, {
661
+ const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
669
662
  signal: AbortSignal.timeout(2e3)
670
663
  // 2 second timeout
671
664
  });
@@ -844,12 +837,12 @@ async function findHealthyOpenCodeInstances() {
844
837
  }
845
838
  async function startOpenCode(port) {
846
839
  let command = "opencode";
847
- let args = ["serve", "--port", port.toString()];
840
+ let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
848
841
  try {
849
842
  execSync("which opencode", { stdio: "ignore" });
850
843
  } catch {
851
844
  command = "npx";
852
- args = ["opencode", "serve", "--port", port.toString()];
845
+ args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
853
846
  }
854
847
  const child = spawn(command, args, {
855
848
  detached: true,
@@ -981,14 +974,59 @@ async function promptOpenCodeInstall(interactive) {
981
974
  }
982
975
 
983
976
  // src/lib/opencode/session.ts
984
- async function createOpenCodeSession(port) {
985
- const response = await fetch(`http://localhost:${port}/session`, {
977
+ function opencodeBase(port) {
978
+ return `http://127.0.0.1:${port}`;
979
+ }
980
+ async function getOpenCodeDirectory(port) {
981
+ try {
982
+ const res = await fetch(`${opencodeBase(port)}/path`);
983
+ if (!res.ok) return null;
984
+ const body = await res.json();
985
+ 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;
986
+ return dir && dir.trim() ? dir.trim() : null;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
991
+ function roleOf(m) {
992
+ if (!m || typeof m !== "object") return void 0;
993
+ if (typeof m.role === "string") return m.role;
994
+ const infoRole = m.info?.role;
995
+ return typeof infoRole === "string" ? infoRole : void 0;
996
+ }
997
+ function completedOf(m) {
998
+ if (!m || typeof m !== "object") return void 0;
999
+ return m.info?.time?.completed;
1000
+ }
1001
+ async function getSessionMessages(port, sessionId) {
1002
+ try {
1003
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
1004
+ if (!res.ok) return null;
1005
+ const body = await res.json();
1006
+ return Array.isArray(body) ? body : null;
1007
+ } catch {
1008
+ return null;
1009
+ }
1010
+ }
1011
+ function isTurnComplete(messages) {
1012
+ if (!messages || messages.length === 0) return false;
1013
+ const last = messages[messages.length - 1];
1014
+ if (roleOf(last) !== "assistant") return false;
1015
+ return completedOf(last) != null;
1016
+ }
1017
+ async function createOpenCodeSession(port, directory) {
1018
+ const url = new URL(`${opencodeBase(port)}/session`);
1019
+ if (directory && directory.trim()) {
1020
+ url.searchParams.set("directory", directory.trim());
1021
+ }
1022
+ const response = await fetch(url, {
986
1023
  method: "POST",
987
1024
  headers: { "Content-Type": "application/json" },
988
1025
  body: JSON.stringify({})
989
1026
  });
990
1027
  if (!response.ok) {
991
- throw new Error(`Failed to create session: HTTP ${response.status}`);
1028
+ const text = await response.text().catch(() => "");
1029
+ throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
992
1030
  }
993
1031
  const data = await response.json();
994
1032
  return data.id;
@@ -1018,7 +1056,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1018
1056
  if (pollDone) break;
1019
1057
  if (hooks?.onQuestion) {
1020
1058
  try {
1021
- const res = await fetch(`http://localhost:${port}/question`);
1059
+ const res = await fetch(`${opencodeBase(port)}/question`);
1022
1060
  if (res.ok) {
1023
1061
  const questions = await res.json();
1024
1062
  for (const q of questions) {
@@ -1033,7 +1071,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1033
1071
  }
1034
1072
  if (hooks?.onPermission) {
1035
1073
  try {
1036
- const res = await fetch(`http://localhost:${port}/permission`);
1074
+ const res = await fetch(`${opencodeBase(port)}/permission`);
1037
1075
  if (res.ok) {
1038
1076
  const permissions = await res.json();
1039
1077
  for (const p of permissions) {
@@ -1052,7 +1090,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1052
1090
  const controller = new AbortController();
1053
1091
  const timer = setTimeout(() => controller.abort(), maxWaitMs);
1054
1092
  try {
1055
- const res = await fetch(`http://localhost:${port}/session/${sessionId}/message`, {
1093
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
1056
1094
  method: "POST",
1057
1095
  headers: { "Content-Type": "application/json" },
1058
1096
  body: JSON.stringify(body),
@@ -1062,11 +1100,14 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1062
1100
  const text = await res.text().catch(() => "");
1063
1101
  throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1064
1102
  }
1065
- const sessionRes = await fetch(`http://localhost:${port}/session/${sessionId}`).catch(
1103
+ const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
1066
1104
  () => null
1067
1105
  );
1068
1106
  const session = sessionRes?.ok ? await sessionRes.json() : null;
1069
- return { title: session?.title };
1107
+ const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
1108
+ const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
1109
+ const awaitingInteraction = reportedInteraction && !turnComplete;
1110
+ return { title: session?.title, awaitingInteraction };
1070
1111
  } catch (err) {
1071
1112
  if (err instanceof Error && err.name === "AbortError") {
1072
1113
  throw new Error("Message processing timed out");
@@ -1082,147 +1123,149 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1082
1123
  }
1083
1124
 
1084
1125
  // src/lib/tunnel/connection.ts
1085
- import WebSocket from "ws";
1126
+ import WebSocket2 from "ws";
1086
1127
 
1087
1128
  // src/lib/tunnel/forwarding.ts
1088
- var CHUNK_THRESHOLD = 512 * 1024;
1089
- var CHUNK_SIZE = 768 * 1024;
1090
- async function forwardToOpenCode(port, request) {
1091
- const url = `http://localhost:${port}${request.path}`;
1092
- try {
1093
- const response = await fetch(url, {
1094
- method: request.method,
1095
- headers: {
1096
- "Content-Type": "application/json",
1097
- ...request.headers
1098
- },
1099
- body: request.body ? JSON.stringify(request.body) : void 0
1100
- });
1101
- let body;
1102
- const contentType = response.headers.get("Content-Type");
1103
- const text = await response.text();
1104
- if (!text || text.length === 0) {
1105
- body = null;
1106
- } else if (contentType?.includes("application/json")) {
1129
+ import WebSocket from "ws";
1130
+ var LOOPBACK_HOST = "127.0.0.1";
1131
+ var STRIP_REQ = /* @__PURE__ */ new Set([
1132
+ "host",
1133
+ "connection",
1134
+ "keep-alive",
1135
+ "proxy-authorization",
1136
+ "transfer-encoding",
1137
+ "upgrade",
1138
+ "content-length"
1139
+ ]);
1140
+ var STRIP_RES = /* @__PURE__ */ new Set([
1141
+ "connection",
1142
+ "keep-alive",
1143
+ "transfer-encoding",
1144
+ "content-encoding",
1145
+ "content-length"
1146
+ ]);
1147
+ var StreamForwarder = class {
1148
+ constructor(ws, port, callbacks = {}) {
1149
+ this.ws = ws;
1150
+ this.port = port;
1151
+ this.callbacks = callbacks;
1152
+ }
1153
+ inflight = /* @__PURE__ */ new Map();
1154
+ /**
1155
+ * Handle an edge→agent frame. Unknown frame types are ignored.
1156
+ */
1157
+ handleFrame(frame) {
1158
+ switch (frame.type) {
1159
+ case "open":
1160
+ this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
1161
+ void this.handleOpen(frame);
1162
+ break;
1163
+ case "req_data":
1164
+ this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
1165
+ break;
1166
+ case "req_end":
1167
+ this.inflight.get(frame.sid)?.endBody?.();
1168
+ break;
1169
+ case "abort":
1170
+ this.inflight.get(frame.sid)?.abort?.();
1171
+ break;
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Abort every in-flight stream (e.g. on WebSocket close).
1176
+ */
1177
+ abortAll() {
1178
+ for (const stream of this.inflight.values()) {
1107
1179
  try {
1108
- body = JSON.parse(text);
1180
+ stream.abort();
1109
1181
  } catch {
1110
- body = text;
1111
1182
  }
1112
- } else {
1113
- body = text;
1114
1183
  }
1115
- return {
1116
- status: response.status,
1117
- body
1118
- };
1119
- } catch (error2) {
1120
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1121
- return {
1122
- status: 502,
1123
- body: { error: "Failed to connect to OpenCode", message }
1124
- };
1184
+ this.inflight.clear();
1125
1185
  }
1126
- }
1127
- function sendResponse(ws, requestId, response) {
1128
- const bodyStr = JSON.stringify(response.body ?? null);
1129
- const bodyBytes = Buffer.from(bodyStr, "utf-8");
1130
- if (bodyBytes.length < CHUNK_THRESHOLD) {
1131
- ws.send(
1132
- JSON.stringify({
1133
- type: "response",
1134
- id: requestId,
1135
- payload: response
1136
- })
1137
- );
1138
- return;
1186
+ send(frame) {
1187
+ if (this.ws.readyState === WebSocket.OPEN) {
1188
+ this.ws.send(JSON.stringify(frame));
1189
+ }
1139
1190
  }
1140
- sendResponseAsChunks(ws, requestId, response, bodyBytes);
1141
- }
1142
- function sendResponseAsChunks(ws, requestId, response, bodyBytes) {
1143
- const chunks = splitIntoChunks(bodyBytes, CHUNK_SIZE);
1144
- ws.send(
1145
- JSON.stringify({
1146
- type: "response_start",
1147
- id: requestId,
1148
- total_chunks: chunks.length,
1149
- total_size: bodyBytes.length,
1150
- payload: {
1151
- status: response.status,
1152
- headers: response.headers
1191
+ async handleOpen(frame) {
1192
+ const { sid, method, path, headers, has_body } = frame;
1193
+ if (path === TUNNEL_DRAIN_PING_PATH) {
1194
+ this.callbacks.onDrainPing?.();
1195
+ this.send({ type: "head", sid, status: 204, headers: {} });
1196
+ this.send({ type: "res_end", sid });
1197
+ return;
1198
+ }
1199
+ const ac = new AbortController();
1200
+ let bodyPromise;
1201
+ let pushBody;
1202
+ let endBody;
1203
+ if (has_body) {
1204
+ const chunks = [];
1205
+ bodyPromise = new Promise((resolve) => {
1206
+ pushBody = (buf) => {
1207
+ chunks.push(buf);
1208
+ };
1209
+ endBody = () => {
1210
+ resolve(Buffer.concat(chunks));
1211
+ };
1212
+ });
1213
+ }
1214
+ const fwdHeaders = {};
1215
+ for (const [k, v] of Object.entries(headers ?? {})) {
1216
+ if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
1217
+ }
1218
+ this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
1219
+ const body = bodyPromise ? await bodyPromise : void 0;
1220
+ if (ac.signal.aborted) {
1221
+ this.inflight.delete(sid);
1222
+ return;
1223
+ }
1224
+ let upstream;
1225
+ try {
1226
+ upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
1227
+ method,
1228
+ headers: fwdHeaders,
1229
+ body,
1230
+ redirect: "manual",
1231
+ signal: ac.signal
1232
+ });
1233
+ } catch (err) {
1234
+ this.inflight.delete(sid);
1235
+ if (!ac.signal.aborted) {
1236
+ this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
1153
1237
  }
1154
- })
1155
- );
1156
- for (let i = 0; i < chunks.length; i++) {
1157
- ws.send(
1158
- JSON.stringify({
1159
- type: "response_chunk",
1160
- id: requestId,
1161
- chunk_index: i,
1162
- data: chunks[i].toString("base64")
1163
- })
1164
- );
1165
- }
1166
- ws.send(
1167
- JSON.stringify({
1168
- type: "response_end",
1169
- id: requestId
1170
- })
1171
- );
1172
- }
1173
- function splitIntoChunks(data, chunkSize) {
1174
- const chunks = [];
1175
- for (let i = 0; i < data.length; i += chunkSize) {
1176
- chunks.push(data.subarray(i, i + chunkSize));
1177
- }
1178
- return chunks;
1179
- }
1180
-
1181
- // src/lib/tunnel/events.ts
1182
- async function subscribeToOpenCodeEvents(port, subscriptionId, ws, abortController) {
1183
- const url = `http://localhost:${port}/event`;
1184
- try {
1185
- const response = await fetch(url, {
1186
- headers: { Accept: "text/event-stream" },
1187
- signal: abortController.signal
1238
+ return;
1239
+ }
1240
+ const resHeaders = {};
1241
+ upstream.headers.forEach((value, key) => {
1242
+ if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1188
1243
  });
1189
- if (!response.ok) {
1190
- throw new Error(`Failed to connect to OpenCode events: ${response.status}`);
1191
- }
1192
- if (!response.body) {
1193
- throw new Error("No response body");
1194
- }
1195
- const reader = response.body.getReader();
1196
- const decoder = new TextDecoder();
1197
- let buffer = "";
1198
- while (true) {
1199
- const { done, value } = await reader.read();
1200
- if (done) {
1201
- ws.send(JSON.stringify({ type: "event_end", id: subscriptionId }));
1202
- break;
1203
- }
1204
- buffer += decoder.decode(value, { stream: true });
1205
- const lines = buffer.split("\n");
1206
- buffer = lines.pop() || "";
1207
- for (const line of lines) {
1208
- if (line.startsWith("data: ")) {
1209
- try {
1210
- const event = JSON.parse(line.slice(6));
1211
- ws.send(JSON.stringify({ type: "event", id: subscriptionId, event }));
1212
- } catch {
1244
+ this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1245
+ this.callbacks.onHead?.(sid, upstream.status);
1246
+ try {
1247
+ if (upstream.body) {
1248
+ const reader = upstream.body.getReader();
1249
+ while (true) {
1250
+ const { done, value } = await reader.read();
1251
+ if (done) break;
1252
+ const chunk = Buffer.from(value);
1253
+ for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
1254
+ const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
1255
+ this.send({ type: "res_data", sid, b64: slice.toString("base64") });
1213
1256
  }
1214
1257
  }
1215
1258
  }
1259
+ this.send({ type: "res_end", sid });
1260
+ } catch (err) {
1261
+ if (!ac.signal.aborted) {
1262
+ this.send({ type: "res_err", sid, message: String(err) });
1263
+ }
1264
+ } finally {
1265
+ this.inflight.delete(sid);
1216
1266
  }
1217
- } catch (error2) {
1218
- if (abortController.signal.aborted) {
1219
- return;
1220
- }
1221
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1222
- ws.send(JSON.stringify({ type: "event_error", id: subscriptionId, error: message }));
1223
- throw error2;
1224
1267
  }
1225
- }
1268
+ };
1226
1269
 
1227
1270
  // src/lib/tunnel/connection.ts
1228
1271
  var MAX_RECONNECT_DELAY = 3e4;
@@ -1232,6 +1275,33 @@ function getReconnectDelay(attempt) {
1232
1275
  const jitter = Math.random() * 1e3;
1233
1276
  return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
1234
1277
  }
1278
+ function describeSocketError(error2, url) {
1279
+ const code = error2.code;
1280
+ switch (code) {
1281
+ case "ECONNREFUSED":
1282
+ return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
1283
+ case "ENOTFOUND":
1284
+ return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
1285
+ case "ETIMEDOUT":
1286
+ return `connection timed out to ${url} (ETIMEDOUT)`;
1287
+ case "ECONNRESET":
1288
+ return `connection reset by ${url} (ECONNRESET)`;
1289
+ default: {
1290
+ const base = error2.message?.trim();
1291
+ const suffix = code ? ` (${code})` : "";
1292
+ return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
1293
+ }
1294
+ }
1295
+ }
1296
+ var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
1297
+ "open",
1298
+ "req_data",
1299
+ "req_end",
1300
+ "abort"
1301
+ ]);
1302
+ function isStreamFrame(message) {
1303
+ return STREAM_FRAME_TYPES.has(message.type);
1304
+ }
1235
1305
  function connectTunnel(options) {
1236
1306
  const {
1237
1307
  agentId,
@@ -1242,330 +1312,890 @@ function connectTunnel(options) {
1242
1312
  onError,
1243
1313
  onRequest,
1244
1314
  onResponse,
1245
- onInfo
1315
+ onInfo,
1316
+ onDrainPing
1246
1317
  } = options;
1247
1318
  const tunnelUrl = getTunnelUrlConfig();
1248
1319
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1249
- const activeEventSubscriptions = /* @__PURE__ */ new Map();
1250
1320
  return new Promise((resolve, reject) => {
1251
- const ws = new WebSocket(url, {
1321
+ const ws = new WebSocket2(url, {
1252
1322
  headers: {
1253
1323
  Authorization: authHeader
1254
1324
  }
1255
1325
  });
1326
+ const streamStartTimes = /* @__PURE__ */ new Map();
1327
+ const forwarder = new StreamForwarder(ws, port, {
1328
+ onOpen: (sid, method, path) => {
1329
+ if (path === TUNNEL_DRAIN_PING_PATH) return;
1330
+ streamStartTimes.set(sid, Date.now());
1331
+ onRequest?.(method, path, sid);
1332
+ },
1333
+ onHead: (sid, status) => {
1334
+ const startedAt = streamStartTimes.get(sid);
1335
+ streamStartTimes.delete(sid);
1336
+ onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1337
+ },
1338
+ onDrainPing: () => onDrainPing?.()
1339
+ });
1256
1340
  const connectionTimeout = setTimeout(() => {
1257
1341
  ws.close();
1258
1342
  reject(new Error("Connection timeout"));
1259
1343
  }, 3e4);
1344
+ let upgradeRejection = null;
1345
+ ws.on("unexpected-response", (_req, res) => {
1346
+ clearTimeout(connectionTimeout);
1347
+ const chunks = [];
1348
+ res.on("data", (chunk) => chunks.push(chunk));
1349
+ res.on("end", () => {
1350
+ const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
1351
+ let detail = bodyRaw;
1352
+ try {
1353
+ const parsed = JSON.parse(bodyRaw);
1354
+ detail = parsed.error ?? parsed.message ?? bodyRaw;
1355
+ if (parsed.details) detail += ` (${parsed.details})`;
1356
+ } catch {
1357
+ }
1358
+ const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
1359
+ upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
1360
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
1361
+ reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
1362
+ });
1363
+ });
1260
1364
  ws.on("open", () => {
1261
1365
  onInfo?.("WebSocket connection established");
1262
1366
  });
1263
- ws.on("message", async (data) => {
1367
+ ws.on("message", (data) => {
1368
+ let message;
1264
1369
  try {
1265
- const message = JSON.parse(data.toString());
1266
- switch (message.type) {
1267
- case "connected": {
1268
- clearTimeout(connectionTimeout);
1269
- const connectedAgentId = message.agent_id ?? agentId;
1270
- onConnected?.(connectedAgentId);
1271
- resolve({
1272
- ws,
1273
- close: () => ws.close(1e3, "CLI shutdown"),
1274
- activeEventSubscriptions
1275
- });
1276
- break;
1277
- }
1278
- case "error":
1279
- clearTimeout(connectionTimeout);
1280
- onError?.(message.message || "Unknown tunnel error");
1281
- if (message.code === "unauthorized") {
1282
- ws.close();
1283
- reject(new Error("Unauthorized"));
1284
- }
1285
- break;
1286
- case "ping":
1287
- ws.send(JSON.stringify({ type: "pong" }));
1288
- break;
1289
- case "request":
1290
- if (message.id && message.payload) {
1291
- const startTime = Date.now();
1292
- onRequest?.(message.payload.method, message.payload.path, message.id);
1293
- const response = await forwardToOpenCode(port, message.payload);
1294
- const durationMs = Date.now() - startTime;
1295
- onResponse?.(response.status, durationMs, message.id);
1296
- sendResponse(ws, message.id, response);
1297
- }
1298
- break;
1299
- case "subscribe_events":
1300
- if (message.id) {
1301
- const abortController = new AbortController();
1302
- activeEventSubscriptions.set(message.id, abortController);
1303
- onInfo?.(`Starting event subscription ${message.id.slice(0, 8)}`);
1304
- subscribeToOpenCodeEvents(port, message.id, ws, abortController).catch((error2) => {
1305
- if (!abortController.signal.aborted) {
1306
- onError?.(`Event subscription failed: ${error2.message}`);
1307
- }
1308
- }).finally(() => {
1309
- activeEventSubscriptions.delete(message.id);
1310
- });
1311
- }
1312
- break;
1313
- case "unsubscribe_events":
1314
- if (message.id) {
1315
- const controller = activeEventSubscriptions.get(message.id);
1316
- if (controller) {
1317
- controller.abort();
1318
- activeEventSubscriptions.delete(message.id);
1319
- }
1320
- }
1321
- break;
1322
- }
1370
+ message = JSON.parse(data.toString());
1323
1371
  } catch (error2) {
1324
1372
  const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1325
1373
  onError?.(`Failed to handle message: ${errorMessage}`);
1374
+ return;
1375
+ }
1376
+ if (isStreamFrame(message)) {
1377
+ forwarder.handleFrame(message);
1378
+ return;
1379
+ }
1380
+ switch (message.type) {
1381
+ case "connected": {
1382
+ clearTimeout(connectionTimeout);
1383
+ const connectedAgentId = message.agent_id ?? agentId;
1384
+ onConnected?.(connectedAgentId);
1385
+ resolve({
1386
+ ws,
1387
+ close: () => ws.close(1e3, "CLI shutdown")
1388
+ });
1389
+ break;
1390
+ }
1391
+ case "error":
1392
+ clearTimeout(connectionTimeout);
1393
+ onError?.(message.message || "Unknown tunnel error");
1394
+ if (message.code === "unauthorized") {
1395
+ ws.close();
1396
+ reject(new Error("Unauthorized"));
1397
+ }
1398
+ break;
1399
+ case "ping":
1400
+ ws.send(JSON.stringify({ type: "pong" }));
1401
+ break;
1326
1402
  }
1327
1403
  });
1328
1404
  ws.on("error", (error2) => {
1329
1405
  clearTimeout(connectionTimeout);
1330
- onError?.(`Connection error: ${error2.message}`);
1331
- reject(error2);
1406
+ const detail = upgradeRejection ?? describeSocketError(error2, url);
1407
+ onError?.(`Connection error: ${detail}`);
1408
+ reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
1332
1409
  });
1333
1410
  ws.on("close", (code, reason) => {
1334
- const reasonStr = reason.toString() || "No reason provided";
1411
+ const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1412
+ forwarder.abortAll();
1413
+ streamStartTimes.clear();
1335
1414
  onDisconnected?.(code, reasonStr);
1336
- for (const [, controller] of activeEventSubscriptions) {
1337
- controller.abort();
1338
- }
1339
- activeEventSubscriptions.clear();
1340
1415
  });
1341
1416
  });
1342
1417
  }
1343
1418
 
1344
- // src/commands/run.ts
1345
- var MAX_ACTIVITY_LOG_ENTRIES = 10;
1346
- var MESSAGE_POLL_INTERVAL_MS = 2e3;
1347
- var MAX_CONSECUTIVE_FETCH_FAILURES = 3;
1348
- var LOCK_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1e3;
1349
- async function resolveAgentIdFromKey(authHeader) {
1350
- const apiUrl = getApiUrlConfig();
1351
- try {
1352
- const response = await fetch(`${apiUrl}/me`, {
1353
- headers: { Authorization: authHeader }
1354
- });
1355
- if (!response.ok) {
1356
- return { error: `Failed to resolve agent from key: HTTP ${response.status}` };
1357
- }
1358
- const data = await response.json();
1359
- if (data.auth_type === "agent_key" && data.agent_id) {
1360
- return { agent_id: data.agent_id };
1419
+ // src/lib/tunnel/runner-connection.ts
1420
+ var RunnerConnection = class {
1421
+ opts;
1422
+ sleep;
1423
+ connection = null;
1424
+ resolvedAgentId;
1425
+ /** True while a (re)connect loop is in flight. */
1426
+ reconnecting = false;
1427
+ /** The in-flight reconnect promise, awaitable by the caller. */
1428
+ reconnectPromise = null;
1429
+ /** 1-based count of the current reconnect attempt streak. */
1430
+ reconnectAttempt = 0;
1431
+ constructor(opts) {
1432
+ this.opts = opts;
1433
+ this.resolvedAgentId = opts.agentId;
1434
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1435
+ }
1436
+ get agentId() {
1437
+ return this.resolvedAgentId;
1438
+ }
1439
+ /** Establish the initial tunnel connection (with retry/backoff). */
1440
+ async connect() {
1441
+ await this.connectWithRetry(false);
1442
+ }
1443
+ /** Close the active connection (idempotent). */
1444
+ close() {
1445
+ if (this.connection) {
1446
+ try {
1447
+ this.connection.close();
1448
+ } catch {
1449
+ }
1450
+ this.connection = null;
1361
1451
  }
1362
- return {
1363
- error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
1364
- };
1365
- } catch (error2) {
1366
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1367
- return { error: `Failed to resolve agent from key: ${message}` };
1368
1452
  }
1369
- }
1370
- async function getAgentInfo(agentId, authHeader) {
1371
- const apiUrl = getApiUrlConfig();
1372
- try {
1373
- const response = await fetch(`${apiUrl}/agents/${agentId}`, {
1374
- headers: { Authorization: authHeader }
1375
- });
1376
- if (response.status === 404) {
1377
- return { valid: false, error: "Agent not found" };
1378
- }
1379
- if (response.status === 401) {
1380
- return { valid: false, error: "Authentication failed", authFailed: true };
1381
- }
1382
- if (!response.ok) {
1383
- return { valid: false, error: `API error: ${response.status}` };
1384
- }
1385
- const agent = await response.json();
1386
- if (agent.sandbox_type !== "local" && agent.sandbox_type !== "github_actions") {
1387
- return {
1388
- valid: false,
1389
- error: `Agent is type '${agent.sandbox_type}', must be 'local' or 'github_actions' for CLI connection`
1390
- };
1453
+ async connectWithRetry(isReconnect) {
1454
+ if (isReconnect && this.reconnecting) return;
1455
+ this.reconnecting = true;
1456
+ this.close();
1457
+ const { events } = this.opts;
1458
+ while (this.opts.isRunning()) {
1459
+ try {
1460
+ this.connection = await connectTunnel({
1461
+ agentId: this.resolvedAgentId,
1462
+ authHeader: this.opts.getAuthHeader(),
1463
+ port: this.opts.port,
1464
+ onConnected: (agentId) => {
1465
+ this.reconnectAttempt = 0;
1466
+ this.reconnecting = false;
1467
+ this.resolvedAgentId = agentId;
1468
+ events.onConnected(agentId, isReconnect);
1469
+ },
1470
+ onDisconnected: (code, reason) => {
1471
+ events.onDisconnected(code, reason);
1472
+ if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
1473
+ this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
1474
+ events.onError?.(`Reconnection failed: ${err.message}`);
1475
+ });
1476
+ }
1477
+ },
1478
+ onError: (error2) => events.onError?.(error2),
1479
+ onResponse: () => events.onResponse?.(),
1480
+ onDrainPing: () => events.onDrainPing?.(),
1481
+ onInfo: (message) => events.onInfo?.(message)
1482
+ });
1483
+ return;
1484
+ } catch (error2) {
1485
+ this.reconnectAttempt++;
1486
+ if (error2.message === "Unauthorized") {
1487
+ this.reconnecting = false;
1488
+ throw error2;
1489
+ }
1490
+ const delay = getReconnectDelay(this.reconnectAttempt);
1491
+ events.onReconnecting?.(this.reconnectAttempt);
1492
+ events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
1493
+ await this.sleep(delay);
1494
+ }
1391
1495
  }
1392
- return { valid: true, agent };
1393
- } catch (error2) {
1394
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1395
- return { valid: false, error: `Failed to validate agent: ${message}` };
1496
+ this.reconnecting = false;
1396
1497
  }
1397
- }
1398
- var AuthenticationError = class extends Error {
1498
+ };
1499
+
1500
+ // src/lib/channels/driver.ts
1501
+ var DEFAULT_RETRY_POLICY = {
1502
+ maxAttempts: 6,
1503
+ baseDelayMs: 500,
1504
+ maxDelayMs: 3e4
1505
+ };
1506
+ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1507
+ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1508
+ var ChannelAuthError = class extends Error {
1399
1509
  constructor(message) {
1400
1510
  super(message);
1401
- this.name = "AuthenticationError";
1511
+ this.name = "ChannelAuthError";
1402
1512
  }
1403
1513
  };
1404
- function checkAuthResponse(response, context) {
1405
- if (response.status === 401 || response.status === 403) {
1406
- throw new AuthenticationError(
1407
- `Authentication failed during ${context}: HTTP ${response.status}. Your session may have expired.`
1408
- );
1409
- }
1410
- }
1411
- async function getPendingConversations(agentId, authHeader, conversationFilter) {
1412
- const apiUrl = getApiUrlConfig();
1413
- const response = await fetch(`${apiUrl}/agents/${agentId}/conversations/pending`, {
1414
- headers: { Authorization: authHeader }
1415
- });
1416
- checkAuthResponse(response, "fetching pending conversations");
1417
- if (!response.ok) {
1418
- throw new Error(`Failed to get pending conversations: HTTP ${response.status}`);
1419
- }
1420
- const data = await response.json();
1421
- let conversations = data.conversations;
1422
- if (conversationFilter) {
1423
- conversations = conversations.filter((c) => c.id === conversationFilter);
1514
+ function backoffDelay(attempt, policy) {
1515
+ const exp = policy.baseDelayMs * Math.pow(2, attempt);
1516
+ const capped = Math.min(policy.maxDelayMs, exp);
1517
+ return Math.floor(Math.random() * capped);
1518
+ }
1519
+ function isRetryableStatus(status) {
1520
+ return status === 429 || status >= 500 && status <= 599;
1521
+ }
1522
+ var ChannelDriver = class {
1523
+ agentId;
1524
+ port;
1525
+ apiUrl;
1526
+ getAuthHeader;
1527
+ conversationFilter;
1528
+ retry;
1529
+ log;
1530
+ fetchImpl;
1531
+ sleep;
1532
+ pausedPollIntervalMs;
1533
+ pausedMaxWaitMs;
1534
+ /** Cache of conversationId → opencode sessionId. */
1535
+ sessions = /* @__PURE__ */ new Map();
1536
+ /**
1537
+ * Outstanding paused-session watchers, keyed by `message.id` (WI-2-CLI).
1538
+ * Single-flight per message: while a watcher is live for a message we never
1539
+ * start a second one. The message stays `processing` for the watcher's lifetime
1540
+ * so the `?status=pending` drain cannot double-claim it.
1541
+ */
1542
+ watchers = /* @__PURE__ */ new Map();
1543
+ /**
1544
+ * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1545
+ * first session creation so drain-created sessions are rooted at the project
1546
+ * directory and thus visible in `opencode web`'s session list. `undefined` =
1547
+ * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1548
+ */
1549
+ opencodeDirectory = void 0;
1550
+ /** Serialises drains so a reconnect during a drain doesn't double-process. */
1551
+ draining = false;
1552
+ constructor(config2) {
1553
+ this.agentId = config2.agentId;
1554
+ this.port = config2.port;
1555
+ this.apiUrl = config2.apiUrl.replace(/\/$/, "");
1556
+ this.getAuthHeader = config2.getAuthHeader;
1557
+ this.conversationFilter = config2.conversationFilter ?? null;
1558
+ this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
1559
+ this.log = config2.log ?? (() => {
1560
+ });
1561
+ this.fetchImpl = config2.fetchImpl ?? fetch;
1562
+ this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1563
+ this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1564
+ this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1565
+ }
1566
+ /** The IPv4-loopback base URL for the local `opencode serve`. */
1567
+ get opencodeBase() {
1568
+ return `http://127.0.0.1:${this.port}`;
1569
+ }
1570
+ // -------------------------------------------------------------------------
1571
+ // Public API
1572
+ // -------------------------------------------------------------------------
1573
+ /**
1574
+ * Drain all pending channel conversations once: poll → process → callback.
1575
+ * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1576
+ * Re-entrant calls while a drain is in flight are skipped (return 0).
1577
+ *
1578
+ * @returns the number of messages processed.
1579
+ */
1580
+ async drainPending() {
1581
+ if (this.draining) return 0;
1582
+ this.draining = true;
1583
+ let processed = 0;
1584
+ try {
1585
+ const conversations = await this.getPendingConversations();
1586
+ if (conversations.length > 0) {
1587
+ const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
1588
+ this.log({
1589
+ level: "info",
1590
+ message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
1591
+ });
1592
+ }
1593
+ for (const conv of conversations) {
1594
+ processed += await this.processConversation(conv);
1595
+ }
1596
+ } finally {
1597
+ this.draining = false;
1598
+ }
1599
+ return processed;
1424
1600
  }
1425
- return conversations;
1426
- }
1427
- async function getPendingMessages(agentId, conversationId, authHeader) {
1428
- const apiUrl = getApiUrlConfig();
1429
- const response = await fetch(
1430
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/messages?status=pending`,
1431
- { headers: { Authorization: authHeader } }
1432
- );
1433
- checkAuthResponse(response, "fetching pending messages");
1434
- if (!response.ok) {
1435
- throw new Error(`Failed to get messages: HTTP ${response.status}`);
1601
+ /**
1602
+ * Await all outstanding paused-session watchers (WI-2-CLI).
1603
+ *
1604
+ * In production the watchers are deliberately started-not-awaited so the drain
1605
+ * loop never blocks on them and process exit is not held up (the cron recovers
1606
+ * any abandoned ones). This helper exists primarily for deterministic tests
1607
+ * that need to observe the watcher's effect (the `done` PATCH or its giving up)
1608
+ * after a non-blocking `drainPending`. Watchers never reject, so this resolves.
1609
+ */
1610
+ async flushPausedWatchers() {
1611
+ await Promise.all([...this.watchers.values()]);
1612
+ }
1613
+ // -------------------------------------------------------------------------
1614
+ // Conversation processing
1615
+ // -------------------------------------------------------------------------
1616
+ async processConversation(conv) {
1617
+ const sessionId = await this.ensureSession(conv);
1618
+ const messages = await this.getPendingMessages(conv.id);
1619
+ let processed = 0;
1620
+ for (const message of messages) {
1621
+ const claimed = await this.markProcessing(conv.id, message.id);
1622
+ if (!claimed) {
1623
+ this.log({
1624
+ level: "info",
1625
+ message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
1626
+ conversation_id: conv.id,
1627
+ message_id: message.id
1628
+ });
1629
+ continue;
1630
+ }
1631
+ try {
1632
+ this.log({
1633
+ level: "info",
1634
+ message: `Sending queued message ${message.id.slice(0, 8)} to OpenCode (session ${sessionId.slice(0, 8)})`,
1635
+ conversation_id: conv.id,
1636
+ message_id: message.id
1637
+ });
1638
+ const result = await sendMessageToOpenCode(
1639
+ this.port,
1640
+ sessionId,
1641
+ message.content,
1642
+ {
1643
+ agent: message.opencode_agent ?? void 0,
1644
+ model: message.opencode_model ?? void 0
1645
+ },
1646
+ {
1647
+ onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1648
+ onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1649
+ }
1650
+ );
1651
+ if (result.awaitingInteraction) {
1652
+ this.log({
1653
+ level: "info",
1654
+ message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
1655
+ conversation_id: conv.id,
1656
+ message_id: message.id
1657
+ });
1658
+ this.startPausedWatcher(conv, message, sessionId);
1659
+ continue;
1660
+ }
1661
+ await this.confirmCompletion(sessionId);
1662
+ await this.markDone(conv.id, message.id, sessionId);
1663
+ processed += 1;
1664
+ this.log({
1665
+ level: "info",
1666
+ message: `Message ${message.id.slice(0, 8)} processed`,
1667
+ conversation_id: conv.id,
1668
+ message_id: message.id
1669
+ });
1670
+ } catch (err) {
1671
+ if (err instanceof ChannelAuthError) throw err;
1672
+ await this.markFailed(conv.id, message.id).catch(() => {
1673
+ });
1674
+ this.log({
1675
+ level: "error",
1676
+ message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1677
+ conversation_id: conv.id,
1678
+ message_id: message.id
1679
+ });
1680
+ }
1681
+ }
1682
+ return processed;
1436
1683
  }
1437
- return response.json();
1438
- }
1439
- async function markMessageProcessing(agentId, conversationId, messageId, authHeader) {
1440
- const apiUrl = getApiUrlConfig();
1441
- const response = await fetch(
1442
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/messages/${messageId}`,
1443
- {
1444
- method: "PATCH",
1445
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1446
- body: JSON.stringify({ status: "processing" })
1684
+ async ensureSession(conv) {
1685
+ const cached = this.sessions.get(conv.id);
1686
+ if (cached) return cached;
1687
+ if (conv.opencode_session_id) {
1688
+ this.sessions.set(conv.id, conv.opencode_session_id);
1689
+ return conv.opencode_session_id;
1447
1690
  }
1448
- );
1449
- checkAuthResponse(response, "marking message as processing");
1450
- return response.ok;
1451
- }
1452
- async function reportInteractiveEvent(agentId, conversationId, type, data, authHeader) {
1453
- const apiUrl = getApiUrlConfig();
1454
- const response = await fetch(
1455
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/interactive-event`,
1456
- {
1457
- method: "POST",
1458
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1459
- body: JSON.stringify({ type, data })
1691
+ const directory = await this.resolveOpenCodeDirectory();
1692
+ const sessionId = await createOpenCodeSession(this.port, directory);
1693
+ this.sessions.set(conv.id, sessionId);
1694
+ await this.persistSession(conv.id, sessionId).catch(() => {
1695
+ });
1696
+ return sessionId;
1697
+ }
1698
+ /**
1699
+ * Lazily resolve (and cache) opencode's root directory via `GET /path`.
1700
+ * Resolved once per driver: `undefined` until first lookup, then the directory
1701
+ * string or `null` if unavailable (we don't keep retrying a missing `/path`).
1702
+ */
1703
+ async resolveOpenCodeDirectory() {
1704
+ if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
1705
+ this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1706
+ if (!this.opencodeDirectory) {
1707
+ this.log({
1708
+ level: "info",
1709
+ message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1710
+ });
1460
1711
  }
1461
- );
1462
- checkAuthResponse(response, "reporting interactive event");
1463
- if (!response.ok) {
1464
- throw new Error(`Failed to report interactive event: HTTP ${response.status}`);
1712
+ return this.opencodeDirectory;
1465
1713
  }
1466
- }
1467
- async function markMessageDone(agentId, conversationId, messageId, authHeader, sessionId) {
1468
- const apiUrl = getApiUrlConfig();
1469
- const body = { status: "done" };
1470
- if (sessionId) {
1471
- body.opencode_session_id = sessionId;
1714
+ /**
1715
+ * Local reconcile: re-query `GET /session/:id/message` and check whether the
1716
+ * last assistant message is message-level complete (`isTurnComplete`).
1717
+ *
1718
+ * This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
1719
+ * blocking POST already returned, and `markDone` causes the server to re-fetch
1720
+ * the messages itself (via `extractTextFromMessages`) when delivering the
1721
+ * reply — so this round-trip never gates delivery. We keep it only to surface a
1722
+ * truthful diagnostic when opencode hasn't yet recorded a completed assistant
1723
+ * turn at reconcile time, then proceed to `markDone` regardless. Uses the
1724
+ * injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
1725
+ */
1726
+ async confirmCompletion(sessionId) {
1727
+ try {
1728
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1729
+ if (!res.ok) return;
1730
+ const body = await res.json();
1731
+ const messages = Array.isArray(body) ? body : null;
1732
+ if (!isTurnComplete(messages)) {
1733
+ this.log({
1734
+ level: "info",
1735
+ message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
1736
+ });
1737
+ }
1738
+ } catch {
1739
+ }
1740
+ }
1741
+ // -------------------------------------------------------------------------
1742
+ // Paused-session watcher (WI-2-CLI)
1743
+ // -------------------------------------------------------------------------
1744
+ /**
1745
+ * Start (but do NOT await) a watcher that resumes a paused turn to completion.
1746
+ *
1747
+ * Single-flight per message: if a watcher is already live for this message we
1748
+ * skip. The returned watcher promise is tracked in `this.watchers` and removed
1749
+ * when it settles; it never rejects (the body is fully guarded), so a failed
1750
+ * poll/markDone can never crash the run loop — the cron stays as the safety net.
1751
+ */
1752
+ startPausedWatcher(conv, message, sessionId) {
1753
+ if (this.watchers.has(message.id)) return;
1754
+ const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
1755
+ this.watchers.delete(message.id);
1756
+ });
1757
+ this.watchers.set(message.id, watcher);
1472
1758
  }
1473
- const response = await fetch(
1474
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/messages/${messageId}`,
1475
- {
1476
- method: "PATCH",
1477
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1478
- body: JSON.stringify(body)
1759
+ /**
1760
+ * Poll `GET /session/:id/message` until the SAME paused turn is message-level
1761
+ * complete — the last message is an ASSISTANT message whose
1762
+ * `info.time.completed` is set (the user answered the question/permission in
1763
+ * opencode web and opencode finished the turn) — then complete it via the
1764
+ * EXISTING `markDone` PATCH — NO re-send of the original message. The server
1765
+ * re-fetches the assistant messages on completion, so the watcher does not
1766
+ * pass any reply text.
1767
+ *
1768
+ * Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
1769
+ * tests' injection) and reuses only the pure `isTurnComplete` predicate from
1770
+ * `session.ts` — we do NOT call `getSessionMessages` (which uses the global
1771
+ * `fetch`) here.
1772
+ *
1773
+ * Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
1774
+ * reset): on timeout we STOP and leave the message `processing` so the cron
1775
+ * remains the last-resort safety net. The whole body is wrapped so any
1776
+ * poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
1777
+ * out of the run loop.
1778
+ */
1779
+ async watchPausedSession(conv, message, sessionId) {
1780
+ const deadline = Date.now() + this.pausedMaxWaitMs;
1781
+ try {
1782
+ while (Date.now() < deadline) {
1783
+ await this.sleep(this.pausedPollIntervalMs);
1784
+ let completed = false;
1785
+ try {
1786
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1787
+ if (res.ok) {
1788
+ const body = await res.json();
1789
+ const messages = Array.isArray(body) ? body : null;
1790
+ completed = isTurnComplete(messages);
1791
+ }
1792
+ } catch {
1793
+ continue;
1794
+ }
1795
+ if (!completed) continue;
1796
+ this.log({
1797
+ level: "info",
1798
+ message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
1799
+ conversation_id: conv.id,
1800
+ message_id: message.id
1801
+ });
1802
+ await this.markDone(conv.id, message.id, sessionId);
1803
+ return;
1804
+ }
1805
+ this.log({
1806
+ level: "info",
1807
+ message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
1808
+ conversation_id: conv.id,
1809
+ message_id: message.id
1810
+ });
1811
+ } catch (err) {
1812
+ this.log({
1813
+ level: "error",
1814
+ message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1815
+ conversation_id: conv.id,
1816
+ message_id: message.id
1817
+ });
1479
1818
  }
1480
- );
1481
- checkAuthResponse(response, "marking message as done");
1482
- }
1483
- async function markMessageFailed(agentId, conversationId, messageId, authHeader) {
1484
- const apiUrl = getApiUrlConfig();
1485
- const response = await fetch(
1486
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/messages/${messageId}`,
1487
- {
1488
- method: "PATCH",
1489
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1490
- body: JSON.stringify({ status: "failed" })
1819
+ }
1820
+ // -------------------------------------------------------------------------
1821
+ // Evident API calls (combinedAuth thread routes)
1822
+ // -------------------------------------------------------------------------
1823
+ async getPendingConversations() {
1824
+ const res = await this.fetchImpl(
1825
+ `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
1826
+ {
1827
+ headers: { Authorization: this.getAuthHeader() }
1828
+ }
1829
+ );
1830
+ this.assertAuth(res, "fetching pending conversations");
1831
+ if (!res.ok) {
1832
+ throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
1491
1833
  }
1492
- );
1493
- checkAuthResponse(response, "marking message as failed");
1494
- }
1495
- async function acquireConversationLock(agentId, conversationId, correlationId, authHeader) {
1496
- const apiUrl = getApiUrlConfig();
1497
- try {
1498
- const response = await fetch(`${apiUrl}/agents/${agentId}/threads/${conversationId}/lock`, {
1499
- method: "POST",
1500
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1501
- body: JSON.stringify({ correlation_id: correlationId })
1502
- });
1503
- checkAuthResponse(response, "acquiring conversation lock");
1504
- if (response.status === 409) {
1505
- return { acquired: false, error: "Conversation already locked by another runner" };
1834
+ const data = await res.json();
1835
+ let conversations = data.conversations;
1836
+ if (this.conversationFilter) {
1837
+ conversations = conversations.filter((c) => c.id === this.conversationFilter);
1506
1838
  }
1507
- if (!response.ok) {
1508
- return { acquired: false, error: `Failed to acquire lock: HTTP ${response.status}` };
1839
+ return conversations;
1840
+ }
1841
+ async getPendingMessages(conversationId) {
1842
+ const res = await this.fetchImpl(
1843
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
1844
+ { headers: { Authorization: this.getAuthHeader() } }
1845
+ );
1846
+ this.assertAuth(res, "fetching pending messages");
1847
+ if (!res.ok) {
1848
+ throw new Error(`Failed to get messages: HTTP ${res.status}`);
1509
1849
  }
1510
- return { acquired: true };
1511
- } catch (error2) {
1512
- if (error2 instanceof AuthenticationError) throw error2;
1513
- return { acquired: false, error: String(error2) };
1850
+ return await res.json();
1514
1851
  }
1515
- }
1516
- async function extendConversationLock(agentId, conversationId, correlationId, authHeader) {
1517
- const apiUrl = getApiUrlConfig();
1518
- try {
1519
- const response = await fetch(
1520
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/lock/extend`,
1852
+ async markProcessing(conversationId, messageId) {
1853
+ const res = await this.fetchImpl(
1854
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1521
1855
  {
1522
- method: "POST",
1523
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1524
- body: JSON.stringify({ correlation_id: correlationId })
1856
+ method: "PATCH",
1857
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1858
+ body: JSON.stringify({ status: "processing" })
1525
1859
  }
1526
1860
  );
1527
- return response.ok;
1528
- } catch {
1529
- return false;
1861
+ this.assertAuth(res, "marking message as processing");
1862
+ return res.ok;
1530
1863
  }
1531
- }
1532
- async function releaseConversationLock(agentId, conversationId, correlationId, authHeader) {
1533
- const apiUrl = getApiUrlConfig();
1534
- try {
1535
- await fetch(
1536
- `${apiUrl}/agents/${agentId}/threads/${conversationId}/lock?correlation_id=${encodeURIComponent(correlationId)}`,
1864
+ /**
1865
+ * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1866
+ * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
1867
+ * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1868
+ * for an already-`done` message a no-op (no double Slack post).
1869
+ */
1870
+ async markDone(conversationId, messageId, sessionId) {
1871
+ await this.callWithRetry(
1872
+ "marking message as done",
1873
+ () => this.fetchImpl(
1874
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1875
+ {
1876
+ method: "PATCH",
1877
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1878
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1879
+ }
1880
+ )
1881
+ );
1882
+ }
1883
+ async markFailed(conversationId, messageId) {
1884
+ await this.callWithRetry(
1885
+ "marking message as failed",
1886
+ () => this.fetchImpl(
1887
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1888
+ {
1889
+ method: "PATCH",
1890
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1891
+ body: JSON.stringify({ status: "failed" })
1892
+ }
1893
+ )
1894
+ );
1895
+ }
1896
+ async persistSession(conversationId, sessionId) {
1897
+ const res = await this.fetchImpl(
1898
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
1537
1899
  {
1538
- method: "DELETE",
1539
- headers: { Authorization: authHeader }
1900
+ method: "PATCH",
1901
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1902
+ body: JSON.stringify({ opencode_session_id: sessionId })
1540
1903
  }
1541
1904
  );
1905
+ this.assertAuth(res, "persisting session id");
1906
+ }
1907
+ /**
1908
+ * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1909
+ * `POST .../interactive-event {type, data}`. The server persists the
1910
+ * interaction and posts a link to the proxied opencode-web conversation.
1911
+ */
1912
+ async reportInteraction(conversationId, type, data) {
1913
+ try {
1914
+ await this.callWithRetry(
1915
+ "reporting interactive event",
1916
+ () => this.fetchImpl(
1917
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
1918
+ {
1919
+ method: "POST",
1920
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1921
+ body: JSON.stringify({ type, data })
1922
+ }
1923
+ )
1924
+ );
1925
+ this.log({
1926
+ level: "info",
1927
+ message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1928
+ conversation_id: conversationId
1929
+ });
1930
+ } catch (err) {
1931
+ if (err instanceof ChannelAuthError) throw err;
1932
+ this.log({
1933
+ level: "error",
1934
+ message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1935
+ conversation_id: conversationId
1936
+ });
1937
+ }
1938
+ }
1939
+ // -------------------------------------------------------------------------
1940
+ // Retry wrapper
1941
+ // -------------------------------------------------------------------------
1942
+ /**
1943
+ * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
1944
+ * network errors) with exponential backoff + jitter (capped). Auth failures
1945
+ * (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
1946
+ * terminal too. No on-disk persistence — a crash mid-retry drops the callback
1947
+ * (accepted by ADR-0039).
1948
+ */
1949
+ async callWithRetry(context, call) {
1950
+ let lastError;
1951
+ for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
1952
+ let res;
1953
+ try {
1954
+ res = await call();
1955
+ } catch (err) {
1956
+ lastError = err;
1957
+ if (attempt < this.retry.maxAttempts - 1) {
1958
+ await this.sleep(backoffDelay(attempt, this.retry));
1959
+ continue;
1960
+ }
1961
+ throw err;
1962
+ }
1963
+ if (res.status === 401 || res.status === 403) {
1964
+ throw new ChannelAuthError(
1965
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1966
+ );
1967
+ }
1968
+ if (res.ok) return;
1969
+ if (isRetryableStatus(res.status)) {
1970
+ lastError = new Error(`${context}: HTTP ${res.status}`);
1971
+ if (attempt < this.retry.maxAttempts - 1) {
1972
+ await this.sleep(backoffDelay(attempt, this.retry));
1973
+ continue;
1974
+ }
1975
+ }
1976
+ throw new Error(`${context}: HTTP ${res.status}`);
1977
+ }
1978
+ throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1979
+ }
1980
+ assertAuth(res, context) {
1981
+ if (res.status === 401 || res.status === 403) {
1982
+ throw new ChannelAuthError(
1983
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1984
+ );
1985
+ }
1986
+ }
1987
+ };
1988
+
1989
+ // src/commands/ensure-opencode.ts
1990
+ import chalk5 from "chalk";
1991
+ import ora2 from "ora";
1992
+ import { select as select2 } from "@inquirer/prompts";
1993
+ async function ensureOpenCodeRunning(ctx) {
1994
+ const healthCheck = await checkOpenCodeHealth(ctx.port);
1995
+ if (healthCheck.healthy) {
1996
+ return { port: ctx.port, process: null, version: healthCheck.version ?? null };
1997
+ }
1998
+ const runningInstances = await findHealthyOpenCodeInstances();
1999
+ if (runningInstances.length > 0) {
2000
+ if (!ctx.interactive) {
2001
+ throw new Error(
2002
+ `OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
2003
+ );
2004
+ }
2005
+ blank();
2006
+ console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
2007
+ for (const instance of runningInstances) {
2008
+ const ver = instance.version ? ` (v${instance.version})` : "";
2009
+ const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
2010
+ console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
2011
+ }
2012
+ blank();
2013
+ if (runningInstances.length === 1) {
2014
+ console.log(chalk5.yellow("Tip: Run with the correct port:"));
2015
+ console.log(
2016
+ chalk5.dim(
2017
+ ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
2018
+ )
2019
+ );
2020
+ }
2021
+ blank();
2022
+ throw new Error(`OpenCode not running on port ${ctx.port}`);
2023
+ }
2024
+ if (!isOpenCodeInstalled()) {
2025
+ if (!ctx.interactive) {
2026
+ throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
2027
+ }
2028
+ const result = await promptOpenCodeInstall(true);
2029
+ if (result === "exit") process.exit(0);
2030
+ if (result !== "installed" && !isOpenCodeInstalled()) {
2031
+ throw new Error("OpenCode is not installed");
2032
+ }
2033
+ }
2034
+ if (!ctx.interactive) {
2035
+ ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
2036
+ const proc = await startOpenCode(ctx.port);
2037
+ const health = await waitForOpenCodeHealth(ctx.port, 3e4);
2038
+ if (!health.healthy) {
2039
+ throw new Error(
2040
+ `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
2041
+ );
2042
+ }
2043
+ ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
2044
+ return { port: ctx.port, process: proc, version: health.version ?? null };
2045
+ }
2046
+ let port = ctx.port;
2047
+ if (isPortInUse(port)) {
2048
+ console.log(chalk5.yellow(`
2049
+ Port ${port} is already in use.`));
2050
+ const alternativePort = findAvailablePort(port + 1);
2051
+ if (alternativePort) {
2052
+ const useAlternative = await select2({
2053
+ message: `Use port ${alternativePort} instead?`,
2054
+ choices: [
2055
+ { name: `Yes, use port ${alternativePort}`, value: "yes" },
2056
+ { name: "No, I will free the port manually", value: "no" }
2057
+ ]
2058
+ });
2059
+ if (useAlternative === "yes") {
2060
+ port = alternativePort;
2061
+ } else {
2062
+ throw new Error(`Port ${ctx.port} is in use`);
2063
+ }
2064
+ }
2065
+ }
2066
+ const action = await select2({
2067
+ message: "OpenCode is not running. What would you like to do?",
2068
+ choices: [
2069
+ {
2070
+ name: "Start OpenCode for me",
2071
+ value: "start",
2072
+ description: `Run 'opencode serve --port ${port}'`
2073
+ },
2074
+ {
2075
+ name: "Show me the command",
2076
+ value: "manual",
2077
+ description: "Display the command to run manually"
2078
+ },
2079
+ {
2080
+ name: "Continue without OpenCode",
2081
+ value: "continue",
2082
+ description: "Requests will fail until OpenCode starts"
2083
+ }
2084
+ ]
2085
+ });
2086
+ if (action === "manual") {
2087
+ blank();
2088
+ console.log(chalk5.bold("Run this command in another terminal:"));
2089
+ blank();
2090
+ console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
2091
+ blank();
2092
+ throw new Error("Please start OpenCode manually");
2093
+ }
2094
+ if (action === "start") {
2095
+ const spinner = ora2("Starting OpenCode...").start();
2096
+ const proc = await startOpenCode(port);
2097
+ const health = await waitForOpenCodeHealth(port, 3e4);
2098
+ if (!health.healthy) {
2099
+ spinner.fail("Failed to start OpenCode");
2100
+ throw new Error("OpenCode failed to start");
2101
+ }
2102
+ spinner.stop();
2103
+ return { port, process: proc, version: health.version ?? null };
2104
+ }
2105
+ return { port, process: null, version: null };
2106
+ }
2107
+
2108
+ // src/commands/agent-lookup.ts
2109
+ async function readErrorMessage(response) {
2110
+ const text = await response.text().catch(() => "");
2111
+ if (!text) return response.statusText || void 0;
2112
+ try {
2113
+ const data = JSON.parse(text);
2114
+ const message = data.message ?? data.error;
2115
+ if (typeof message === "string" && message.trim()) {
2116
+ return message;
2117
+ }
1542
2118
  } catch {
1543
2119
  }
2120
+ return text.trim() || response.statusText || void 0;
1544
2121
  }
1545
- async function updateConversationSession(agentId, conversationId, sessionId, authHeader) {
2122
+ function authFailureHint(apiUrl, serverMessage) {
2123
+ const reason = serverMessage ? `: ${serverMessage}` : "";
2124
+ 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.`;
2125
+ }
2126
+ async function resolveAgentIdFromKey(authHeader) {
1546
2127
  const apiUrl = getApiUrlConfig();
1547
- const response = await fetch(`${apiUrl}/agents/${agentId}/threads/${conversationId}`, {
1548
- method: "PATCH",
1549
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1550
- body: JSON.stringify({ opencode_session_id: sessionId })
1551
- });
1552
- checkAuthResponse(response, "updating conversation session");
1553
- if (!response.ok) {
1554
- const text = await response.text().catch(() => "");
1555
- throw new Error(
1556
- `Failed to update conversation session: HTTP ${response.status}${text ? `: ${text}` : ""}`
1557
- );
2128
+ try {
2129
+ const response = await fetch(`${apiUrl}/me`, {
2130
+ headers: { Authorization: authHeader }
2131
+ });
2132
+ if (response.status === 401) {
2133
+ const serverMessage = await readErrorMessage(response);
2134
+ return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2135
+ }
2136
+ if (!response.ok) {
2137
+ const serverMessage = await readErrorMessage(response);
2138
+ return {
2139
+ error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2140
+ };
2141
+ }
2142
+ const data = await response.json();
2143
+ if (data.auth_type === "agent_key" && data.agent_id) {
2144
+ return { agent_id: data.agent_id };
2145
+ }
2146
+ return {
2147
+ error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
2148
+ };
2149
+ } catch (error2) {
2150
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2151
+ return { error: `Failed to resolve agent from key: ${message}` };
1558
2152
  }
1559
2153
  }
1560
- async function updateConversationTitle(agentId, conversationId, title, authHeader) {
2154
+ async function getAgentInfo(agentId, authHeader) {
1561
2155
  const apiUrl = getApiUrlConfig();
1562
- const response = await fetch(`${apiUrl}/agents/${agentId}/threads/${conversationId}`, {
1563
- method: "PATCH",
1564
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1565
- body: JSON.stringify({ title })
1566
- });
1567
- checkAuthResponse(response, "updating conversation title");
2156
+ try {
2157
+ const response = await fetch(`${apiUrl}/agents/${agentId}`, {
2158
+ headers: { Authorization: authHeader }
2159
+ });
2160
+ if (response.status === 401) {
2161
+ const serverMessage = await readErrorMessage(response);
2162
+ return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2163
+ }
2164
+ if (response.status === 403) {
2165
+ const serverMessage = await readErrorMessage(response);
2166
+ return {
2167
+ valid: false,
2168
+ error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
2169
+ };
2170
+ }
2171
+ if (response.status === 404) {
2172
+ const serverMessage = await readErrorMessage(response);
2173
+ return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
2174
+ }
2175
+ if (!response.ok) {
2176
+ const serverMessage = await readErrorMessage(response);
2177
+ return {
2178
+ valid: false,
2179
+ error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2180
+ };
2181
+ }
2182
+ const agent = await response.json();
2183
+ if (agent.agent_type !== "local") {
2184
+ return {
2185
+ valid: false,
2186
+ error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
2187
+ };
2188
+ }
2189
+ return { valid: true, agent };
2190
+ } catch (error2) {
2191
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2192
+ return { valid: false, error: `Failed to validate agent: ${message}` };
2193
+ }
1568
2194
  }
2195
+
2196
+ // src/commands/run.ts
2197
+ var MAX_ACTIVITY_LOG_ENTRIES = 10;
2198
+ var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
1569
2199
  function log(state, message, isError = false) {
1570
2200
  if (state.json) {
1571
2201
  console.log(
@@ -1576,7 +2206,7 @@ function log(state, message, isError = false) {
1576
2206
  })
1577
2207
  );
1578
2208
  } else if (!state.interactive) {
1579
- const prefix = isError ? chalk5.red("\u2717") : chalk5.green("\u2022");
2209
+ const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
1580
2210
  console.log(`${prefix} ${message}`);
1581
2211
  }
1582
2212
  }
@@ -1589,7 +2219,6 @@ function logActivity(state, entry) {
1589
2219
  if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
1590
2220
  state.activityLog.shift();
1591
2221
  }
1592
- state.lastActivity = fullEntry.timestamp;
1593
2222
  if (!state.interactive) {
1594
2223
  if (entry.type === "error") {
1595
2224
  log(state, entry.error ?? "Unknown error", true);
@@ -1598,130 +2227,21 @@ function logActivity(state, entry) {
1598
2227
  }
1599
2228
  }
1600
2229
  }
1601
- var ANSI = {
1602
- moveUp: (n) => `\x1B[${n}A`
1603
- };
1604
- var STATUS_DISPLAY_HEIGHT = 22;
1605
- function colorizeStatus(status) {
1606
- if (status >= 200 && status < 300) {
1607
- return chalk5.green(status.toString());
1608
- } else if (status >= 300 && status < 400) {
1609
- return chalk5.yellow(status.toString());
1610
- } else if (status >= 400 && status < 500) {
1611
- return chalk5.red(status.toString());
1612
- } else if (status >= 500) {
1613
- return chalk5.bgRed.white(` ${status} `);
1614
- }
1615
- return status.toString();
1616
- }
1617
- function formatActivityEntry(entry) {
1618
- const time = entry.timestamp.toLocaleTimeString("en-US", {
1619
- hour12: false,
1620
- hour: "2-digit",
1621
- minute: "2-digit",
1622
- second: "2-digit"
1623
- });
1624
- switch (entry.type) {
1625
- case "request": {
1626
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
1627
- const status = entry.status ? ` -> ${colorizeStatus(entry.status)}` : " ...";
1628
- return ` ${chalk5.dim(`[${time}]`)} ${chalk5.cyan("<-")} ${entry.method} ${entry.path}${status}${duration}`;
1629
- }
1630
- case "response": {
1631
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
1632
- return ` ${chalk5.dim(`[${time}]`)} ${chalk5.green("->")} ${entry.method} ${entry.path} ${colorizeStatus(entry.status)}${duration}`;
1633
- }
1634
- case "error": {
1635
- const errorMsg = entry.error || "Unknown error";
1636
- const path = entry.path ? ` ${entry.method} ${entry.path}` : "";
1637
- return ` ${chalk5.dim(`[${time}]`)} ${chalk5.red("x")}${path} - ${chalk5.red(errorMsg)}`;
1638
- }
1639
- case "info": {
1640
- return ` ${chalk5.dim(`[${time}]`)} ${chalk5.blue("*")} ${entry.message}`;
1641
- }
1642
- default:
1643
- return ` ${chalk5.dim(`[${time}]`)} ${entry.message || "Unknown"}`;
1644
- }
1645
- }
1646
2230
  function displayStatus(state) {
1647
2231
  if (!state.interactive) return;
1648
- const lines = [];
1649
- lines.push(chalk5.bold("Evident"));
1650
- lines.push(chalk5.dim("-".repeat(60)));
1651
- lines.push("");
1652
- if (state.agentName) {
1653
- lines.push(` Agent: ${state.agentName}`);
1654
- }
1655
- lines.push(` ID: ${state.agentId}`);
1656
- if (state.conversationFilter) {
1657
- lines.push(` Filter: conversation ${state.conversationFilter.slice(0, 8)}...`);
1658
- }
1659
- lines.push("");
1660
- if (state.connected) {
1661
- lines.push(` ${chalk5.green("*")} Tunnel: ${chalk5.green("Connected to Evident")}`);
1662
- } else {
1663
- if (state.reconnectAttempt > 0) {
1664
- lines.push(
1665
- ` ${chalk5.yellow("o")} Tunnel: ${chalk5.yellow(`Reconnecting... (attempt ${state.reconnectAttempt})`)}`
1666
- );
1667
- } else {
1668
- lines.push(` ${chalk5.yellow("o")} Tunnel: ${chalk5.yellow("Connecting...")}`);
1669
- }
1670
- }
1671
- if (state.opencodeConnected) {
1672
- const version = state.opencodeVersion ? `, v${state.opencodeVersion}` : "";
1673
- lines.push(
1674
- ` ${chalk5.green("*")} OpenCode: ${chalk5.green(`Running on port ${state.port}${version}`)}`
1675
- );
1676
- } else {
1677
- lines.push(` ${chalk5.red("o")} OpenCode: ${chalk5.red(`Not connected (port ${state.port})`)}`);
1678
- }
1679
- lines.push("");
1680
- if (state.messageCount > 0) {
1681
- lines.push(` Messages: ${state.messageCount} processed`);
1682
- lines.push("");
1683
- }
1684
- if (state.activityLog.length > 0) {
1685
- lines.push(chalk5.bold(" Activity:"));
1686
- for (const entry of state.activityLog) {
1687
- lines.push(formatActivityEntry(entry));
1688
- }
1689
- } else {
1690
- lines.push(chalk5.dim(" No activity yet. Waiting for requests..."));
1691
- }
1692
- lines.push("");
1693
- lines.push(chalk5.dim("-".repeat(60)));
1694
- if (state.verbose) {
1695
- lines.push(chalk5.dim(" Verbose mode: ON"));
1696
- }
1697
- lines.push("");
1698
- lines.push(
1699
- chalk5.dim(` Tip: Run \`opencode attach http://localhost:${state.port}\` to see live activity`)
2232
+ const attempt = state.connection?.reconnectAttempt ?? 0;
2233
+ const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
2234
+ const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
2235
+ const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
2236
+ const last = state.activityLog[state.activityLog.length - 1];
2237
+ const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
2238
+ const agent = state.agentName ?? state.agentId;
2239
+ console.log(
2240
+ `${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
1700
2241
  );
1701
- lines.push(chalk5.dim(" Press Ctrl+C to disconnect"));
1702
- while (lines.length < STATUS_DISPLAY_HEIGHT) {
1703
- lines.push("");
1704
- }
1705
- if (!state.displayInitialized) {
1706
- console.log("");
1707
- console.log(chalk5.dim("=".repeat(60)));
1708
- console.log("");
1709
- for (const line of lines) {
1710
- console.log(line);
1711
- }
1712
- state.displayInitialized = true;
1713
- } else {
1714
- process.stdout.write(ANSI.moveUp(STATUS_DISPLAY_HEIGHT + 3));
1715
- console.log(chalk5.dim("=".repeat(60)));
1716
- console.log("");
1717
- for (const line of lines) {
1718
- process.stdout.write("\x1B[2K");
1719
- console.log(line);
1720
- }
1721
- }
1722
2242
  }
1723
2243
  async function promptForLogin(promptMessage, successMessage) {
1724
- const action = await select2({
2244
+ const action = await select3({
1725
2245
  message: promptMessage,
1726
2246
  choices: [
1727
2247
  {
@@ -1737,7 +2257,7 @@ async function promptForLogin(promptMessage, successMessage) {
1737
2257
  ]
1738
2258
  });
1739
2259
  if (action === "exit") {
1740
- console.log(chalk5.dim(`
2260
+ console.log(chalk6.dim(`
1741
2261
  You can log in later by running: ${getCliName()} login`));
1742
2262
  process.exit(0);
1743
2263
  }
@@ -1748,137 +2268,10 @@ You can log in later by running: ${getCliName()} login`));
1748
2268
  process.exit(1);
1749
2269
  }
1750
2270
  blank();
1751
- console.log(chalk5.green(successMessage));
2271
+ console.log(chalk6.green(successMessage));
1752
2272
  blank();
1753
2273
  return { token: credentials2.token, authType: "bearer", user: credentials2.user };
1754
2274
  }
1755
- async function ensureOpenCodeRunning(state) {
1756
- const healthCheck = await checkOpenCodeHealth(state.port);
1757
- if (healthCheck.healthy) {
1758
- state.opencodeConnected = true;
1759
- state.opencodeVersion = healthCheck.version ?? null;
1760
- return;
1761
- }
1762
- const runningInstances = await findHealthyOpenCodeInstances();
1763
- if (runningInstances.length > 0) {
1764
- if (!state.interactive) {
1765
- throw new Error(
1766
- `OpenCode not found on port ${state.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
1767
- );
1768
- }
1769
- blank();
1770
- console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
1771
- for (const instance of runningInstances) {
1772
- const ver = instance.version ? ` (v${instance.version})` : "";
1773
- const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
1774
- console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
1775
- }
1776
- blank();
1777
- if (runningInstances.length === 1) {
1778
- console.log(chalk5.yellow("Tip: Run with the correct port:"));
1779
- console.log(
1780
- chalk5.dim(
1781
- ` ${getCliName()} run --agent ${state.agentId} --port ${runningInstances[0].port}`
1782
- )
1783
- );
1784
- }
1785
- blank();
1786
- throw new Error(`OpenCode not running on port ${state.port}`);
1787
- }
1788
- if (!isOpenCodeInstalled()) {
1789
- if (!state.interactive) {
1790
- throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
1791
- }
1792
- const result = await promptOpenCodeInstall(true);
1793
- if (result === "exit") {
1794
- process.exit(0);
1795
- }
1796
- if (result === "installed" || isOpenCodeInstalled()) {
1797
- } else {
1798
- throw new Error("OpenCode is not installed");
1799
- }
1800
- }
1801
- if (state.interactive) {
1802
- let actualPort = state.port;
1803
- if (isPortInUse(state.port)) {
1804
- console.log(chalk5.yellow(`
1805
- Port ${state.port} is already in use.`));
1806
- const alternativePort = findAvailablePort(state.port + 1);
1807
- if (alternativePort) {
1808
- const useAlternative = await select2({
1809
- message: `Use port ${alternativePort} instead?`,
1810
- choices: [
1811
- { name: `Yes, use port ${alternativePort}`, value: "yes" },
1812
- { name: "No, I will free the port manually", value: "no" }
1813
- ]
1814
- });
1815
- if (useAlternative === "yes") {
1816
- actualPort = alternativePort;
1817
- state.port = actualPort;
1818
- } else {
1819
- throw new Error(`Port ${state.port} is in use`);
1820
- }
1821
- }
1822
- }
1823
- const action = await select2({
1824
- message: "OpenCode is not running. What would you like to do?",
1825
- choices: [
1826
- {
1827
- name: "Start OpenCode for me",
1828
- value: "start",
1829
- description: `Run 'opencode serve --port ${actualPort}'`
1830
- },
1831
- {
1832
- name: "Show me the command",
1833
- value: "manual",
1834
- description: "Display the command to run manually"
1835
- },
1836
- {
1837
- name: "Continue without OpenCode",
1838
- value: "continue",
1839
- description: "Requests will fail until OpenCode starts"
1840
- }
1841
- ]
1842
- });
1843
- if (action === "manual") {
1844
- blank();
1845
- console.log(chalk5.bold("Run this command in another terminal:"));
1846
- blank();
1847
- console.log(` ${chalk5.cyan(`opencode serve --port ${actualPort}`)}`);
1848
- blank();
1849
- throw new Error("Please start OpenCode manually");
1850
- }
1851
- if (action === "start") {
1852
- const spinner = ora2("Starting OpenCode...").start();
1853
- state.opencodeProcess = await startOpenCode(actualPort);
1854
- const health = await waitForOpenCodeHealth(actualPort, 3e4);
1855
- if (!health.healthy) {
1856
- spinner.fail("Failed to start OpenCode");
1857
- throw new Error("OpenCode failed to start");
1858
- }
1859
- spinner.succeed(
1860
- `OpenCode running on port ${actualPort}${health.version ? ` (v${health.version})` : ""}`
1861
- );
1862
- state.opencodeConnected = true;
1863
- state.opencodeVersion = health.version ?? null;
1864
- }
1865
- } else {
1866
- log(state, `OpenCode is not running on port ${state.port}. Starting it automatically...`);
1867
- state.opencodeProcess = await startOpenCode(state.port);
1868
- const health = await waitForOpenCodeHealth(state.port, 3e4);
1869
- if (!health.healthy) {
1870
- throw new Error(
1871
- `OpenCode failed to start on port ${state.port}. Install with: npm install -g opencode-ai`
1872
- );
1873
- }
1874
- log(
1875
- state,
1876
- `OpenCode started on port ${state.port}${health.version ? ` (v${health.version})` : ""}`
1877
- );
1878
- state.opencodeConnected = true;
1879
- state.opencodeVersion = health.version ?? null;
1880
- }
1881
- }
1882
2275
  var AUTH_EXPIRED_EXIT_CODE = 77;
1883
2276
  async function handleAuthError(state, error2) {
1884
2277
  logActivity(state, {
@@ -1888,12 +2281,12 @@ async function handleAuthError(state, error2) {
1888
2281
  if (state.interactive) displayStatus(state);
1889
2282
  if (!state.interactive) {
1890
2283
  blank();
1891
- console.log(chalk5.red("Authentication expired"));
1892
- console.log(chalk5.dim("Your authentication token is no longer valid."));
2284
+ console.log(chalk6.red("Authentication expired"));
2285
+ console.log(chalk6.dim("Your authentication token is no longer valid."));
1893
2286
  blank();
1894
- console.log(chalk5.dim("To fix this:"));
1895
- console.log(chalk5.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
1896
- console.log(chalk5.dim(" 2. Restart this command"));
2287
+ console.log(chalk6.dim("To fix this:"));
2288
+ console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
2289
+ console.log(chalk6.dim(" 2. Restart this command"));
1897
2290
  blank();
1898
2291
  await cleanup(state);
1899
2292
  await shutdownTelemetry();
@@ -1901,7 +2294,7 @@ async function handleAuthError(state, error2) {
1901
2294
  return { success: false };
1902
2295
  }
1903
2296
  blank();
1904
- console.log(chalk5.yellow("Your authentication has expired."));
2297
+ console.log(chalk6.yellow("Your authentication has expired."));
1905
2298
  blank();
1906
2299
  try {
1907
2300
  const credentials2 = await promptForLogin(
@@ -1914,285 +2307,62 @@ async function handleAuthError(state, error2) {
1914
2307
  return { success: false };
1915
2308
  }
1916
2309
  }
1917
- function isNetworkError(error2) {
1918
- if (error2 instanceof Error) {
1919
- const message = error2.message.toLowerCase();
1920
- return message.includes("fetch failed") || message.includes("network") || message.includes("econnrefused") || message.includes("econnreset") || message.includes("etimedout") || message.includes("socket hang up");
1921
- }
1922
- return false;
1923
- }
1924
- async function processQueue(state, authHeader, triggerReconnect) {
2310
+ async function driveChannels(state, driver) {
1925
2311
  let idlePolls = 0;
1926
- let currentAuthHeader = authHeader;
1927
2312
  while (state.running) {
1928
- if (state.reconnecting && state.reconnectPromise) {
1929
- logActivity(state, {
1930
- type: "info",
1931
- message: "Waiting for tunnel reconnection..."
1932
- });
2313
+ if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2314
+ logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
1933
2315
  if (state.interactive) displayStatus(state);
1934
- await state.reconnectPromise;
2316
+ await state.connection.reconnectPromise;
1935
2317
  }
1936
2318
  try {
1937
- const conversations = await getPendingConversations(
1938
- state.agentId,
1939
- currentAuthHeader,
1940
- state.conversationFilter ?? void 0
1941
- );
1942
- state.consecutiveFetchFailures = 0;
1943
- if (conversations.length > 0) {
2319
+ const processed = await driver.drainPending();
2320
+ state.messageCount += processed;
2321
+ if (processed > 0) {
1944
2322
  idlePolls = 0;
1945
- for (const conv of conversations) {
1946
- if (!state.running) break;
1947
- if (!state.lockedConversations.has(conv.id)) {
1948
- const lockResult = await acquireConversationLock(
1949
- state.agentId,
1950
- conv.id,
1951
- state.lockCorrelationId,
1952
- currentAuthHeader
1953
- );
1954
- if (!lockResult.acquired) {
1955
- logActivity(state, {
1956
- type: "info",
1957
- message: `Conversation ${conv.id.slice(0, 8)} locked by another runner \u2014 skipping`
1958
- });
1959
- if (state.interactive) displayStatus(state);
1960
- continue;
1961
- }
1962
- state.lockedConversations.add(conv.id);
1963
- logActivity(state, {
1964
- type: "info",
1965
- message: `Lock acquired on conversation ${conv.id.slice(0, 8)}`
1966
- });
1967
- }
1968
- logActivity(state, {
1969
- type: "info",
1970
- message: `Processing conversation ${conv.id.slice(0, 8)}... (${conv.pending_message_count} pending)`
1971
- });
1972
- if (state.interactive) displayStatus(state);
1973
- let sessionId = state.sessions.get(conv.id);
1974
- if (!sessionId) {
1975
- if (conv.opencode_session_id) {
1976
- sessionId = conv.opencode_session_id;
1977
- } else {
1978
- sessionId = await createOpenCodeSession(state.port);
1979
- await updateConversationSession(state.agentId, conv.id, sessionId, currentAuthHeader);
1980
- logActivity(state, {
1981
- type: "info",
1982
- message: `Created session ${sessionId.slice(0, 8)}`
1983
- });
1984
- }
1985
- state.sessions.set(conv.id, sessionId);
1986
- }
1987
- const messages = await getPendingMessages(state.agentId, conv.id, currentAuthHeader);
1988
- for (const message of messages) {
1989
- if (!state.running) break;
1990
- logActivity(state, {
1991
- type: "info",
1992
- message: `Processing message ${message.id.slice(0, 8)}...`
1993
- });
1994
- if (state.interactive) displayStatus(state);
1995
- const claimed = await markMessageProcessing(
1996
- state.agentId,
1997
- conv.id,
1998
- message.id,
1999
- currentAuthHeader
2000
- );
2001
- if (!claimed) {
2002
- logActivity(state, {
2003
- type: "info",
2004
- message: `Message ${message.id.slice(0, 8)} already claimed`
2005
- });
2006
- continue;
2007
- }
2008
- emitAgentMessageProcessing(state.agentId, {
2009
- message_id: message.id,
2010
- conversation_id: conv.id
2011
- });
2012
- try {
2013
- const result = await sendMessageToOpenCode(
2014
- state.port,
2015
- sessionId,
2016
- message.content,
2017
- {
2018
- agent: message.opencode_agent ?? void 0,
2019
- model: message.opencode_model ?? void 0
2020
- },
2021
- {
2022
- onQuestion: async (question) => {
2023
- try {
2024
- await reportInteractiveEvent(
2025
- state.agentId,
2026
- conv.id,
2027
- "question",
2028
- question,
2029
- currentAuthHeader
2030
- );
2031
- logActivity(state, {
2032
- type: "info",
2033
- message: `Question surfaced to user (id: ${question.id.slice(0, 8)})`
2034
- });
2035
- } catch (err) {
2036
- logActivity(state, {
2037
- type: "error",
2038
- error: `Failed to surface question: ${err}`
2039
- });
2040
- }
2041
- },
2042
- onPermission: async (permission) => {
2043
- try {
2044
- await reportInteractiveEvent(
2045
- state.agentId,
2046
- conv.id,
2047
- "permission",
2048
- permission,
2049
- currentAuthHeader
2050
- );
2051
- logActivity(state, {
2052
- type: "info",
2053
- message: `Permission request surfaced to user (id: ${permission.id.slice(0, 8)})`
2054
- });
2055
- } catch (err) {
2056
- logActivity(state, {
2057
- type: "error",
2058
- error: `Failed to surface permission: ${err}`
2059
- });
2060
- }
2061
- }
2062
- }
2063
- );
2064
- if (result.title) {
2065
- try {
2066
- await updateConversationTitle(
2067
- state.agentId,
2068
- conv.id,
2069
- result.title,
2070
- currentAuthHeader
2071
- );
2072
- } catch {
2073
- }
2074
- }
2075
- await markMessageDone(
2076
- state.agentId,
2077
- conv.id,
2078
- message.id,
2079
- currentAuthHeader,
2080
- sessionId
2081
- );
2082
- state.messageCount++;
2083
- logActivity(state, {
2084
- type: "info",
2085
- message: `Message ${message.id.slice(0, 8)} processed`
2086
- });
2087
- emitAgentMessageDone(state.agentId, {
2088
- message_id: message.id,
2089
- conversation_id: conv.id
2090
- });
2091
- } catch (error2) {
2092
- if (error2 instanceof AuthenticationError) {
2093
- throw error2;
2094
- }
2095
- await markMessageFailed(state.agentId, conv.id, message.id, currentAuthHeader);
2096
- logActivity(state, {
2097
- type: "error",
2098
- error: `Message ${message.id.slice(0, 8)} failed: ${error2}`
2099
- });
2100
- emitAgentMessageFailed(state.agentId, {
2101
- message_id: message.id,
2102
- conversation_id: conv.id,
2103
- error: String(error2)
2104
- });
2105
- }
2106
- if (state.interactive) displayStatus(state);
2107
- }
2108
- }
2109
- } else {
2110
- if (state.idleTimeout !== null) {
2111
- idlePolls++;
2112
- if (idlePolls === 1) {
2113
- logActivity(state, {
2114
- type: "info",
2115
- message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2116
- });
2117
- if (state.interactive) displayStatus(state);
2118
- }
2119
- }
2120
- }
2121
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2122
- if (state.idleTimeout !== null && idlePolls >= 2) {
2123
- const idleMs = idlePolls * MESSAGE_POLL_INTERVAL_MS;
2124
- if (idleMs > state.idleTimeout * 1e3) {
2323
+ if (state.interactive) displayStatus(state);
2324
+ } else if (state.idleTimeout !== null) {
2325
+ idlePolls++;
2326
+ if (idlePolls === 1) {
2125
2327
  logActivity(state, {
2126
2328
  type: "info",
2127
- message: "Idle timeout reached"
2329
+ message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2128
2330
  });
2129
2331
  if (state.interactive) displayStatus(state);
2130
- break;
2131
2332
  }
2132
2333
  }
2133
2334
  } catch (error2) {
2134
- if (error2 instanceof AuthenticationError) {
2335
+ if (error2 instanceof ChannelAuthError) {
2135
2336
  const result = await handleAuthError(state, error2);
2136
2337
  if (result.success && result.newAuthHeader) {
2137
- currentAuthHeader = result.newAuthHeader;
2138
2338
  state.authHeader = result.newAuthHeader;
2139
- logActivity(state, {
2140
- type: "info",
2141
- message: "Continuing with new credentials..."
2142
- });
2339
+ logActivity(state, { type: "info", message: "Continuing with new credentials..." });
2143
2340
  if (state.interactive) displayStatus(state);
2144
2341
  continue;
2145
- } else {
2146
- state.running = false;
2147
- break;
2148
2342
  }
2343
+ state.running = false;
2344
+ break;
2149
2345
  }
2150
2346
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
2151
- logActivity(state, {
2152
- type: "error",
2153
- error: `Queue processing error: ${errorMessage}`
2154
- });
2347
+ logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
2155
2348
  if (state.interactive) displayStatus(state);
2156
- if (isNetworkError(error2)) {
2157
- state.consecutiveFetchFailures++;
2158
- if (state.consecutiveFetchFailures >= MAX_CONSECUTIVE_FETCH_FAILURES) {
2159
- logActivity(state, {
2160
- type: "info",
2161
- message: `Detected ${state.consecutiveFetchFailures} consecutive fetch failures, triggering reconnection...`
2162
- });
2163
- if (state.interactive) displayStatus(state);
2164
- await triggerReconnect();
2165
- state.consecutiveFetchFailures = 0;
2166
- }
2349
+ }
2350
+ await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
2351
+ if (state.idleTimeout !== null && idlePolls >= 2) {
2352
+ const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
2353
+ if (idleMs > state.idleTimeout * 1e3) {
2354
+ logActivity(state, { type: "info", message: "Idle timeout reached" });
2355
+ if (state.interactive) displayStatus(state);
2356
+ break;
2167
2357
  }
2168
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2169
2358
  }
2170
2359
  }
2171
2360
  }
2172
- async function cleanup(state, authHeader) {
2361
+ async function cleanup(state) {
2173
2362
  state.running = false;
2174
- if (state.lockHeartbeatTimer) {
2175
- clearInterval(state.lockHeartbeatTimer);
2176
- state.lockHeartbeatTimer = null;
2177
- }
2178
- if (authHeader && state.lockedConversations.size > 0) {
2179
- for (const convId of state.lockedConversations) {
2180
- await releaseConversationLock(state.agentId, convId, state.lockCorrelationId, authHeader);
2181
- }
2182
- if (state.interactive) {
2183
- logActivity(state, {
2184
- type: "info",
2185
- message: `Released ${state.lockedConversations.size} lock(s)`
2186
- });
2187
- displayStatus(state);
2188
- } else {
2189
- log(state, `Released ${state.lockedConversations.size} lock(s)`);
2190
- }
2191
- state.lockedConversations.clear();
2192
- }
2193
- if (state.tunnelConnection) {
2194
- state.tunnelConnection.close();
2195
- state.tunnelConnection = null;
2363
+ if (state.connection) {
2364
+ state.connection.close();
2365
+ state.connection = null;
2196
2366
  }
2197
2367
  if (state.opencodeProcess) {
2198
2368
  stopOpenCode(state.opencodeProcess);
@@ -2211,7 +2381,6 @@ async function run(options) {
2211
2381
  agentId: options.agent || "",
2212
2382
  agentName: null,
2213
2383
  port: options.port ?? 4096,
2214
- verbose: options.verbose ?? false,
2215
2384
  conversationFilter: options.conversation ?? null,
2216
2385
  idleTimeout: options.idleTimeout ?? null,
2217
2386
  json: options.json ?? false,
@@ -2219,22 +2388,11 @@ async function run(options) {
2219
2388
  connected: false,
2220
2389
  opencodeConnected: false,
2221
2390
  opencodeVersion: null,
2222
- reconnectAttempt: 0,
2223
2391
  opencodeProcess: null,
2224
- tunnelConnection: null,
2392
+ connection: null,
2225
2393
  running: true,
2226
2394
  activityLog: [],
2227
- displayInitialized: false,
2228
- lastActivity: /* @__PURE__ */ new Date(),
2229
- pendingRequests: /* @__PURE__ */ new Map(),
2230
- sessions: /* @__PURE__ */ new Map(),
2231
2395
  messageCount: 0,
2232
- lockCorrelationId: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2233
- lockedConversations: /* @__PURE__ */ new Set(),
2234
- lockHeartbeatTimer: null,
2235
- consecutiveFetchFailures: 0,
2236
- reconnecting: false,
2237
- reconnectPromise: null,
2238
2396
  authHeader: ""
2239
2397
  };
2240
2398
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
@@ -2251,7 +2409,7 @@ async function run(options) {
2251
2409
  } else {
2252
2410
  log(state, "Shutting down...");
2253
2411
  }
2254
- await cleanup(state, state.authHeader);
2412
+ await cleanup(state);
2255
2413
  await shutdownTelemetry();
2256
2414
  process.exit(0);
2257
2415
  };
@@ -2263,13 +2421,13 @@ async function run(options) {
2263
2421
  if (!interactive) {
2264
2422
  printError("Authentication required");
2265
2423
  blank();
2266
- console.log(chalk5.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2267
- console.log(chalk5.dim("Or run `evident login` for interactive authentication"));
2424
+ console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2425
+ console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
2268
2426
  blank();
2269
2427
  process.exit(1);
2270
2428
  }
2271
2429
  blank();
2272
- console.log(chalk5.yellow("You are not logged in to Evident."));
2430
+ console.log(chalk6.yellow("You are not logged in to Evident."));
2273
2431
  blank();
2274
2432
  credentials2 = await promptForLogin(
2275
2433
  "Would you like to log in now?",
@@ -2283,6 +2441,12 @@ async function run(options) {
2283
2441
  if (resolved.agent_id) {
2284
2442
  state.agentId = resolved.agent_id;
2285
2443
  log(state, `Resolved agent ID from key: ${state.agentId}`);
2444
+ if (state.interactive && !state.json) {
2445
+ logActivity(state, {
2446
+ type: "info",
2447
+ message: `Agent ID resolved from key: ${state.agentId}`
2448
+ });
2449
+ }
2286
2450
  } else {
2287
2451
  printError(resolved.error || "Failed to resolve agent ID from key");
2288
2452
  process.exit(1);
@@ -2290,7 +2454,7 @@ async function run(options) {
2290
2454
  } else {
2291
2455
  printError("--agent is required when not using EVIDENT_AGENT_KEY");
2292
2456
  blank();
2293
- console.log(chalk5.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2457
+ console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2294
2458
  blank();
2295
2459
  process.exit(1);
2296
2460
  }
@@ -2309,15 +2473,15 @@ async function run(options) {
2309
2473
  );
2310
2474
  if (interactive && !state.json) {
2311
2475
  blank();
2312
- console.log(chalk5.bold("Evident Run"));
2313
- console.log(chalk5.dim("-".repeat(40)));
2476
+ console.log(chalk6.bold("Evident Run"));
2477
+ console.log(chalk6.dim("-".repeat(40)));
2314
2478
  }
2315
- const spinner = interactive && !state.json ? ora2("Validating agent...").start() : null;
2479
+ const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
2316
2480
  let validation = await getAgentInfo(state.agentId, state.authHeader);
2317
2481
  if (!validation.valid && validation.authFailed && interactive) {
2318
2482
  spinner?.fail("Authentication failed");
2319
2483
  blank();
2320
- console.log(chalk5.yellow("Your authentication token is invalid or expired."));
2484
+ console.log(chalk6.yellow("Your authentication token is invalid or expired."));
2321
2485
  blank();
2322
2486
  credentials2 = await promptForLogin(
2323
2487
  "Would you like to log in again?",
@@ -2333,172 +2497,129 @@ async function run(options) {
2333
2497
  }
2334
2498
  spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
2335
2499
  state.agentName = validation.agent.name;
2336
- const ocSpinner = interactive && !state.json ? ora2("Checking OpenCode...").start() : null;
2500
+ const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
2337
2501
  try {
2338
- await ensureOpenCodeRunning(state);
2502
+ const oc = await ensureOpenCodeRunning({
2503
+ port: state.port,
2504
+ interactive: state.interactive,
2505
+ agentId: state.agentId,
2506
+ log: (message) => log(state, message)
2507
+ });
2508
+ state.port = oc.port;
2509
+ state.opencodeProcess = oc.process;
2510
+ state.opencodeVersion = oc.version;
2511
+ state.opencodeConnected = oc.process !== null || oc.version !== null;
2339
2512
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2340
2513
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2341
2514
  } catch (error2) {
2342
2515
  ocSpinner?.fail(error2.message);
2343
2516
  throw error2;
2344
2517
  }
2345
- const tunnelSpinner = interactive && !state.json ? ora2("Connecting tunnel...").start() : null;
2346
- const connectWithRetry = async (isReconnect = false) => {
2347
- if (isReconnect && state.reconnecting) {
2348
- return;
2349
- }
2350
- state.reconnecting = true;
2351
- if (state.tunnelConnection) {
2352
- try {
2353
- state.tunnelConnection.close();
2354
- } catch {
2355
- }
2356
- state.tunnelConnection = null;
2357
- }
2358
- while (state.running) {
2359
- try {
2360
- state.tunnelConnection = await connectTunnel({
2361
- agentId: state.agentId,
2362
- authHeader: state.authHeader,
2363
- port: state.port,
2364
- onConnected: (agentId) => {
2365
- state.connected = true;
2366
- state.reconnectAttempt = 0;
2367
- state.reconnecting = false;
2368
- state.consecutiveFetchFailures = 0;
2369
- state.agentId = agentId;
2370
- logActivity(state, {
2371
- type: "info",
2372
- message: isReconnect ? `Tunnel reconnected (agent: ${agentId})` : `Tunnel connected (agent: ${agentId})`
2373
- });
2374
- emitAgentConnected(state.agentId, { port: state.port });
2375
- if (state.interactive) displayStatus(state);
2376
- },
2377
- onDisconnected: (code, reason) => {
2378
- state.connected = false;
2518
+ const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
2519
+ const channelDriver = new ChannelDriver({
2520
+ agentId: state.agentId,
2521
+ port: state.port,
2522
+ apiUrl: getApiUrlConfig(),
2523
+ getAuthHeader: () => state.authHeader,
2524
+ conversationFilter: state.conversationFilter,
2525
+ log: (entry) => logActivity(state, {
2526
+ type: entry.level === "error" ? "error" : "info",
2527
+ message: entry.message,
2528
+ error: entry.level === "error" ? entry.message : void 0
2529
+ })
2530
+ });
2531
+ const connection = new RunnerConnection({
2532
+ agentId: state.agentId,
2533
+ getAuthHeader: () => state.authHeader,
2534
+ port: state.port,
2535
+ isRunning: () => state.running,
2536
+ events: {
2537
+ onConnected: (agentId, isReconnect) => {
2538
+ state.connected = true;
2539
+ state.agentId = agentId;
2540
+ logActivity(state, {
2541
+ type: "info",
2542
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
2543
+ });
2544
+ emitAgentConnected(state.agentId, { port: state.port });
2545
+ if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2546
+ if (state.interactive) displayStatus(state);
2547
+ channelDriver.drainPending().then((processed) => {
2548
+ if (processed > 0) {
2549
+ state.messageCount += processed;
2379
2550
  logActivity(state, {
2380
2551
  type: "info",
2381
- message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2552
+ message: `Drained ${processed} queued message(s) on connect`
2382
2553
  });
2383
- emitAgentDisconnected(state.agentId, { code, reason });
2384
- if (state.interactive) displayStatus(state);
2385
- if (state.running && code !== 1e3 && !state.reconnecting) {
2386
- logActivity(state, {
2387
- type: "info",
2388
- message: "Attempting automatic reconnection..."
2389
- });
2390
- if (state.interactive) displayStatus(state);
2391
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2392
- logActivity(state, {
2393
- type: "error",
2394
- error: `Reconnection failed: ${err.message}`
2395
- });
2396
- if (state.interactive) displayStatus(state);
2397
- });
2398
- }
2399
- },
2400
- onError: (error2) => {
2401
- logActivity(state, { type: "error", error: error2 });
2402
- if (state.interactive) displayStatus(state);
2403
- },
2404
- onRequest: (method, path, requestId) => {
2405
- state.pendingRequests.set(requestId, {
2406
- startTime: Date.now(),
2407
- method,
2408
- path
2409
- });
2410
- logActivity(state, { type: "request", method, path, requestId });
2411
- if (state.interactive) displayStatus(state);
2412
- },
2413
- onResponse: (status, durationMs, requestId) => {
2414
- const pending = state.pendingRequests.get(requestId);
2415
- state.pendingRequests.delete(requestId);
2416
- state.opencodeConnected = true;
2417
- const lastEntry = state.activityLog[state.activityLog.length - 1];
2418
- if (lastEntry && lastEntry.requestId === requestId) {
2419
- lastEntry.type = "response";
2420
- lastEntry.status = status;
2421
- lastEntry.durationMs = durationMs;
2422
- } else if (pending) {
2423
- logActivity(state, {
2424
- type: "response",
2425
- method: pending.method,
2426
- path: pending.path,
2427
- status,
2428
- durationMs,
2429
- requestId
2430
- });
2431
- }
2432
- if (state.interactive) displayStatus(state);
2433
- },
2434
- onInfo: (message) => {
2435
- logActivity(state, { type: "info", message });
2436
2554
  if (state.interactive) displayStatus(state);
2437
2555
  }
2556
+ }).catch((error2) => {
2557
+ const message = error2 instanceof Error ? error2.message : String(error2);
2558
+ logActivity(state, {
2559
+ type: "error",
2560
+ error: `Failed to drain queued messages on connect: ${message}`
2561
+ });
2562
+ if (state.interactive) displayStatus(state);
2438
2563
  });
2439
- if (!isReconnect) {
2440
- tunnelSpinner?.succeed("Tunnel connected");
2441
- }
2442
- return;
2443
- } catch (error2) {
2444
- state.reconnectAttempt++;
2445
- const delay = getReconnectDelay(state.reconnectAttempt);
2446
- if (error2.message === "Unauthorized") {
2447
- state.reconnecting = false;
2448
- if (!isReconnect) {
2449
- tunnelSpinner?.fail("Unauthorized");
2450
- }
2451
- throw error2;
2452
- }
2564
+ },
2565
+ onDisconnected: (code, reason) => {
2566
+ state.connected = false;
2453
2567
  logActivity(state, {
2454
- type: "error",
2455
- error: `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`
2568
+ type: "info",
2569
+ message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2456
2570
  });
2571
+ emitAgentDisconnected(state.agentId, { code, reason });
2457
2572
  if (state.interactive) displayStatus(state);
2458
- await new Promise((resolve) => setTimeout(resolve, delay));
2459
- }
2460
- }
2461
- state.reconnecting = false;
2462
- };
2463
- const triggerReconnect = async () => {
2464
- if (!state.reconnecting) {
2465
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2466
- logActivity(state, {
2467
- type: "error",
2468
- error: `Reconnection failed: ${err.message}`
2469
- });
2573
+ },
2574
+ onError: (error2) => {
2575
+ logActivity(state, { type: "error", error: error2 });
2470
2576
  if (state.interactive) displayStatus(state);
2471
- });
2472
- }
2473
- if (state.reconnectPromise) {
2474
- await state.reconnectPromise;
2475
- }
2476
- };
2477
- await connectWithRetry(false);
2478
- state.lockHeartbeatTimer = setInterval(async () => {
2479
- for (const convId of state.lockedConversations) {
2480
- const extended = await extendConversationLock(
2481
- state.agentId,
2482
- convId,
2483
- state.lockCorrelationId,
2484
- state.authHeader
2485
- );
2486
- if (!extended) {
2487
- logActivity(state, {
2488
- type: "error",
2489
- error: `Failed to extend lock on conversation ${convId.slice(0, 8)}`
2577
+ },
2578
+ // Web traffic is proxied transparently; only note opencode is live.
2579
+ onResponse: () => {
2580
+ state.opencodeConnected = true;
2581
+ },
2582
+ // A channel message was queued and the api-worker pinged us over the
2583
+ // tunnel to drain immediately instead of waiting for the next poll tick.
2584
+ // Best-effort + non-fatal: mirror the on-connect drain block. A failed
2585
+ // drain here is logged and swallowed — the steady-state poll retries, so
2586
+ // a lost/failed ping can never orphan a message (§2 invariant).
2587
+ onDrainPing: () => {
2588
+ if (!state.running) return;
2589
+ logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
2590
+ channelDriver.drainPending().then((processed) => {
2591
+ if (processed > 0) {
2592
+ state.messageCount += processed;
2593
+ logActivity(state, {
2594
+ type: "info",
2595
+ message: `Drained ${processed} queued message(s) on ping`
2596
+ });
2597
+ if (state.interactive) displayStatus(state);
2598
+ }
2599
+ }).catch((error2) => {
2600
+ const message = error2 instanceof Error ? error2.message : String(error2);
2601
+ logActivity(state, {
2602
+ type: "error",
2603
+ error: `Failed to drain queued messages on ping: ${message}`
2604
+ });
2605
+ if (state.interactive) displayStatus(state);
2490
2606
  });
2491
- state.lockedConversations.delete(convId);
2492
- }
2607
+ },
2608
+ onInfo: (message) => logActivity(state, { type: "info", message })
2493
2609
  }
2494
- }, LOCK_HEARTBEAT_INTERVAL_MS);
2495
- if (interactive && !state.json) {
2496
- displayStatus(state);
2497
- } else {
2498
- log(state, "Processing queue...");
2610
+ });
2611
+ state.connection = connection;
2612
+ try {
2613
+ await connection.connect();
2614
+ } catch (error2) {
2615
+ if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2616
+ throw error2;
2617
+ }
2618
+ if (!interactive || state.json) {
2619
+ log(state, "Driving channel messages...");
2499
2620
  }
2500
- await processQueue(state, state.authHeader, triggerReconnect);
2501
- await cleanup(state, state.authHeader);
2621
+ await driveChannels(state, channelDriver);
2622
+ await cleanup(state);
2502
2623
  if (state.json) {
2503
2624
  console.log(
2504
2625
  JSON.stringify({
@@ -2512,7 +2633,7 @@ async function run(options) {
2512
2633
  await shutdownTelemetry();
2513
2634
  process.exit(0);
2514
2635
  } catch (error2) {
2515
- await cleanup(state, state.authHeader);
2636
+ await cleanup(state);
2516
2637
  const message = error2 instanceof Error ? error2.message : String(error2);
2517
2638
  if (state.json) {
2518
2639
  console.log(JSON.stringify({ status: "error", error: message }));
@@ -2530,16 +2651,22 @@ async function run(options) {
2530
2651
 
2531
2652
  // src/index.ts
2532
2653
  var program = new Command();
2533
- program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option("-e, --env <environment>", "Environment to use (local, dev, production)", "production").hook("preAction", (thisCommand) => {
2534
- const env = thisCommand.opts().env;
2535
- if (env) {
2536
- setEnvironment(env);
2654
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
2655
+ "--endpoint <url>",
2656
+ "Evident API base URL (default: production; e.g. http://localhost:3001)"
2657
+ ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
2658
+ const { endpoint, tunnel } = thisCommand.opts();
2659
+ if (endpoint) {
2660
+ setEndpoint(endpoint);
2661
+ }
2662
+ if (tunnel) {
2663
+ setTunnelUrl(tunnel);
2537
2664
  }
2538
2665
  });
2539
2666
  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);
2540
- program.command("logout").description("Remove stored credentials").action(logout);
2667
+ 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 }));
2541
2668
  program.command("whoami").description("Show the currently logged in user").action(whoami);
2542
- program.command("run").description("Connect to Evident and process messages").requiredOption("-a, --agent <id>", "Agent ID to connect to").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(
2669
+ 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(
2543
2670
  (options) => {
2544
2671
  run({
2545
2672
  agent: options.agent,