@evident-ai/cli 0.2.1-dev.d55ec9b → 0.2.1-dev.dbc07db

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 process.env.EVIDENT_API_URL ?? endpointOverride ?? defaults.apiUrl;
47
36
  }
48
37
  function getTunnelUrl() {
49
- return process.env.EVIDENT_TUNNEL_URL ?? getEnvConfig().tunnelUrl;
38
+ return process.env.EVIDENT_TUNNEL_URL ?? tunnelOverride ?? defaults.tunnelUrl;
50
39
  }
51
40
  var config = new Conf({
52
41
  projectName: "evident",
@@ -444,9 +433,9 @@ async function whoami() {
444
433
  }
445
434
 
446
435
  // src/commands/run.ts
447
- import chalk5 from "chalk";
448
- import ora2 from "ora";
449
- import { select as select2 } from "@inquirer/prompts";
436
+ import chalk6 from "chalk";
437
+ import ora3 from "ora";
438
+ import { select as select3 } from "@inquirer/prompts";
450
439
 
451
440
  // ../../packages/types/src/telemetry/index.ts
452
441
  var TelemetryEventTypes = {
@@ -459,10 +448,7 @@ var TelemetryEventTypes = {
459
448
  };
460
449
 
461
450
  // ../../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;
451
+ var MAX_FRAME_BYTES = 256 * 1024;
466
452
 
467
453
  // src/lib/telemetry.ts
468
454
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -574,33 +560,6 @@ function emitAgentDisconnected(agentId, metadata) {
574
560
  agent_id: agentId
575
561
  });
576
562
  }
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
563
  var EventTypes = {
605
564
  // Tunnel lifecycle
606
565
  TUNNEL_STARTING: "tunnel.starting",
@@ -665,7 +624,7 @@ function isInteractive(jsonOutput) {
665
624
  // src/lib/opencode/health.ts
666
625
  async function checkOpenCodeHealth(port) {
667
626
  try {
668
- const response = await fetch(`http://localhost:${port}/global/health`, {
627
+ const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
669
628
  signal: AbortSignal.timeout(2e3)
670
629
  // 2 second timeout
671
630
  });
@@ -844,12 +803,12 @@ async function findHealthyOpenCodeInstances() {
844
803
  }
845
804
  async function startOpenCode(port) {
846
805
  let command = "opencode";
847
- let args = ["serve", "--port", port.toString()];
806
+ let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
848
807
  try {
849
808
  execSync("which opencode", { stdio: "ignore" });
850
809
  } catch {
851
810
  command = "npx";
852
- args = ["opencode", "serve", "--port", port.toString()];
811
+ args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
853
812
  }
854
813
  const child = spawn(command, args, {
855
814
  detached: true,
@@ -898,7 +857,7 @@ async function promptOpenCodeInstall(interactive) {
898
857
  error: "OpenCode is not installed",
899
858
  install_url: OPENCODE_INSTALL_URL,
900
859
  install_commands: {
901
- npm: "npm install -g opencode",
860
+ npm: "npm install -g opencode-ai",
902
861
  curl: "curl -fsSL https://opencode.ai/install.sh | sh"
903
862
  }
904
863
  })
@@ -936,7 +895,7 @@ async function promptOpenCodeInstall(interactive) {
936
895
  console.log(chalk4.bold("Install OpenCode using one of these methods:"));
937
896
  blank();
938
897
  console.log(chalk4.dim(" # Option 1: Install via npm (recommended)"));
939
- console.log(` ${chalk4.cyan("npm install -g opencode")}`);
898
+ console.log(` ${chalk4.cyan("npm install -g opencode-ai")}`);
940
899
  blank();
941
900
  console.log(chalk4.dim(" # Option 2: Install via curl"));
942
901
  console.log(` ${chalk4.cyan("curl -fsSL https://opencode.ai/install.sh | sh")}`);
@@ -1082,147 +1041,143 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1082
1041
  }
1083
1042
 
1084
1043
  // src/lib/tunnel/connection.ts
1085
- import WebSocket from "ws";
1044
+ import WebSocket2 from "ws";
1086
1045
 
1087
1046
  // 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")) {
1047
+ import WebSocket from "ws";
1048
+ var LOOPBACK_HOST = "127.0.0.1";
1049
+ var STRIP_REQ = /* @__PURE__ */ new Set([
1050
+ "host",
1051
+ "connection",
1052
+ "keep-alive",
1053
+ "proxy-authorization",
1054
+ "transfer-encoding",
1055
+ "upgrade",
1056
+ "content-length"
1057
+ ]);
1058
+ var STRIP_RES = /* @__PURE__ */ new Set([
1059
+ "connection",
1060
+ "keep-alive",
1061
+ "transfer-encoding",
1062
+ "content-encoding",
1063
+ "content-length"
1064
+ ]);
1065
+ var StreamForwarder = class {
1066
+ constructor(ws, port, callbacks = {}) {
1067
+ this.ws = ws;
1068
+ this.port = port;
1069
+ this.callbacks = callbacks;
1070
+ }
1071
+ inflight = /* @__PURE__ */ new Map();
1072
+ /**
1073
+ * Handle an edge→agent frame. Unknown frame types are ignored.
1074
+ */
1075
+ handleFrame(frame) {
1076
+ switch (frame.type) {
1077
+ case "open":
1078
+ this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
1079
+ void this.handleOpen(frame);
1080
+ break;
1081
+ case "req_data":
1082
+ this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
1083
+ break;
1084
+ case "req_end":
1085
+ this.inflight.get(frame.sid)?.endBody?.();
1086
+ break;
1087
+ case "abort":
1088
+ this.inflight.get(frame.sid)?.abort?.();
1089
+ break;
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Abort every in-flight stream (e.g. on WebSocket close).
1094
+ */
1095
+ abortAll() {
1096
+ for (const stream of this.inflight.values()) {
1107
1097
  try {
1108
- body = JSON.parse(text);
1098
+ stream.abort();
1109
1099
  } catch {
1110
- body = text;
1111
1100
  }
1112
- } else {
1113
- body = text;
1114
1101
  }
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
- };
1102
+ this.inflight.clear();
1125
1103
  }
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;
1104
+ send(frame) {
1105
+ if (this.ws.readyState === WebSocket.OPEN) {
1106
+ this.ws.send(JSON.stringify(frame));
1107
+ }
1139
1108
  }
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
1109
+ async handleOpen(frame) {
1110
+ const { sid, method, path, headers, has_body } = frame;
1111
+ const ac = new AbortController();
1112
+ let bodyPromise;
1113
+ let pushBody;
1114
+ let endBody;
1115
+ if (has_body) {
1116
+ const chunks = [];
1117
+ bodyPromise = new Promise((resolve) => {
1118
+ pushBody = (buf) => {
1119
+ chunks.push(buf);
1120
+ };
1121
+ endBody = () => {
1122
+ resolve(Buffer.concat(chunks));
1123
+ };
1124
+ });
1125
+ }
1126
+ const fwdHeaders = {};
1127
+ for (const [k, v] of Object.entries(headers ?? {})) {
1128
+ if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
1129
+ }
1130
+ this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
1131
+ const body = bodyPromise ? await bodyPromise : void 0;
1132
+ if (ac.signal.aborted) {
1133
+ this.inflight.delete(sid);
1134
+ return;
1135
+ }
1136
+ let upstream;
1137
+ try {
1138
+ upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
1139
+ method,
1140
+ headers: fwdHeaders,
1141
+ body,
1142
+ redirect: "manual",
1143
+ signal: ac.signal
1144
+ });
1145
+ } catch (err) {
1146
+ this.inflight.delete(sid);
1147
+ if (!ac.signal.aborted) {
1148
+ this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
1153
1149
  }
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
1150
+ return;
1151
+ }
1152
+ const resHeaders = {};
1153
+ upstream.headers.forEach((value, key) => {
1154
+ if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1188
1155
  });
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 {
1156
+ this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1157
+ this.callbacks.onHead?.(sid, upstream.status);
1158
+ try {
1159
+ if (upstream.body) {
1160
+ const reader = upstream.body.getReader();
1161
+ while (true) {
1162
+ const { done, value } = await reader.read();
1163
+ if (done) break;
1164
+ const chunk = Buffer.from(value);
1165
+ for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
1166
+ const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
1167
+ this.send({ type: "res_data", sid, b64: slice.toString("base64") });
1213
1168
  }
1214
1169
  }
1215
1170
  }
1171
+ this.send({ type: "res_end", sid });
1172
+ } catch (err) {
1173
+ if (!ac.signal.aborted) {
1174
+ this.send({ type: "res_err", sid, message: String(err) });
1175
+ }
1176
+ } finally {
1177
+ this.inflight.delete(sid);
1216
1178
  }
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
1179
  }
1225
- }
1180
+ };
1226
1181
 
1227
1182
  // src/lib/tunnel/connection.ts
1228
1183
  var MAX_RECONNECT_DELAY = 3e4;
@@ -1232,6 +1187,33 @@ function getReconnectDelay(attempt) {
1232
1187
  const jitter = Math.random() * 1e3;
1233
1188
  return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
1234
1189
  }
1190
+ function describeSocketError(error2, url) {
1191
+ const code = error2.code;
1192
+ switch (code) {
1193
+ case "ECONNREFUSED":
1194
+ return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
1195
+ case "ENOTFOUND":
1196
+ return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
1197
+ case "ETIMEDOUT":
1198
+ return `connection timed out to ${url} (ETIMEDOUT)`;
1199
+ case "ECONNRESET":
1200
+ return `connection reset by ${url} (ECONNRESET)`;
1201
+ default: {
1202
+ const base = error2.message?.trim();
1203
+ const suffix = code ? ` (${code})` : "";
1204
+ return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
1205
+ }
1206
+ }
1207
+ }
1208
+ var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
1209
+ "open",
1210
+ "req_data",
1211
+ "req_end",
1212
+ "abort"
1213
+ ]);
1214
+ function isStreamFrame(message) {
1215
+ return STREAM_FRAME_TYPES.has(message.type);
1216
+ }
1235
1217
  function connectTunnel(options) {
1236
1218
  const {
1237
1219
  agentId,
@@ -1246,326 +1228,688 @@ function connectTunnel(options) {
1246
1228
  } = options;
1247
1229
  const tunnelUrl = getTunnelUrlConfig();
1248
1230
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1249
- const activeEventSubscriptions = /* @__PURE__ */ new Map();
1250
1231
  return new Promise((resolve, reject) => {
1251
- const ws = new WebSocket(url, {
1232
+ const ws = new WebSocket2(url, {
1252
1233
  headers: {
1253
1234
  Authorization: authHeader
1254
1235
  }
1255
1236
  });
1237
+ const streamStartTimes = /* @__PURE__ */ new Map();
1238
+ const forwarder = new StreamForwarder(ws, port, {
1239
+ onOpen: (sid, method, path) => {
1240
+ streamStartTimes.set(sid, Date.now());
1241
+ onRequest?.(method, path, sid);
1242
+ },
1243
+ onHead: (sid, status) => {
1244
+ const startedAt = streamStartTimes.get(sid);
1245
+ streamStartTimes.delete(sid);
1246
+ onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1247
+ }
1248
+ });
1256
1249
  const connectionTimeout = setTimeout(() => {
1257
1250
  ws.close();
1258
1251
  reject(new Error("Connection timeout"));
1259
1252
  }, 3e4);
1253
+ let upgradeRejection = null;
1254
+ ws.on("unexpected-response", (_req, res) => {
1255
+ clearTimeout(connectionTimeout);
1256
+ const chunks = [];
1257
+ res.on("data", (chunk) => chunks.push(chunk));
1258
+ res.on("end", () => {
1259
+ const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
1260
+ let detail = bodyRaw;
1261
+ try {
1262
+ const parsed = JSON.parse(bodyRaw);
1263
+ detail = parsed.error ?? parsed.message ?? bodyRaw;
1264
+ if (parsed.details) detail += ` (${parsed.details})`;
1265
+ } catch {
1266
+ }
1267
+ const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
1268
+ upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
1269
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
1270
+ reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
1271
+ });
1272
+ });
1260
1273
  ws.on("open", () => {
1261
1274
  onInfo?.("WebSocket connection established");
1262
1275
  });
1263
- ws.on("message", async (data) => {
1276
+ ws.on("message", (data) => {
1277
+ let message;
1264
1278
  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
- }
1279
+ message = JSON.parse(data.toString());
1323
1280
  } catch (error2) {
1324
1281
  const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1325
1282
  onError?.(`Failed to handle message: ${errorMessage}`);
1283
+ return;
1284
+ }
1285
+ if (isStreamFrame(message)) {
1286
+ forwarder.handleFrame(message);
1287
+ return;
1288
+ }
1289
+ switch (message.type) {
1290
+ case "connected": {
1291
+ clearTimeout(connectionTimeout);
1292
+ const connectedAgentId = message.agent_id ?? agentId;
1293
+ onConnected?.(connectedAgentId);
1294
+ resolve({
1295
+ ws,
1296
+ close: () => ws.close(1e3, "CLI shutdown")
1297
+ });
1298
+ break;
1299
+ }
1300
+ case "error":
1301
+ clearTimeout(connectionTimeout);
1302
+ onError?.(message.message || "Unknown tunnel error");
1303
+ if (message.code === "unauthorized") {
1304
+ ws.close();
1305
+ reject(new Error("Unauthorized"));
1306
+ }
1307
+ break;
1308
+ case "ping":
1309
+ ws.send(JSON.stringify({ type: "pong" }));
1310
+ break;
1326
1311
  }
1327
1312
  });
1328
1313
  ws.on("error", (error2) => {
1329
1314
  clearTimeout(connectionTimeout);
1330
- onError?.(`Connection error: ${error2.message}`);
1331
- reject(error2);
1315
+ const detail = upgradeRejection ?? describeSocketError(error2, url);
1316
+ onError?.(`Connection error: ${detail}`);
1317
+ reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
1332
1318
  });
1333
1319
  ws.on("close", (code, reason) => {
1334
- const reasonStr = reason.toString() || "No reason provided";
1320
+ const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1321
+ forwarder.abortAll();
1322
+ streamStartTimes.clear();
1335
1323
  onDisconnected?.(code, reasonStr);
1336
- for (const [, controller] of activeEventSubscriptions) {
1337
- controller.abort();
1338
- }
1339
- activeEventSubscriptions.clear();
1340
1324
  });
1341
1325
  });
1342
1326
  }
1343
1327
 
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 };
1328
+ // src/lib/tunnel/runner-connection.ts
1329
+ var RunnerConnection = class {
1330
+ opts;
1331
+ sleep;
1332
+ connection = null;
1333
+ resolvedAgentId;
1334
+ /** True while a (re)connect loop is in flight. */
1335
+ reconnecting = false;
1336
+ /** The in-flight reconnect promise, awaitable by the caller. */
1337
+ reconnectPromise = null;
1338
+ /** 1-based count of the current reconnect attempt streak. */
1339
+ reconnectAttempt = 0;
1340
+ constructor(opts) {
1341
+ this.opts = opts;
1342
+ this.resolvedAgentId = opts.agentId;
1343
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1344
+ }
1345
+ get agentId() {
1346
+ return this.resolvedAgentId;
1347
+ }
1348
+ /** Establish the initial tunnel connection (with retry/backoff). */
1349
+ async connect() {
1350
+ await this.connectWithRetry(false);
1351
+ }
1352
+ /** Close the active connection (idempotent). */
1353
+ close() {
1354
+ if (this.connection) {
1355
+ try {
1356
+ this.connection.close();
1357
+ } catch {
1358
+ }
1359
+ this.connection = null;
1361
1360
  }
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
1361
  }
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
- };
1362
+ async connectWithRetry(isReconnect) {
1363
+ if (isReconnect && this.reconnecting) return;
1364
+ this.reconnecting = true;
1365
+ this.close();
1366
+ const { events } = this.opts;
1367
+ while (this.opts.isRunning()) {
1368
+ try {
1369
+ this.connection = await connectTunnel({
1370
+ agentId: this.resolvedAgentId,
1371
+ authHeader: this.opts.getAuthHeader(),
1372
+ port: this.opts.port,
1373
+ onConnected: (agentId) => {
1374
+ this.reconnectAttempt = 0;
1375
+ this.reconnecting = false;
1376
+ this.resolvedAgentId = agentId;
1377
+ events.onConnected(agentId, isReconnect);
1378
+ },
1379
+ onDisconnected: (code, reason) => {
1380
+ events.onDisconnected(code, reason);
1381
+ if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
1382
+ this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
1383
+ events.onError?.(`Reconnection failed: ${err.message}`);
1384
+ });
1385
+ }
1386
+ },
1387
+ onError: (error2) => events.onError?.(error2),
1388
+ onResponse: () => events.onResponse?.(),
1389
+ onInfo: (message) => events.onInfo?.(message)
1390
+ });
1391
+ return;
1392
+ } catch (error2) {
1393
+ this.reconnectAttempt++;
1394
+ if (error2.message === "Unauthorized") {
1395
+ this.reconnecting = false;
1396
+ throw error2;
1397
+ }
1398
+ const delay = getReconnectDelay(this.reconnectAttempt);
1399
+ events.onReconnecting?.(this.reconnectAttempt);
1400
+ events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
1401
+ await this.sleep(delay);
1402
+ }
1391
1403
  }
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}` };
1404
+ this.reconnecting = false;
1396
1405
  }
1397
- }
1398
- var AuthenticationError = class extends Error {
1406
+ };
1407
+
1408
+ // src/lib/channels/driver.ts
1409
+ var DEFAULT_RETRY_POLICY = {
1410
+ maxAttempts: 6,
1411
+ baseDelayMs: 500,
1412
+ maxDelayMs: 3e4
1413
+ };
1414
+ var ChannelAuthError = class extends Error {
1399
1415
  constructor(message) {
1400
1416
  super(message);
1401
- this.name = "AuthenticationError";
1417
+ this.name = "ChannelAuthError";
1402
1418
  }
1403
1419
  };
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);
1420
+ function backoffDelay(attempt, policy) {
1421
+ const exp = policy.baseDelayMs * Math.pow(2, attempt);
1422
+ const capped = Math.min(policy.maxDelayMs, exp);
1423
+ return Math.floor(Math.random() * capped);
1424
+ }
1425
+ function isRetryableStatus(status) {
1426
+ return status === 429 || status >= 500 && status <= 599;
1427
+ }
1428
+ var ChannelDriver = class {
1429
+ agentId;
1430
+ port;
1431
+ apiUrl;
1432
+ getAuthHeader;
1433
+ conversationFilter;
1434
+ retry;
1435
+ log;
1436
+ fetchImpl;
1437
+ sleep;
1438
+ /** Cache of conversationId → opencode sessionId. */
1439
+ sessions = /* @__PURE__ */ new Map();
1440
+ /** Serialises drains so a reconnect during a drain doesn't double-process. */
1441
+ draining = false;
1442
+ constructor(config2) {
1443
+ this.agentId = config2.agentId;
1444
+ this.port = config2.port;
1445
+ this.apiUrl = config2.apiUrl.replace(/\/$/, "");
1446
+ this.getAuthHeader = config2.getAuthHeader;
1447
+ this.conversationFilter = config2.conversationFilter ?? null;
1448
+ this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
1449
+ this.log = config2.log ?? (() => {
1450
+ });
1451
+ this.fetchImpl = config2.fetchImpl ?? fetch;
1452
+ this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1424
1453
  }
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}`);
1454
+ /** The IPv4-loopback base URL for the local `opencode serve`. */
1455
+ get opencodeBase() {
1456
+ return `http://127.0.0.1:${this.port}`;
1436
1457
  }
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" })
1447
- }
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 })
1458
+ // -------------------------------------------------------------------------
1459
+ // Public API
1460
+ // -------------------------------------------------------------------------
1461
+ /**
1462
+ * Drain all pending channel conversations once: poll → process → callback.
1463
+ * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1464
+ * Re-entrant calls while a drain is in flight are skipped (return 0).
1465
+ *
1466
+ * @returns the number of messages processed.
1467
+ */
1468
+ async drainPending() {
1469
+ if (this.draining) return 0;
1470
+ this.draining = true;
1471
+ let processed = 0;
1472
+ try {
1473
+ const conversations = await this.getPendingConversations();
1474
+ for (const conv of conversations) {
1475
+ processed += await this.processConversation(conv);
1476
+ }
1477
+ } finally {
1478
+ this.draining = false;
1479
+ }
1480
+ return processed;
1481
+ }
1482
+ // -------------------------------------------------------------------------
1483
+ // Conversation processing
1484
+ // -------------------------------------------------------------------------
1485
+ async processConversation(conv) {
1486
+ const sessionId = await this.ensureSession(conv);
1487
+ const messages = await this.getPendingMessages(conv.id);
1488
+ let processed = 0;
1489
+ for (const message of messages) {
1490
+ const claimed = await this.markProcessing(conv.id, message.id);
1491
+ if (!claimed) {
1492
+ this.log({
1493
+ level: "info",
1494
+ message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
1495
+ conversation_id: conv.id,
1496
+ message_id: message.id
1497
+ });
1498
+ continue;
1499
+ }
1500
+ try {
1501
+ await sendMessageToOpenCode(
1502
+ this.port,
1503
+ sessionId,
1504
+ message.content,
1505
+ {
1506
+ agent: message.opencode_agent ?? void 0,
1507
+ model: message.opencode_model ?? void 0
1508
+ },
1509
+ {
1510
+ onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1511
+ onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1512
+ }
1513
+ );
1514
+ await this.confirmCompletion(sessionId);
1515
+ await this.markDone(conv.id, message.id, sessionId);
1516
+ processed += 1;
1517
+ this.log({
1518
+ level: "info",
1519
+ message: `Message ${message.id.slice(0, 8)} processed`,
1520
+ conversation_id: conv.id,
1521
+ message_id: message.id
1522
+ });
1523
+ } catch (err) {
1524
+ if (err instanceof ChannelAuthError) throw err;
1525
+ await this.markFailed(conv.id, message.id).catch(() => {
1526
+ });
1527
+ this.log({
1528
+ level: "error",
1529
+ message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1530
+ conversation_id: conv.id,
1531
+ message_id: message.id
1532
+ });
1533
+ }
1460
1534
  }
1461
- );
1462
- checkAuthResponse(response, "reporting interactive event");
1463
- if (!response.ok) {
1464
- throw new Error(`Failed to report interactive event: HTTP ${response.status}`);
1465
- }
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;
1535
+ return processed;
1472
1536
  }
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)
1479
- }
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" })
1537
+ async ensureSession(conv) {
1538
+ const cached = this.sessions.get(conv.id);
1539
+ if (cached) return cached;
1540
+ if (conv.opencode_session_id) {
1541
+ this.sessions.set(conv.id, conv.opencode_session_id);
1542
+ return conv.opencode_session_id;
1491
1543
  }
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 })
1544
+ const sessionId = await createOpenCodeSession(this.port);
1545
+ this.sessions.set(conv.id, sessionId);
1546
+ await this.persistSession(conv.id, sessionId).catch(() => {
1502
1547
  });
1503
- checkAuthResponse(response, "acquiring conversation lock");
1504
- if (response.status === 409) {
1505
- return { acquired: false, error: "Conversation already locked by another runner" };
1506
- }
1507
- if (!response.ok) {
1508
- return { acquired: false, error: `Failed to acquire lock: HTTP ${response.status}` };
1548
+ return sessionId;
1549
+ }
1550
+ /**
1551
+ * Local reconcile: re-query `GET /session/:id` and check `time.completed`.
1552
+ * Best-effort — if opencode is unreachable or the field is absent we proceed
1553
+ * to mark done anyway (the blocking call already returned).
1554
+ */
1555
+ async confirmCompletion(sessionId) {
1556
+ try {
1557
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
1558
+ if (!res.ok) return;
1559
+ const session = await res.json();
1560
+ if (session.time && session.time.completed == null) {
1561
+ this.log({
1562
+ level: "info",
1563
+ message: `Session ${sessionId.slice(0, 8)} not marked completed on reconcile \u2014 delivering anyway`
1564
+ });
1565
+ }
1566
+ } catch {
1509
1567
  }
1510
- return { acquired: true };
1511
- } catch (error2) {
1512
- if (error2 instanceof AuthenticationError) throw error2;
1513
- return { acquired: false, error: String(error2) };
1514
1568
  }
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`,
1569
+ // -------------------------------------------------------------------------
1570
+ // Evident API calls (combinedAuth thread routes)
1571
+ // -------------------------------------------------------------------------
1572
+ async getPendingConversations() {
1573
+ const res = await this.fetchImpl(
1574
+ `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
1521
1575
  {
1522
- method: "POST",
1523
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1524
- body: JSON.stringify({ correlation_id: correlationId })
1576
+ headers: { Authorization: this.getAuthHeader() }
1525
1577
  }
1526
1578
  );
1527
- return response.ok;
1528
- } catch {
1529
- return false;
1579
+ this.assertAuth(res, "fetching pending conversations");
1580
+ if (!res.ok) {
1581
+ throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
1582
+ }
1583
+ const data = await res.json();
1584
+ let conversations = data.conversations;
1585
+ if (this.conversationFilter) {
1586
+ conversations = conversations.filter((c) => c.id === this.conversationFilter);
1587
+ }
1588
+ return conversations;
1530
1589
  }
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)}`,
1590
+ async getPendingMessages(conversationId) {
1591
+ const res = await this.fetchImpl(
1592
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
1593
+ { headers: { Authorization: this.getAuthHeader() } }
1594
+ );
1595
+ this.assertAuth(res, "fetching pending messages");
1596
+ if (!res.ok) {
1597
+ throw new Error(`Failed to get messages: HTTP ${res.status}`);
1598
+ }
1599
+ return await res.json();
1600
+ }
1601
+ async markProcessing(conversationId, messageId) {
1602
+ const res = await this.fetchImpl(
1603
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1537
1604
  {
1538
- method: "DELETE",
1539
- headers: { Authorization: authHeader }
1605
+ method: "PATCH",
1606
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1607
+ body: JSON.stringify({ status: "processing" })
1540
1608
  }
1541
1609
  );
1542
- } catch {
1610
+ this.assertAuth(res, "marking message as processing");
1611
+ return res.ok;
1543
1612
  }
1544
- }
1545
- async function updateConversationSession(agentId, conversationId, sessionId, authHeader) {
1546
- 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 })
1613
+ /**
1614
+ * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1615
+ * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
1616
+ * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1617
+ * for an already-`done` message a no-op (no double Slack post).
1618
+ */
1619
+ async markDone(conversationId, messageId, sessionId) {
1620
+ await this.callWithRetry(
1621
+ "marking message as done",
1622
+ () => this.fetchImpl(
1623
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1624
+ {
1625
+ method: "PATCH",
1626
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1627
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1628
+ }
1629
+ )
1630
+ );
1631
+ }
1632
+ async markFailed(conversationId, messageId) {
1633
+ await this.callWithRetry(
1634
+ "marking message as failed",
1635
+ () => this.fetchImpl(
1636
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1637
+ {
1638
+ method: "PATCH",
1639
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1640
+ body: JSON.stringify({ status: "failed" })
1641
+ }
1642
+ )
1643
+ );
1644
+ }
1645
+ async persistSession(conversationId, sessionId) {
1646
+ const res = await this.fetchImpl(
1647
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
1648
+ {
1649
+ method: "PATCH",
1650
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1651
+ body: JSON.stringify({ opencode_session_id: sessionId })
1652
+ }
1653
+ );
1654
+ this.assertAuth(res, "persisting session id");
1655
+ }
1656
+ /**
1657
+ * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1658
+ * `POST .../interactive-event {type, data}`. The server persists the
1659
+ * interaction and posts a link to the proxied opencode-web conversation.
1660
+ */
1661
+ async reportInteraction(conversationId, type, data) {
1662
+ try {
1663
+ await this.callWithRetry(
1664
+ "reporting interactive event",
1665
+ () => this.fetchImpl(
1666
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
1667
+ {
1668
+ method: "POST",
1669
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1670
+ body: JSON.stringify({ type, data })
1671
+ }
1672
+ )
1673
+ );
1674
+ this.log({
1675
+ level: "info",
1676
+ message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1677
+ conversation_id: conversationId
1678
+ });
1679
+ } catch (err) {
1680
+ if (err instanceof ChannelAuthError) throw err;
1681
+ this.log({
1682
+ level: "error",
1683
+ message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1684
+ conversation_id: conversationId
1685
+ });
1686
+ }
1687
+ }
1688
+ // -------------------------------------------------------------------------
1689
+ // Retry wrapper
1690
+ // -------------------------------------------------------------------------
1691
+ /**
1692
+ * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
1693
+ * network errors) with exponential backoff + jitter (capped). Auth failures
1694
+ * (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
1695
+ * terminal too. No on-disk persistence — a crash mid-retry drops the callback
1696
+ * (accepted by ADR-0039).
1697
+ */
1698
+ async callWithRetry(context, call) {
1699
+ let lastError;
1700
+ for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
1701
+ let res;
1702
+ try {
1703
+ res = await call();
1704
+ } catch (err) {
1705
+ lastError = err;
1706
+ if (attempt < this.retry.maxAttempts - 1) {
1707
+ await this.sleep(backoffDelay(attempt, this.retry));
1708
+ continue;
1709
+ }
1710
+ throw err;
1711
+ }
1712
+ if (res.status === 401 || res.status === 403) {
1713
+ throw new ChannelAuthError(
1714
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1715
+ );
1716
+ }
1717
+ if (res.ok) return;
1718
+ if (isRetryableStatus(res.status)) {
1719
+ lastError = new Error(`${context}: HTTP ${res.status}`);
1720
+ if (attempt < this.retry.maxAttempts - 1) {
1721
+ await this.sleep(backoffDelay(attempt, this.retry));
1722
+ continue;
1723
+ }
1724
+ }
1725
+ throw new Error(`${context}: HTTP ${res.status}`);
1726
+ }
1727
+ throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1728
+ }
1729
+ assertAuth(res, context) {
1730
+ if (res.status === 401 || res.status === 403) {
1731
+ throw new ChannelAuthError(
1732
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1733
+ );
1734
+ }
1735
+ }
1736
+ };
1737
+
1738
+ // src/commands/ensure-opencode.ts
1739
+ import chalk5 from "chalk";
1740
+ import ora2 from "ora";
1741
+ import { select as select2 } from "@inquirer/prompts";
1742
+ async function ensureOpenCodeRunning(ctx) {
1743
+ const healthCheck = await checkOpenCodeHealth(ctx.port);
1744
+ if (healthCheck.healthy) {
1745
+ return { port: ctx.port, process: null, version: healthCheck.version ?? null };
1746
+ }
1747
+ const runningInstances = await findHealthyOpenCodeInstances();
1748
+ if (runningInstances.length > 0) {
1749
+ if (!ctx.interactive) {
1750
+ throw new Error(
1751
+ `OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
1752
+ );
1753
+ }
1754
+ blank();
1755
+ console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
1756
+ for (const instance of runningInstances) {
1757
+ const ver = instance.version ? ` (v${instance.version})` : "";
1758
+ const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
1759
+ console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
1760
+ }
1761
+ blank();
1762
+ if (runningInstances.length === 1) {
1763
+ console.log(chalk5.yellow("Tip: Run with the correct port:"));
1764
+ console.log(
1765
+ chalk5.dim(
1766
+ ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
1767
+ )
1768
+ );
1769
+ }
1770
+ blank();
1771
+ throw new Error(`OpenCode not running on port ${ctx.port}`);
1772
+ }
1773
+ if (!isOpenCodeInstalled()) {
1774
+ if (!ctx.interactive) {
1775
+ throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
1776
+ }
1777
+ const result = await promptOpenCodeInstall(true);
1778
+ if (result === "exit") process.exit(0);
1779
+ if (result !== "installed" && !isOpenCodeInstalled()) {
1780
+ throw new Error("OpenCode is not installed");
1781
+ }
1782
+ }
1783
+ if (!ctx.interactive) {
1784
+ ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
1785
+ const proc = await startOpenCode(ctx.port);
1786
+ const health = await waitForOpenCodeHealth(ctx.port, 3e4);
1787
+ if (!health.healthy) {
1788
+ throw new Error(
1789
+ `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
1790
+ );
1791
+ }
1792
+ ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
1793
+ return { port: ctx.port, process: proc, version: health.version ?? null };
1794
+ }
1795
+ let port = ctx.port;
1796
+ if (isPortInUse(port)) {
1797
+ console.log(chalk5.yellow(`
1798
+ Port ${port} is already in use.`));
1799
+ const alternativePort = findAvailablePort(port + 1);
1800
+ if (alternativePort) {
1801
+ const useAlternative = await select2({
1802
+ message: `Use port ${alternativePort} instead?`,
1803
+ choices: [
1804
+ { name: `Yes, use port ${alternativePort}`, value: "yes" },
1805
+ { name: "No, I will free the port manually", value: "no" }
1806
+ ]
1807
+ });
1808
+ if (useAlternative === "yes") {
1809
+ port = alternativePort;
1810
+ } else {
1811
+ throw new Error(`Port ${ctx.port} is in use`);
1812
+ }
1813
+ }
1814
+ }
1815
+ const action = await select2({
1816
+ message: "OpenCode is not running. What would you like to do?",
1817
+ choices: [
1818
+ {
1819
+ name: "Start OpenCode for me",
1820
+ value: "start",
1821
+ description: `Run 'opencode serve --port ${port}'`
1822
+ },
1823
+ {
1824
+ name: "Show me the command",
1825
+ value: "manual",
1826
+ description: "Display the command to run manually"
1827
+ },
1828
+ {
1829
+ name: "Continue without OpenCode",
1830
+ value: "continue",
1831
+ description: "Requests will fail until OpenCode starts"
1832
+ }
1833
+ ]
1551
1834
  });
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}` : ""}`
1835
+ if (action === "manual") {
1836
+ blank();
1837
+ console.log(chalk5.bold("Run this command in another terminal:"));
1838
+ blank();
1839
+ console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
1840
+ blank();
1841
+ throw new Error("Please start OpenCode manually");
1842
+ }
1843
+ if (action === "start") {
1844
+ const spinner = ora2("Starting OpenCode...").start();
1845
+ const proc = await startOpenCode(port);
1846
+ const health = await waitForOpenCodeHealth(port, 3e4);
1847
+ if (!health.healthy) {
1848
+ spinner.fail("Failed to start OpenCode");
1849
+ throw new Error("OpenCode failed to start");
1850
+ }
1851
+ spinner.succeed(
1852
+ `OpenCode running on port ${port}${health.version ? ` (v${health.version})` : ""}`
1557
1853
  );
1854
+ return { port, process: proc, version: health.version ?? null };
1558
1855
  }
1856
+ return { port, process: null, version: null };
1559
1857
  }
1560
- async function updateConversationTitle(agentId, conversationId, title, authHeader) {
1858
+
1859
+ // src/commands/agent-lookup.ts
1860
+ async function resolveAgentIdFromKey(authHeader) {
1561
1861
  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");
1862
+ try {
1863
+ const response = await fetch(`${apiUrl}/me`, {
1864
+ headers: { Authorization: authHeader }
1865
+ });
1866
+ if (!response.ok) {
1867
+ return { error: `Failed to resolve agent from key: HTTP ${response.status}` };
1868
+ }
1869
+ const data = await response.json();
1870
+ if (data.auth_type === "agent_key" && data.agent_id) {
1871
+ return { agent_id: data.agent_id };
1872
+ }
1873
+ return {
1874
+ error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
1875
+ };
1876
+ } catch (error2) {
1877
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
1878
+ return { error: `Failed to resolve agent from key: ${message}` };
1879
+ }
1568
1880
  }
1881
+ async function getAgentInfo(agentId, authHeader) {
1882
+ const apiUrl = getApiUrlConfig();
1883
+ try {
1884
+ const response = await fetch(`${apiUrl}/agents/${agentId}`, {
1885
+ headers: { Authorization: authHeader }
1886
+ });
1887
+ if (response.status === 404) {
1888
+ return { valid: false, error: "Agent not found" };
1889
+ }
1890
+ if (response.status === 401) {
1891
+ return { valid: false, error: "Authentication failed", authFailed: true };
1892
+ }
1893
+ if (!response.ok) {
1894
+ return { valid: false, error: `API error: ${response.status}` };
1895
+ }
1896
+ const agent = await response.json();
1897
+ if (agent.agent_type !== "local") {
1898
+ return {
1899
+ valid: false,
1900
+ error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
1901
+ };
1902
+ }
1903
+ return { valid: true, agent };
1904
+ } catch (error2) {
1905
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
1906
+ return { valid: false, error: `Failed to validate agent: ${message}` };
1907
+ }
1908
+ }
1909
+
1910
+ // src/commands/run.ts
1911
+ var MAX_ACTIVITY_LOG_ENTRIES = 10;
1912
+ var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
1569
1913
  function log(state, message, isError = false) {
1570
1914
  if (state.json) {
1571
1915
  console.log(
@@ -1576,7 +1920,7 @@ function log(state, message, isError = false) {
1576
1920
  })
1577
1921
  );
1578
1922
  } else if (!state.interactive) {
1579
- const prefix = isError ? chalk5.red("\u2717") : chalk5.green("\u2022");
1923
+ const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
1580
1924
  console.log(`${prefix} ${message}`);
1581
1925
  }
1582
1926
  }
@@ -1589,7 +1933,6 @@ function logActivity(state, entry) {
1589
1933
  if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
1590
1934
  state.activityLog.shift();
1591
1935
  }
1592
- state.lastActivity = fullEntry.timestamp;
1593
1936
  if (!state.interactive) {
1594
1937
  if (entry.type === "error") {
1595
1938
  log(state, entry.error ?? "Unknown error", true);
@@ -1598,130 +1941,21 @@ function logActivity(state, entry) {
1598
1941
  }
1599
1942
  }
1600
1943
  }
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
1944
  function displayStatus(state) {
1647
1945
  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`)
1946
+ const attempt = state.connection?.reconnectAttempt ?? 0;
1947
+ const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
1948
+ const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
1949
+ const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
1950
+ const last = state.activityLog[state.activityLog.length - 1];
1951
+ const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
1952
+ const agent = state.agentName ?? state.agentId;
1953
+ console.log(
1954
+ `${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
1700
1955
  );
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
1956
  }
1723
1957
  async function promptForLogin(promptMessage, successMessage) {
1724
- const action = await select2({
1958
+ const action = await select3({
1725
1959
  message: promptMessage,
1726
1960
  choices: [
1727
1961
  {
@@ -1737,7 +1971,7 @@ async function promptForLogin(promptMessage, successMessage) {
1737
1971
  ]
1738
1972
  });
1739
1973
  if (action === "exit") {
1740
- console.log(chalk5.dim(`
1974
+ console.log(chalk6.dim(`
1741
1975
  You can log in later by running: ${getCliName()} login`));
1742
1976
  process.exit(0);
1743
1977
  }
@@ -1748,126 +1982,10 @@ You can log in later by running: ${getCliName()} login`));
1748
1982
  process.exit(1);
1749
1983
  }
1750
1984
  blank();
1751
- console.log(chalk5.green(successMessage));
1985
+ console.log(chalk6.green(successMessage));
1752
1986
  blank();
1753
1987
  return { token: credentials2.token, authType: "bearer", user: credentials2.user };
1754
1988
  }
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");
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
- throw new Error(
1867
- `OpenCode is not running on port ${state.port}. Start it with: opencode serve --port ${state.port}`
1868
- );
1869
- }
1870
- }
1871
1989
  var AUTH_EXPIRED_EXIT_CODE = 77;
1872
1990
  async function handleAuthError(state, error2) {
1873
1991
  logActivity(state, {
@@ -1877,12 +1995,12 @@ async function handleAuthError(state, error2) {
1877
1995
  if (state.interactive) displayStatus(state);
1878
1996
  if (!state.interactive) {
1879
1997
  blank();
1880
- console.log(chalk5.red("Authentication expired"));
1881
- console.log(chalk5.dim("Your authentication token is no longer valid."));
1998
+ console.log(chalk6.red("Authentication expired"));
1999
+ console.log(chalk6.dim("Your authentication token is no longer valid."));
1882
2000
  blank();
1883
- console.log(chalk5.dim("To fix this:"));
1884
- console.log(chalk5.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
1885
- console.log(chalk5.dim(" 2. Restart this command"));
2001
+ console.log(chalk6.dim("To fix this:"));
2002
+ console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
2003
+ console.log(chalk6.dim(" 2. Restart this command"));
1886
2004
  blank();
1887
2005
  await cleanup(state);
1888
2006
  await shutdownTelemetry();
@@ -1890,7 +2008,7 @@ async function handleAuthError(state, error2) {
1890
2008
  return { success: false };
1891
2009
  }
1892
2010
  blank();
1893
- console.log(chalk5.yellow("Your authentication has expired."));
2011
+ console.log(chalk6.yellow("Your authentication has expired."));
1894
2012
  blank();
1895
2013
  try {
1896
2014
  const credentials2 = await promptForLogin(
@@ -1903,282 +2021,62 @@ async function handleAuthError(state, error2) {
1903
2021
  return { success: false };
1904
2022
  }
1905
2023
  }
1906
- function isNetworkError(error2) {
1907
- if (error2 instanceof Error) {
1908
- const message = error2.message.toLowerCase();
1909
- return message.includes("fetch failed") || message.includes("network") || message.includes("econnrefused") || message.includes("econnreset") || message.includes("etimedout") || message.includes("socket hang up");
1910
- }
1911
- return false;
1912
- }
1913
- async function processQueue(state, authHeader, triggerReconnect) {
1914
- let idleStart = null;
1915
- let currentAuthHeader = authHeader;
2024
+ async function driveChannels(state, driver) {
2025
+ let idlePolls = 0;
1916
2026
  while (state.running) {
1917
- if (state.reconnecting && state.reconnectPromise) {
1918
- logActivity(state, {
1919
- type: "info",
1920
- message: "Waiting for tunnel reconnection..."
1921
- });
2027
+ if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2028
+ logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
1922
2029
  if (state.interactive) displayStatus(state);
1923
- await state.reconnectPromise;
2030
+ await state.connection.reconnectPromise;
1924
2031
  }
1925
2032
  try {
1926
- const conversations = await getPendingConversations(
1927
- state.agentId,
1928
- currentAuthHeader,
1929
- state.conversationFilter ?? void 0
1930
- );
1931
- state.consecutiveFetchFailures = 0;
1932
- if (conversations.length > 0) {
1933
- idleStart = null;
1934
- for (const conv of conversations) {
1935
- if (!state.running) break;
1936
- if (!state.lockedConversations.has(conv.id)) {
1937
- const lockResult = await acquireConversationLock(
1938
- state.agentId,
1939
- conv.id,
1940
- state.lockCorrelationId,
1941
- currentAuthHeader
1942
- );
1943
- if (!lockResult.acquired) {
1944
- logActivity(state, {
1945
- type: "info",
1946
- message: `Conversation ${conv.id.slice(0, 8)} locked by another runner \u2014 skipping`
1947
- });
1948
- if (state.interactive) displayStatus(state);
1949
- continue;
1950
- }
1951
- state.lockedConversations.add(conv.id);
1952
- logActivity(state, {
1953
- type: "info",
1954
- message: `Lock acquired on conversation ${conv.id.slice(0, 8)}`
1955
- });
1956
- }
2033
+ const processed = await driver.drainPending();
2034
+ state.messageCount += processed;
2035
+ if (processed > 0) {
2036
+ idlePolls = 0;
2037
+ if (state.interactive) displayStatus(state);
2038
+ } else if (state.idleTimeout !== null) {
2039
+ idlePolls++;
2040
+ if (idlePolls === 1) {
1957
2041
  logActivity(state, {
1958
2042
  type: "info",
1959
- message: `Processing conversation ${conv.id.slice(0, 8)}... (${conv.pending_message_count} pending)`
2043
+ message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
1960
2044
  });
1961
2045
  if (state.interactive) displayStatus(state);
1962
- let sessionId = state.sessions.get(conv.id);
1963
- if (!sessionId) {
1964
- if (conv.opencode_session_id) {
1965
- sessionId = conv.opencode_session_id;
1966
- } else {
1967
- sessionId = await createOpenCodeSession(state.port);
1968
- await updateConversationSession(state.agentId, conv.id, sessionId, currentAuthHeader);
1969
- logActivity(state, {
1970
- type: "info",
1971
- message: `Created session ${sessionId.slice(0, 8)}`
1972
- });
1973
- }
1974
- state.sessions.set(conv.id, sessionId);
1975
- }
1976
- const messages = await getPendingMessages(state.agentId, conv.id, currentAuthHeader);
1977
- for (const message of messages) {
1978
- if (!state.running) break;
1979
- logActivity(state, {
1980
- type: "info",
1981
- message: `Processing message ${message.id.slice(0, 8)}...`
1982
- });
1983
- if (state.interactive) displayStatus(state);
1984
- const claimed = await markMessageProcessing(
1985
- state.agentId,
1986
- conv.id,
1987
- message.id,
1988
- currentAuthHeader
1989
- );
1990
- if (!claimed) {
1991
- logActivity(state, {
1992
- type: "info",
1993
- message: `Message ${message.id.slice(0, 8)} already claimed`
1994
- });
1995
- continue;
1996
- }
1997
- emitAgentMessageProcessing(state.agentId, {
1998
- message_id: message.id,
1999
- conversation_id: conv.id
2000
- });
2001
- try {
2002
- const result = await sendMessageToOpenCode(
2003
- state.port,
2004
- sessionId,
2005
- message.content,
2006
- {
2007
- agent: message.opencode_agent ?? void 0,
2008
- model: message.opencode_model ?? void 0
2009
- },
2010
- {
2011
- onQuestion: async (question) => {
2012
- try {
2013
- await reportInteractiveEvent(
2014
- state.agentId,
2015
- conv.id,
2016
- "question",
2017
- question,
2018
- currentAuthHeader
2019
- );
2020
- logActivity(state, {
2021
- type: "info",
2022
- message: `Question surfaced to user (id: ${question.id.slice(0, 8)})`
2023
- });
2024
- } catch (err) {
2025
- logActivity(state, {
2026
- type: "error",
2027
- error: `Failed to surface question: ${err}`
2028
- });
2029
- }
2030
- },
2031
- onPermission: async (permission) => {
2032
- try {
2033
- await reportInteractiveEvent(
2034
- state.agentId,
2035
- conv.id,
2036
- "permission",
2037
- permission,
2038
- currentAuthHeader
2039
- );
2040
- logActivity(state, {
2041
- type: "info",
2042
- message: `Permission request surfaced to user (id: ${permission.id.slice(0, 8)})`
2043
- });
2044
- } catch (err) {
2045
- logActivity(state, {
2046
- type: "error",
2047
- error: `Failed to surface permission: ${err}`
2048
- });
2049
- }
2050
- }
2051
- }
2052
- );
2053
- if (result.title) {
2054
- try {
2055
- await updateConversationTitle(
2056
- state.agentId,
2057
- conv.id,
2058
- result.title,
2059
- currentAuthHeader
2060
- );
2061
- } catch {
2062
- }
2063
- }
2064
- await markMessageDone(
2065
- state.agentId,
2066
- conv.id,
2067
- message.id,
2068
- currentAuthHeader,
2069
- sessionId
2070
- );
2071
- state.messageCount++;
2072
- logActivity(state, {
2073
- type: "info",
2074
- message: `Message ${message.id.slice(0, 8)} processed`
2075
- });
2076
- emitAgentMessageDone(state.agentId, {
2077
- message_id: message.id,
2078
- conversation_id: conv.id
2079
- });
2080
- } catch (error2) {
2081
- if (error2 instanceof AuthenticationError) {
2082
- throw error2;
2083
- }
2084
- await markMessageFailed(state.agentId, conv.id, message.id, currentAuthHeader);
2085
- logActivity(state, {
2086
- type: "error",
2087
- error: `Message ${message.id.slice(0, 8)} failed: ${error2}`
2088
- });
2089
- emitAgentMessageFailed(state.agentId, {
2090
- message_id: message.id,
2091
- conversation_id: conv.id,
2092
- error: String(error2)
2093
- });
2094
- }
2095
- if (state.interactive) displayStatus(state);
2096
- }
2097
- }
2098
- } else {
2099
- if (state.idleTimeout !== null) {
2100
- if (idleStart === null) {
2101
- idleStart = Date.now();
2102
- logActivity(state, {
2103
- type: "info",
2104
- message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2105
- });
2106
- if (state.interactive) displayStatus(state);
2107
- }
2108
- if (Date.now() - idleStart > state.idleTimeout * 1e3) {
2109
- logActivity(state, {
2110
- type: "info",
2111
- message: "Idle timeout reached"
2112
- });
2113
- if (state.interactive) displayStatus(state);
2114
- break;
2115
- }
2116
2046
  }
2117
2047
  }
2118
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2119
2048
  } catch (error2) {
2120
- if (error2 instanceof AuthenticationError) {
2049
+ if (error2 instanceof ChannelAuthError) {
2121
2050
  const result = await handleAuthError(state, error2);
2122
2051
  if (result.success && result.newAuthHeader) {
2123
- currentAuthHeader = result.newAuthHeader;
2124
2052
  state.authHeader = result.newAuthHeader;
2125
- logActivity(state, {
2126
- type: "info",
2127
- message: "Continuing with new credentials..."
2128
- });
2053
+ logActivity(state, { type: "info", message: "Continuing with new credentials..." });
2129
2054
  if (state.interactive) displayStatus(state);
2130
2055
  continue;
2131
- } else {
2132
- state.running = false;
2133
- break;
2134
2056
  }
2057
+ state.running = false;
2058
+ break;
2135
2059
  }
2136
2060
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
2137
- logActivity(state, {
2138
- type: "error",
2139
- error: `Queue processing error: ${errorMessage}`
2140
- });
2061
+ logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
2141
2062
  if (state.interactive) displayStatus(state);
2142
- if (isNetworkError(error2)) {
2143
- state.consecutiveFetchFailures++;
2144
- if (state.consecutiveFetchFailures >= MAX_CONSECUTIVE_FETCH_FAILURES) {
2145
- logActivity(state, {
2146
- type: "info",
2147
- message: `Detected ${state.consecutiveFetchFailures} consecutive fetch failures, triggering reconnection...`
2148
- });
2149
- if (state.interactive) displayStatus(state);
2150
- await triggerReconnect();
2151
- state.consecutiveFetchFailures = 0;
2152
- }
2063
+ }
2064
+ await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
2065
+ if (state.idleTimeout !== null && idlePolls >= 2) {
2066
+ const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
2067
+ if (idleMs > state.idleTimeout * 1e3) {
2068
+ logActivity(state, { type: "info", message: "Idle timeout reached" });
2069
+ if (state.interactive) displayStatus(state);
2070
+ break;
2153
2071
  }
2154
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2155
2072
  }
2156
2073
  }
2157
2074
  }
2158
- async function cleanup(state, authHeader) {
2075
+ async function cleanup(state) {
2159
2076
  state.running = false;
2160
- if (state.lockHeartbeatTimer) {
2161
- clearInterval(state.lockHeartbeatTimer);
2162
- state.lockHeartbeatTimer = null;
2163
- }
2164
- if (authHeader && state.lockedConversations.size > 0) {
2165
- for (const convId of state.lockedConversations) {
2166
- await releaseConversationLock(state.agentId, convId, state.lockCorrelationId, authHeader);
2167
- }
2168
- if (state.interactive) {
2169
- logActivity(state, {
2170
- type: "info",
2171
- message: `Released ${state.lockedConversations.size} lock(s)`
2172
- });
2173
- displayStatus(state);
2174
- } else {
2175
- log(state, `Released ${state.lockedConversations.size} lock(s)`);
2176
- }
2177
- state.lockedConversations.clear();
2178
- }
2179
- if (state.tunnelConnection) {
2180
- state.tunnelConnection.close();
2181
- state.tunnelConnection = null;
2077
+ if (state.connection) {
2078
+ state.connection.close();
2079
+ state.connection = null;
2182
2080
  }
2183
2081
  if (state.opencodeProcess) {
2184
2082
  stopOpenCode(state.opencodeProcess);
@@ -2197,7 +2095,6 @@ async function run(options) {
2197
2095
  agentId: options.agent || "",
2198
2096
  agentName: null,
2199
2097
  port: options.port ?? 4096,
2200
- verbose: options.verbose ?? false,
2201
2098
  conversationFilter: options.conversation ?? null,
2202
2099
  idleTimeout: options.idleTimeout ?? null,
2203
2100
  json: options.json ?? false,
@@ -2205,22 +2102,11 @@ async function run(options) {
2205
2102
  connected: false,
2206
2103
  opencodeConnected: false,
2207
2104
  opencodeVersion: null,
2208
- reconnectAttempt: 0,
2209
2105
  opencodeProcess: null,
2210
- tunnelConnection: null,
2106
+ connection: null,
2211
2107
  running: true,
2212
2108
  activityLog: [],
2213
- displayInitialized: false,
2214
- lastActivity: /* @__PURE__ */ new Date(),
2215
- pendingRequests: /* @__PURE__ */ new Map(),
2216
- sessions: /* @__PURE__ */ new Map(),
2217
2109
  messageCount: 0,
2218
- lockCorrelationId: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2219
- lockedConversations: /* @__PURE__ */ new Set(),
2220
- lockHeartbeatTimer: null,
2221
- consecutiveFetchFailures: 0,
2222
- reconnecting: false,
2223
- reconnectPromise: null,
2224
2110
  authHeader: ""
2225
2111
  };
2226
2112
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
@@ -2237,7 +2123,7 @@ async function run(options) {
2237
2123
  } else {
2238
2124
  log(state, "Shutting down...");
2239
2125
  }
2240
- await cleanup(state, state.authHeader);
2126
+ await cleanup(state);
2241
2127
  await shutdownTelemetry();
2242
2128
  process.exit(0);
2243
2129
  };
@@ -2249,13 +2135,13 @@ async function run(options) {
2249
2135
  if (!interactive) {
2250
2136
  printError("Authentication required");
2251
2137
  blank();
2252
- console.log(chalk5.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2253
- console.log(chalk5.dim("Or run `evident login` for interactive authentication"));
2138
+ console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2139
+ console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
2254
2140
  blank();
2255
2141
  process.exit(1);
2256
2142
  }
2257
2143
  blank();
2258
- console.log(chalk5.yellow("You are not logged in to Evident."));
2144
+ console.log(chalk6.yellow("You are not logged in to Evident."));
2259
2145
  blank();
2260
2146
  credentials2 = await promptForLogin(
2261
2147
  "Would you like to log in now?",
@@ -2269,6 +2155,12 @@ async function run(options) {
2269
2155
  if (resolved.agent_id) {
2270
2156
  state.agentId = resolved.agent_id;
2271
2157
  log(state, `Resolved agent ID from key: ${state.agentId}`);
2158
+ if (state.interactive && !state.json) {
2159
+ logActivity(state, {
2160
+ type: "info",
2161
+ message: `Agent ID resolved from key: ${state.agentId}`
2162
+ });
2163
+ }
2272
2164
  } else {
2273
2165
  printError(resolved.error || "Failed to resolve agent ID from key");
2274
2166
  process.exit(1);
@@ -2276,7 +2168,7 @@ async function run(options) {
2276
2168
  } else {
2277
2169
  printError("--agent is required when not using EVIDENT_AGENT_KEY");
2278
2170
  blank();
2279
- console.log(chalk5.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2171
+ console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2280
2172
  blank();
2281
2173
  process.exit(1);
2282
2174
  }
@@ -2295,15 +2187,15 @@ async function run(options) {
2295
2187
  );
2296
2188
  if (interactive && !state.json) {
2297
2189
  blank();
2298
- console.log(chalk5.bold("Evident Run"));
2299
- console.log(chalk5.dim("-".repeat(40)));
2190
+ console.log(chalk6.bold("Evident Run"));
2191
+ console.log(chalk6.dim("-".repeat(40)));
2300
2192
  }
2301
- const spinner = interactive && !state.json ? ora2("Validating agent...").start() : null;
2193
+ const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
2302
2194
  let validation = await getAgentInfo(state.agentId, state.authHeader);
2303
2195
  if (!validation.valid && validation.authFailed && interactive) {
2304
2196
  spinner?.fail("Authentication failed");
2305
2197
  blank();
2306
- console.log(chalk5.yellow("Your authentication token is invalid or expired."));
2198
+ console.log(chalk6.yellow("Your authentication token is invalid or expired."));
2307
2199
  blank();
2308
2200
  credentials2 = await promptForLogin(
2309
2201
  "Would you like to log in again?",
@@ -2319,172 +2211,90 @@ async function run(options) {
2319
2211
  }
2320
2212
  spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
2321
2213
  state.agentName = validation.agent.name;
2322
- const ocSpinner = interactive && !state.json ? ora2("Checking OpenCode...").start() : null;
2214
+ const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
2323
2215
  try {
2324
- await ensureOpenCodeRunning(state);
2216
+ const oc = await ensureOpenCodeRunning({
2217
+ port: state.port,
2218
+ interactive: state.interactive,
2219
+ agentId: state.agentId,
2220
+ log: (message) => log(state, message)
2221
+ });
2222
+ state.port = oc.port;
2223
+ state.opencodeProcess = oc.process;
2224
+ state.opencodeVersion = oc.version;
2225
+ state.opencodeConnected = oc.process !== null || oc.version !== null;
2325
2226
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2326
2227
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2327
2228
  } catch (error2) {
2328
2229
  ocSpinner?.fail(error2.message);
2329
2230
  throw error2;
2330
2231
  }
2331
- const tunnelSpinner = interactive && !state.json ? ora2("Connecting tunnel...").start() : null;
2332
- const connectWithRetry = async (isReconnect = false) => {
2333
- if (isReconnect && state.reconnecting) {
2334
- return;
2335
- }
2336
- state.reconnecting = true;
2337
- if (state.tunnelConnection) {
2338
- try {
2339
- state.tunnelConnection.close();
2340
- } catch {
2341
- }
2342
- state.tunnelConnection = null;
2343
- }
2344
- while (state.running) {
2345
- try {
2346
- state.tunnelConnection = await connectTunnel({
2347
- agentId: state.agentId,
2348
- authHeader: state.authHeader,
2349
- port: state.port,
2350
- onConnected: (agentId) => {
2351
- state.connected = true;
2352
- state.reconnectAttempt = 0;
2353
- state.reconnecting = false;
2354
- state.consecutiveFetchFailures = 0;
2355
- state.agentId = agentId;
2356
- logActivity(state, {
2357
- type: "info",
2358
- message: isReconnect ? `Tunnel reconnected (agent: ${agentId})` : `Tunnel connected (agent: ${agentId})`
2359
- });
2360
- emitAgentConnected(state.agentId, { port: state.port });
2361
- if (state.interactive) displayStatus(state);
2362
- },
2363
- onDisconnected: (code, reason) => {
2364
- state.connected = false;
2365
- logActivity(state, {
2366
- type: "info",
2367
- message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2368
- });
2369
- emitAgentDisconnected(state.agentId, { code, reason });
2370
- if (state.interactive) displayStatus(state);
2371
- if (state.running && code !== 1e3 && !state.reconnecting) {
2372
- logActivity(state, {
2373
- type: "info",
2374
- message: "Attempting automatic reconnection..."
2375
- });
2376
- if (state.interactive) displayStatus(state);
2377
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2378
- logActivity(state, {
2379
- type: "error",
2380
- error: `Reconnection failed: ${err.message}`
2381
- });
2382
- if (state.interactive) displayStatus(state);
2383
- });
2384
- }
2385
- },
2386
- onError: (error2) => {
2387
- logActivity(state, { type: "error", error: error2 });
2388
- if (state.interactive) displayStatus(state);
2389
- },
2390
- onRequest: (method, path, requestId) => {
2391
- state.pendingRequests.set(requestId, {
2392
- startTime: Date.now(),
2393
- method,
2394
- path
2395
- });
2396
- logActivity(state, { type: "request", method, path, requestId });
2397
- if (state.interactive) displayStatus(state);
2398
- },
2399
- onResponse: (status, durationMs, requestId) => {
2400
- const pending = state.pendingRequests.get(requestId);
2401
- state.pendingRequests.delete(requestId);
2402
- state.opencodeConnected = true;
2403
- const lastEntry = state.activityLog[state.activityLog.length - 1];
2404
- if (lastEntry && lastEntry.requestId === requestId) {
2405
- lastEntry.type = "response";
2406
- lastEntry.status = status;
2407
- lastEntry.durationMs = durationMs;
2408
- } else if (pending) {
2409
- logActivity(state, {
2410
- type: "response",
2411
- method: pending.method,
2412
- path: pending.path,
2413
- status,
2414
- durationMs,
2415
- requestId
2416
- });
2417
- }
2418
- if (state.interactive) displayStatus(state);
2419
- },
2420
- onInfo: (message) => {
2421
- logActivity(state, { type: "info", message });
2422
- if (state.interactive) displayStatus(state);
2423
- }
2424
- });
2425
- if (!isReconnect) {
2426
- tunnelSpinner?.succeed("Tunnel connected");
2427
- }
2428
- return;
2429
- } catch (error2) {
2430
- state.reconnectAttempt++;
2431
- const delay = getReconnectDelay(state.reconnectAttempt);
2432
- if (error2.message === "Unauthorized") {
2433
- state.reconnecting = false;
2434
- if (!isReconnect) {
2435
- tunnelSpinner?.fail("Unauthorized");
2436
- }
2437
- throw error2;
2438
- }
2232
+ const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
2233
+ const channelDriver = new ChannelDriver({
2234
+ agentId: state.agentId,
2235
+ port: state.port,
2236
+ apiUrl: getApiUrlConfig(),
2237
+ getAuthHeader: () => state.authHeader,
2238
+ conversationFilter: state.conversationFilter,
2239
+ log: (entry) => logActivity(state, {
2240
+ type: entry.level === "error" ? "error" : "info",
2241
+ message: entry.message,
2242
+ error: entry.level === "error" ? entry.message : void 0
2243
+ })
2244
+ });
2245
+ const connection = new RunnerConnection({
2246
+ agentId: state.agentId,
2247
+ getAuthHeader: () => state.authHeader,
2248
+ port: state.port,
2249
+ isRunning: () => state.running,
2250
+ events: {
2251
+ onConnected: (agentId, isReconnect) => {
2252
+ state.connected = true;
2253
+ state.agentId = agentId;
2439
2254
  logActivity(state, {
2440
- type: "error",
2441
- error: `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`
2255
+ type: "info",
2256
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
2442
2257
  });
2258
+ emitAgentConnected(state.agentId, { port: state.port });
2259
+ if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2443
2260
  if (state.interactive) displayStatus(state);
2444
- await new Promise((resolve) => setTimeout(resolve, delay));
2445
- }
2446
- }
2447
- state.reconnecting = false;
2448
- };
2449
- const triggerReconnect = async () => {
2450
- if (!state.reconnecting) {
2451
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2452
- logActivity(state, {
2453
- type: "error",
2454
- error: `Reconnection failed: ${err.message}`
2261
+ channelDriver.drainPending().catch(() => {
2455
2262
  });
2456
- if (state.interactive) displayStatus(state);
2457
- });
2458
- }
2459
- if (state.reconnectPromise) {
2460
- await state.reconnectPromise;
2461
- }
2462
- };
2463
- await connectWithRetry(false);
2464
- state.lockHeartbeatTimer = setInterval(async () => {
2465
- for (const convId of state.lockedConversations) {
2466
- const extended = await extendConversationLock(
2467
- state.agentId,
2468
- convId,
2469
- state.lockCorrelationId,
2470
- state.authHeader
2471
- );
2472
- if (!extended) {
2263
+ },
2264
+ onDisconnected: (code, reason) => {
2265
+ state.connected = false;
2473
2266
  logActivity(state, {
2474
- type: "error",
2475
- error: `Failed to extend lock on conversation ${convId.slice(0, 8)}`
2267
+ type: "info",
2268
+ message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2476
2269
  });
2477
- state.lockedConversations.delete(convId);
2478
- }
2270
+ emitAgentDisconnected(state.agentId, { code, reason });
2271
+ if (state.interactive) displayStatus(state);
2272
+ },
2273
+ onError: (error2) => {
2274
+ logActivity(state, { type: "error", error: error2 });
2275
+ if (state.interactive) displayStatus(state);
2276
+ },
2277
+ // Web traffic is proxied transparently; only note opencode is live.
2278
+ onResponse: () => {
2279
+ state.opencodeConnected = true;
2280
+ },
2281
+ onInfo: (message) => logActivity(state, { type: "info", message })
2479
2282
  }
2480
- }, LOCK_HEARTBEAT_INTERVAL_MS);
2283
+ });
2284
+ state.connection = connection;
2285
+ try {
2286
+ await connection.connect();
2287
+ } catch (error2) {
2288
+ if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2289
+ throw error2;
2290
+ }
2481
2291
  if (interactive && !state.json) {
2482
2292
  displayStatus(state);
2483
2293
  } else {
2484
- log(state, "Processing queue...");
2294
+ log(state, "Driving channel messages...");
2485
2295
  }
2486
- await processQueue(state, state.authHeader, triggerReconnect);
2487
- await cleanup(state, state.authHeader);
2296
+ await driveChannels(state, channelDriver);
2297
+ await cleanup(state);
2488
2298
  if (state.json) {
2489
2299
  console.log(
2490
2300
  JSON.stringify({
@@ -2498,7 +2308,7 @@ async function run(options) {
2498
2308
  await shutdownTelemetry();
2499
2309
  process.exit(0);
2500
2310
  } catch (error2) {
2501
- await cleanup(state, state.authHeader);
2311
+ await cleanup(state);
2502
2312
  const message = error2 instanceof Error ? error2.message : String(error2);
2503
2313
  if (state.json) {
2504
2314
  console.log(JSON.stringify({ status: "error", error: message }));
@@ -2516,16 +2326,22 @@ async function run(options) {
2516
2326
 
2517
2327
  // src/index.ts
2518
2328
  var program = new Command();
2519
- 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) => {
2520
- const env = thisCommand.opts().env;
2521
- if (env) {
2522
- setEnvironment(env);
2329
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
2330
+ "--endpoint <url>",
2331
+ "Evident API base URL (default: production; e.g. http://localhost:3001)"
2332
+ ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
2333
+ const { endpoint, tunnel } = thisCommand.opts();
2334
+ if (endpoint) {
2335
+ setEndpoint(endpoint);
2336
+ }
2337
+ if (tunnel) {
2338
+ setTunnelUrl(tunnel);
2523
2339
  }
2524
2340
  });
2525
2341
  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);
2526
2342
  program.command("logout").description("Remove stored credentials").action(logout);
2527
2343
  program.command("whoami").description("Show the currently logged in user").action(whoami);
2528
- 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(
2344
+ 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(
2529
2345
  (options) => {
2530
2346
  run({
2531
2347
  agent: options.agent,