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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,41 +12,30 @@ import chalk2 from "chalk";
12
12
  import Conf from "conf";
13
13
  import { homedir } from "os";
14
14
  import { join } from "path";
15
- var environmentPresets = {
16
- local: {
17
- apiUrl: "http://localhost:3001/v1",
18
- tunnelUrl: "ws://localhost:8787"
19
- },
20
- dev: {
21
- apiUrl: "https://api.dev.evident.run/v1",
22
- tunnelUrl: "wss://tunnel.dev.evident.run"
23
- },
24
- production: {
25
- // Production URLs also have aliases: api.evident.run, tunnel.evident.run
26
- apiUrl: "https://api.production.evident.run/v1",
27
- tunnelUrl: "wss://tunnel.production.evident.run"
28
- }
15
+ var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
16
+ var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
17
+ var defaults = {
18
+ apiUrl: PRODUCTION_API_URL,
19
+ tunnelUrl: PRODUCTION_TUNNEL_URL
29
20
  };
30
- var defaults = environmentPresets.production;
31
- var currentEnvironment = "production";
32
- function setEnvironment(env) {
33
- currentEnvironment = env;
34
- }
35
- function getEnvironment() {
36
- const envVar = process.env.EVIDENT_ENV;
37
- if (envVar && environmentPresets[envVar]) {
38
- return envVar;
21
+ var endpointOverride;
22
+ var tunnelOverride;
23
+ function setEndpoint(url) {
24
+ if (!url) {
25
+ endpointOverride = void 0;
26
+ return;
39
27
  }
40
- return currentEnvironment;
28
+ const trimmed = url.replace(/\/+$/, "");
29
+ endpointOverride = /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
41
30
  }
42
- function getEnvConfig() {
43
- return environmentPresets[getEnvironment()];
31
+ function setTunnelUrl(url) {
32
+ tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
44
33
  }
45
34
  function getApiUrl() {
46
- return process.env.EVIDENT_API_URL ?? getEnvConfig().apiUrl;
35
+ return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
47
36
  }
48
37
  function getTunnelUrl() {
49
- return process.env.EVIDENT_TUNNEL_URL ?? getEnvConfig().tunnelUrl;
38
+ return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
50
39
  }
51
40
  var config = new Conf({
52
41
  projectName: "evident",
@@ -65,19 +54,28 @@ function getApiUrlConfig() {
65
54
  function getTunnelUrlConfig() {
66
55
  return getTunnelUrl();
67
56
  }
57
+ function credentialsKey() {
58
+ return getApiUrl();
59
+ }
68
60
  function getCredentials() {
69
- return {
70
- token: credentials.get("token"),
71
- user: credentials.get("user"),
72
- expiresAt: credentials.get("expiresAt")
73
- };
61
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
62
+ return byEndpoint[credentialsKey()] ?? {};
74
63
  }
75
64
  function setCredentials(creds) {
76
- if (creds.token) credentials.set("token", creds.token);
77
- if (creds.user) credentials.set("user", creds.user);
78
- if (creds.expiresAt) credentials.set("expiresAt", creds.expiresAt);
65
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
66
+ byEndpoint[credentialsKey()] = {
67
+ token: creds.token,
68
+ user: creds.user,
69
+ expiresAt: creds.expiresAt
70
+ };
71
+ credentials.set("byEndpoint", byEndpoint);
79
72
  }
80
73
  function clearCredentials() {
74
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
75
+ delete byEndpoint[credentialsKey()];
76
+ credentials.set("byEndpoint", byEndpoint);
77
+ }
78
+ function clearAllCredentials() {
81
79
  credentials.clear();
82
80
  }
83
81
  function getCliName() {
@@ -187,7 +185,6 @@ var api = {
187
185
 
188
186
  // src/lib/keychain.ts
189
187
  var SERVICE_NAME = "evident-cli";
190
- var ACCOUNT_NAME = "default";
191
188
  async function getKeytar() {
192
189
  try {
193
190
  const keytar = await import("keytar");
@@ -199,10 +196,13 @@ async function getKeytar() {
199
196
  return null;
200
197
  }
201
198
  }
199
+ function keychainAccount() {
200
+ return getApiUrlConfig();
201
+ }
202
202
  async function storeToken(credentials2) {
203
203
  const keytar = await getKeytar();
204
204
  if (keytar) {
205
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
205
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
206
206
  } else {
207
207
  setCredentials({
208
208
  token: credentials2.token,
@@ -214,12 +214,13 @@ async function storeToken(credentials2) {
214
214
  async function getToken() {
215
215
  const keytar = await getKeytar();
216
216
  if (keytar) {
217
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
217
+ const account = keychainAccount();
218
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
218
219
  if (stored) {
219
220
  try {
220
221
  return JSON.parse(stored);
221
222
  } catch {
222
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
223
+ await keytar.deletePassword(SERVICE_NAME, account);
223
224
  return null;
224
225
  }
225
226
  }
@@ -234,12 +235,26 @@ async function getToken() {
234
235
  }
235
236
  return null;
236
237
  }
237
- async function deleteToken() {
238
+ async function deleteToken(options = {}) {
238
239
  const keytar = await getKeytar();
239
240
  if (keytar) {
240
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
241
+ if (options.all) {
242
+ const all = await keytar.findCredentials(SERVICE_NAME).catch(() => []);
243
+ await Promise.all(
244
+ all.map(
245
+ (entry) => keytar.deletePassword(SERVICE_NAME, entry.account).catch(() => {
246
+ })
247
+ )
248
+ );
249
+ } else {
250
+ await keytar.deletePassword(SERVICE_NAME, keychainAccount());
251
+ }
252
+ }
253
+ if (options.all) {
254
+ clearAllCredentials();
255
+ } else {
256
+ clearCredentials();
241
257
  }
242
- clearCredentials();
243
258
  }
244
259
 
245
260
  // src/utils/ui.ts
@@ -407,25 +422,32 @@ async function login(options) {
407
422
  }
408
423
 
409
424
  // src/commands/logout.ts
410
- async function logout() {
425
+ async function logout(options = {}) {
426
+ if (options.all) {
427
+ await deleteToken({ all: true });
428
+ printSuccess("Logged out of all endpoints.");
429
+ return;
430
+ }
411
431
  const credentials2 = await getToken();
412
432
  if (!credentials2) {
413
- printWarning("You are not logged in.");
433
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
414
434
  return;
415
435
  }
416
436
  await deleteToken();
417
- printSuccess("Logged out successfully.");
437
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
418
438
  }
419
439
 
420
440
  // src/commands/whoami.ts
421
441
  import chalk3 from "chalk";
422
442
  async function whoami() {
443
+ const apiUrl = getApiUrlConfig();
423
444
  const credentials2 = await getToken();
424
445
  if (!credentials2) {
425
- printError("Not logged in. Run the `login` command to authenticate.");
446
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
426
447
  process.exit(1);
427
448
  }
428
449
  blank();
450
+ console.log(keyValue("Endpoint", apiUrl));
429
451
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
430
452
  console.log(keyValue("User ID", credentials2.user.id));
431
453
  if (credentials2.expiresAt) {
@@ -444,9 +466,9 @@ async function whoami() {
444
466
  }
445
467
 
446
468
  // src/commands/run.ts
447
- import chalk5 from "chalk";
448
- import ora2 from "ora";
449
- import { select as select2 } from "@inquirer/prompts";
469
+ import chalk6 from "chalk";
470
+ import ora3 from "ora";
471
+ import { select as select3 } from "@inquirer/prompts";
450
472
 
451
473
  // ../../packages/types/src/telemetry/index.ts
452
474
  var TelemetryEventTypes = {
@@ -459,10 +481,8 @@ var TelemetryEventTypes = {
459
481
  };
460
482
 
461
483
  // ../../packages/types/src/tunnel/index.ts
462
- var TUNNEL_CHUNK_THRESHOLD = 512 * 1024;
463
- var TUNNEL_CHUNK_SIZE = 768 * 1024;
464
- var TUNNEL_MAX_RESPONSE_SIZE = 50 * 1024 * 1024;
465
- var TUNNEL_CHUNK_TIMEOUT_MS = 30 * 1e3;
484
+ var MAX_FRAME_BYTES = 256 * 1024;
485
+ var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
466
486
 
467
487
  // src/lib/telemetry.ts
468
488
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -574,33 +594,6 @@ function emitAgentDisconnected(agentId, metadata) {
574
594
  agent_id: agentId
575
595
  });
576
596
  }
577
- function emitAgentMessageProcessing(agentId, metadata) {
578
- emitEvent({
579
- event_type: TelemetryEventTypes.AGENT_MESSAGE_PROCESSING,
580
- severity: "info",
581
- message: `Processing message ${metadata.message_id.slice(0, 8)}...`,
582
- metadata,
583
- agent_id: agentId
584
- });
585
- }
586
- function emitAgentMessageDone(agentId, metadata) {
587
- emitEvent({
588
- event_type: TelemetryEventTypes.AGENT_MESSAGE_DONE,
589
- severity: "info",
590
- message: `Message ${metadata.message_id.slice(0, 8)} processed`,
591
- metadata,
592
- agent_id: agentId
593
- });
594
- }
595
- function emitAgentMessageFailed(agentId, metadata) {
596
- emitEvent({
597
- event_type: TelemetryEventTypes.AGENT_MESSAGE_FAILED,
598
- severity: "error",
599
- message: metadata.error ? `Message ${metadata.message_id.slice(0, 8)} failed: ${metadata.error}` : `Message ${metadata.message_id.slice(0, 8)} ${metadata.reason || "failed"}`,
600
- metadata,
601
- agent_id: agentId
602
- });
603
- }
604
597
  var EventTypes = {
605
598
  // Tunnel lifecycle
606
599
  TUNNEL_STARTING: "tunnel.starting",
@@ -665,7 +658,7 @@ function isInteractive(jsonOutput) {
665
658
  // src/lib/opencode/health.ts
666
659
  async function checkOpenCodeHealth(port) {
667
660
  try {
668
- const response = await fetch(`http://localhost:${port}/global/health`, {
661
+ const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
669
662
  signal: AbortSignal.timeout(2e3)
670
663
  // 2 second timeout
671
664
  });
@@ -691,6 +684,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
691
684
  return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
692
685
  }
693
686
 
687
+ // src/lib/opencode/opencode-version-gate.ts
688
+ var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
689
+ function isQueueValidatedVersion(version) {
690
+ if (!version) return false;
691
+ return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version);
692
+ }
693
+ function buildOpenCodeVersionWarning(version) {
694
+ if (isQueueValidatedVersion(version)) return null;
695
+ const detected = version ? `v${version}` : "unknown";
696
+ const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
697
+ return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
698
+ }
699
+
694
700
  // src/lib/opencode/process.ts
695
701
  import { execSync, spawn } from "child_process";
696
702
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
@@ -844,12 +850,12 @@ async function findHealthyOpenCodeInstances() {
844
850
  }
845
851
  async function startOpenCode(port) {
846
852
  let command = "opencode";
847
- let args = ["serve", "--port", port.toString()];
853
+ let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
848
854
  try {
849
855
  execSync("which opencode", { stdio: "ignore" });
850
856
  } catch {
851
857
  command = "npx";
852
- args = ["opencode", "serve", "--port", port.toString()];
858
+ args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
853
859
  }
854
860
  const child = spawn(command, args, {
855
861
  detached: true,
@@ -981,20 +987,68 @@ async function promptOpenCodeInstall(interactive) {
981
987
  }
982
988
 
983
989
  // src/lib/opencode/session.ts
984
- async function createOpenCodeSession(port) {
985
- const response = await fetch(`http://localhost:${port}/session`, {
990
+ function opencodeBase(port) {
991
+ return `http://127.0.0.1:${port}`;
992
+ }
993
+ async function getOpenCodeDirectory(port) {
994
+ try {
995
+ const res = await fetch(`${opencodeBase(port)}/path`);
996
+ if (!res.ok) return null;
997
+ const body = await res.json();
998
+ const dir = typeof body.directory === "string" && body.directory || typeof body.worktree === "string" && body.worktree || typeof body.path?.cwd === "string" && body.path.cwd || typeof body.path?.directory === "string" && body.path.directory || null;
999
+ return dir && dir.trim() ? dir.trim() : null;
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }
1004
+ function roleOf(m) {
1005
+ if (!m || typeof m !== "object") return void 0;
1006
+ if (typeof m.role === "string") return m.role;
1007
+ const infoRole = m.info?.role;
1008
+ return typeof infoRole === "string" ? infoRole : void 0;
1009
+ }
1010
+ function completedOf(m) {
1011
+ if (!m || typeof m !== "object") return void 0;
1012
+ return m.info?.time?.completed;
1013
+ }
1014
+ function idOf(m) {
1015
+ if (!m || typeof m !== "object") return void 0;
1016
+ if (typeof m.id === "string") return m.id;
1017
+ const infoId = m.info?.id;
1018
+ return typeof infoId === "string" ? infoId : void 0;
1019
+ }
1020
+ function parentIdOf(m) {
1021
+ if (!m || typeof m !== "object") return void 0;
1022
+ if (typeof m.parentID === "string") return m.parentID;
1023
+ const infoParent = m.info?.parentID;
1024
+ return typeof infoParent === "string" ? infoParent : void 0;
1025
+ }
1026
+ function finishOf(m) {
1027
+ if (!m || typeof m !== "object") return void 0;
1028
+ if (typeof m.finish === "string") return m.finish;
1029
+ const infoFinish = m.info?.finish;
1030
+ return typeof infoFinish === "string" ? infoFinish : void 0;
1031
+ }
1032
+ async function createOpenCodeSession(port, directory) {
1033
+ const url = new URL(`${opencodeBase(port)}/session`);
1034
+ if (directory && directory.trim()) {
1035
+ url.searchParams.set("directory", directory.trim());
1036
+ }
1037
+ const response = await fetch(url, {
986
1038
  method: "POST",
987
1039
  headers: { "Content-Type": "application/json" },
988
1040
  body: JSON.stringify({})
989
1041
  });
990
1042
  if (!response.ok) {
991
- throw new Error(`Failed to create session: HTTP ${response.status}`);
1043
+ const text = await response.text().catch(() => "");
1044
+ throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
992
1045
  }
993
1046
  const data = await response.json();
994
1047
  return data.id;
995
1048
  }
996
- async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1049
+ async function sendPromptAsync(port, sessionId, content, options, messageId) {
997
1050
  const body = {
1051
+ messageID: messageId,
998
1052
  parts: [{ type: "text", text: content }]
999
1053
  };
1000
1054
  if (options?.agent) {
@@ -1009,220 +1063,206 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
1009
1063
  };
1010
1064
  }
1011
1065
  }
1012
- let pollDone = false;
1013
- const reportedQuestions = /* @__PURE__ */ new Set();
1014
- const reportedPermissions = /* @__PURE__ */ new Set();
1015
- const pollInteractive = async () => {
1016
- while (!pollDone) {
1017
- await new Promise((resolve) => setTimeout(resolve, 1e3));
1018
- if (pollDone) break;
1019
- if (hooks?.onQuestion) {
1020
- try {
1021
- const res = await fetch(`http://localhost:${port}/question`);
1022
- if (res.ok) {
1023
- const questions = await res.json();
1024
- for (const q of questions) {
1025
- if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
1026
- reportedQuestions.add(q.id);
1027
- await hooks.onQuestion(q);
1028
- }
1029
- }
1030
- }
1031
- } catch {
1032
- }
1033
- }
1034
- if (hooks?.onPermission) {
1035
- try {
1036
- const res = await fetch(`http://localhost:${port}/permission`);
1037
- if (res.ok) {
1038
- const permissions = await res.json();
1039
- for (const p of permissions) {
1040
- if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
1041
- reportedPermissions.add(p.id);
1042
- await hooks.onPermission(p);
1043
- }
1044
- }
1045
- }
1046
- } catch {
1047
- }
1048
- }
1049
- }
1050
- };
1051
- const sendMessage = async () => {
1052
- const controller = new AbortController();
1053
- const timer = setTimeout(() => controller.abort(), maxWaitMs);
1054
- try {
1055
- const res = await fetch(`http://localhost:${port}/session/${sessionId}/message`, {
1056
- method: "POST",
1057
- headers: { "Content-Type": "application/json" },
1058
- body: JSON.stringify(body),
1059
- signal: controller.signal
1060
- });
1061
- if (!res.ok) {
1062
- const text = await res.text().catch(() => "");
1063
- throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1064
- }
1065
- const sessionRes = await fetch(`http://localhost:${port}/session/${sessionId}`).catch(
1066
- () => null
1067
- );
1068
- const session = sessionRes?.ok ? await sessionRes.json() : null;
1069
- return { title: session?.title };
1070
- } catch (err) {
1071
- if (err instanceof Error && err.name === "AbortError") {
1072
- throw new Error("Message processing timed out");
1073
- }
1074
- throw err;
1075
- } finally {
1076
- clearTimeout(timer);
1077
- pollDone = true;
1078
- }
1079
- };
1080
- const [result] = await Promise.all([sendMessage(), pollInteractive()]);
1081
- return result;
1066
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
1067
+ method: "POST",
1068
+ headers: { "Content-Type": "application/json" },
1069
+ body: JSON.stringify(body)
1070
+ });
1071
+ if (res.status < 200 || res.status >= 300) {
1072
+ const text = await res.text().catch(() => "");
1073
+ throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1074
+ }
1075
+ }
1076
+ function findAssistantReplyAfter(messages, userMessageId) {
1077
+ if (!messages || messages.length === 0) return null;
1078
+ const byParent = messages.find(
1079
+ (m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
1080
+ );
1081
+ if (byParent) return byParent;
1082
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1083
+ if (userIndex === -1) return null;
1084
+ for (let i = userIndex + 1; i < messages.length; i++) {
1085
+ if (roleOf(messages[i]) === "assistant") return messages[i];
1086
+ }
1087
+ return null;
1088
+ }
1089
+ function findLastAssistantReplyFor(messages, userMessageId) {
1090
+ if (!messages || messages.length === 0) return null;
1091
+ for (let i = messages.length - 1; i >= 0; i--) {
1092
+ const m = messages[i];
1093
+ if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
1094
+ }
1095
+ const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
1096
+ if (userIndex === -1) return null;
1097
+ let last = null;
1098
+ for (let i = userIndex + 1; i < messages.length; i++) {
1099
+ const role = roleOf(messages[i]);
1100
+ if (role === "user") break;
1101
+ if (role === "assistant") last = messages[i];
1102
+ }
1103
+ return last;
1104
+ }
1105
+ function messageRunState(messages, userMessageId) {
1106
+ if (!messages || messages.length === 0) return "unknown";
1107
+ const hasUser = messages.some((m) => idOf(m) === userMessageId);
1108
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1109
+ if (!hasUser) {
1110
+ if (!reply) return "unknown";
1111
+ }
1112
+ if (!reply) return "queued";
1113
+ if (completedOf(reply) == null) return "running";
1114
+ if (finishOf(reply) === "tool-calls") return "running";
1115
+ return "done";
1116
+ }
1117
+ function opencodeMessageIdFor(queuedMessageId) {
1118
+ const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
1119
+ return `msg_${sanitized}`;
1082
1120
  }
1083
1121
 
1084
1122
  // src/lib/tunnel/connection.ts
1085
- import WebSocket from "ws";
1123
+ import WebSocket2 from "ws";
1086
1124
 
1087
1125
  // 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")) {
1126
+ import WebSocket from "ws";
1127
+ var LOOPBACK_HOST = "127.0.0.1";
1128
+ var STRIP_REQ = /* @__PURE__ */ new Set([
1129
+ "host",
1130
+ "connection",
1131
+ "keep-alive",
1132
+ "proxy-authorization",
1133
+ "transfer-encoding",
1134
+ "upgrade",
1135
+ "content-length"
1136
+ ]);
1137
+ var STRIP_RES = /* @__PURE__ */ new Set([
1138
+ "connection",
1139
+ "keep-alive",
1140
+ "transfer-encoding",
1141
+ "content-encoding",
1142
+ "content-length"
1143
+ ]);
1144
+ var StreamForwarder = class {
1145
+ constructor(ws, port, callbacks = {}) {
1146
+ this.ws = ws;
1147
+ this.port = port;
1148
+ this.callbacks = callbacks;
1149
+ }
1150
+ inflight = /* @__PURE__ */ new Map();
1151
+ /**
1152
+ * Handle an edge→agent frame. Unknown frame types are ignored.
1153
+ */
1154
+ handleFrame(frame) {
1155
+ switch (frame.type) {
1156
+ case "open":
1157
+ this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
1158
+ void this.handleOpen(frame);
1159
+ break;
1160
+ case "req_data":
1161
+ this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
1162
+ break;
1163
+ case "req_end":
1164
+ this.inflight.get(frame.sid)?.endBody?.();
1165
+ break;
1166
+ case "abort":
1167
+ this.inflight.get(frame.sid)?.abort?.();
1168
+ break;
1169
+ }
1170
+ }
1171
+ /**
1172
+ * Abort every in-flight stream (e.g. on WebSocket close).
1173
+ */
1174
+ abortAll() {
1175
+ for (const stream of this.inflight.values()) {
1107
1176
  try {
1108
- body = JSON.parse(text);
1177
+ stream.abort();
1109
1178
  } catch {
1110
- body = text;
1111
1179
  }
1112
- } else {
1113
- body = text;
1114
1180
  }
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
- };
1125
- }
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;
1139
- }
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
1153
- }
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
- );
1181
+ this.inflight.clear();
1165
1182
  }
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));
1183
+ send(frame) {
1184
+ if (this.ws.readyState === WebSocket.OPEN) {
1185
+ this.ws.send(JSON.stringify(frame));
1186
+ }
1177
1187
  }
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
1188
- });
1189
- if (!response.ok) {
1190
- throw new Error(`Failed to connect to OpenCode events: ${response.status}`);
1188
+ async handleOpen(frame) {
1189
+ const { sid, method, path, headers, has_body } = frame;
1190
+ if (path === TUNNEL_DRAIN_PING_PATH) {
1191
+ this.callbacks.onDrainPing?.();
1192
+ this.send({ type: "head", sid, status: 204, headers: {} });
1193
+ this.send({ type: "res_end", sid });
1194
+ return;
1195
+ }
1196
+ const ac = new AbortController();
1197
+ let bodyPromise;
1198
+ let pushBody;
1199
+ let endBody;
1200
+ if (has_body) {
1201
+ const chunks = [];
1202
+ bodyPromise = new Promise((resolve) => {
1203
+ pushBody = (buf) => {
1204
+ chunks.push(buf);
1205
+ };
1206
+ endBody = () => {
1207
+ resolve(Buffer.concat(chunks));
1208
+ };
1209
+ });
1191
1210
  }
1192
- if (!response.body) {
1193
- throw new Error("No response body");
1211
+ const fwdHeaders = {};
1212
+ for (const [k, v] of Object.entries(headers ?? {})) {
1213
+ if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
1194
1214
  }
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;
1215
+ this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
1216
+ const body = bodyPromise ? await bodyPromise : void 0;
1217
+ if (ac.signal.aborted) {
1218
+ this.inflight.delete(sid);
1219
+ return;
1220
+ }
1221
+ let upstream;
1222
+ try {
1223
+ upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
1224
+ method,
1225
+ headers: fwdHeaders,
1226
+ body,
1227
+ redirect: "manual",
1228
+ signal: ac.signal
1229
+ });
1230
+ } catch (err) {
1231
+ this.inflight.delete(sid);
1232
+ if (!ac.signal.aborted) {
1233
+ this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
1203
1234
  }
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 {
1235
+ return;
1236
+ }
1237
+ const resHeaders = {};
1238
+ upstream.headers.forEach((value, key) => {
1239
+ if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1240
+ });
1241
+ this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1242
+ this.callbacks.onHead?.(sid, upstream.status);
1243
+ try {
1244
+ if (upstream.body) {
1245
+ const reader = upstream.body.getReader();
1246
+ while (true) {
1247
+ const { done, value } = await reader.read();
1248
+ if (done) break;
1249
+ const chunk = Buffer.from(value);
1250
+ for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
1251
+ const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
1252
+ this.send({ type: "res_data", sid, b64: slice.toString("base64") });
1213
1253
  }
1214
1254
  }
1215
1255
  }
1256
+ this.send({ type: "res_end", sid });
1257
+ } catch (err) {
1258
+ if (!ac.signal.aborted) {
1259
+ this.send({ type: "res_err", sid, message: String(err) });
1260
+ }
1261
+ } finally {
1262
+ this.inflight.delete(sid);
1216
1263
  }
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
1264
  }
1225
- }
1265
+ };
1226
1266
 
1227
1267
  // src/lib/tunnel/connection.ts
1228
1268
  var MAX_RECONNECT_DELAY = 3e4;
@@ -1232,6 +1272,33 @@ function getReconnectDelay(attempt) {
1232
1272
  const jitter = Math.random() * 1e3;
1233
1273
  return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
1234
1274
  }
1275
+ function describeSocketError(error2, url) {
1276
+ const code = error2.code;
1277
+ switch (code) {
1278
+ case "ECONNREFUSED":
1279
+ return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
1280
+ case "ENOTFOUND":
1281
+ return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
1282
+ case "ETIMEDOUT":
1283
+ return `connection timed out to ${url} (ETIMEDOUT)`;
1284
+ case "ECONNRESET":
1285
+ return `connection reset by ${url} (ECONNRESET)`;
1286
+ default: {
1287
+ const base = error2.message?.trim();
1288
+ const suffix = code ? ` (${code})` : "";
1289
+ return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
1290
+ }
1291
+ }
1292
+ }
1293
+ var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
1294
+ "open",
1295
+ "req_data",
1296
+ "req_end",
1297
+ "abort"
1298
+ ]);
1299
+ function isStreamFrame(message) {
1300
+ return STREAM_FRAME_TYPES.has(message.type);
1301
+ }
1235
1302
  function connectTunnel(options) {
1236
1303
  const {
1237
1304
  agentId,
@@ -1242,330 +1309,1218 @@ function connectTunnel(options) {
1242
1309
  onError,
1243
1310
  onRequest,
1244
1311
  onResponse,
1245
- onInfo
1312
+ onInfo,
1313
+ onDrainPing
1246
1314
  } = options;
1247
1315
  const tunnelUrl = getTunnelUrlConfig();
1248
1316
  const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1249
- const activeEventSubscriptions = /* @__PURE__ */ new Map();
1250
1317
  return new Promise((resolve, reject) => {
1251
- const ws = new WebSocket(url, {
1318
+ const ws = new WebSocket2(url, {
1252
1319
  headers: {
1253
1320
  Authorization: authHeader
1254
1321
  }
1255
1322
  });
1323
+ const streamStartTimes = /* @__PURE__ */ new Map();
1324
+ const forwarder = new StreamForwarder(ws, port, {
1325
+ onOpen: (sid, method, path) => {
1326
+ if (path === TUNNEL_DRAIN_PING_PATH) return;
1327
+ streamStartTimes.set(sid, Date.now());
1328
+ onRequest?.(method, path, sid);
1329
+ },
1330
+ onHead: (sid, status) => {
1331
+ const startedAt = streamStartTimes.get(sid);
1332
+ streamStartTimes.delete(sid);
1333
+ onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1334
+ },
1335
+ onDrainPing: () => onDrainPing?.()
1336
+ });
1256
1337
  const connectionTimeout = setTimeout(() => {
1257
1338
  ws.close();
1258
1339
  reject(new Error("Connection timeout"));
1259
1340
  }, 3e4);
1341
+ let upgradeRejection = null;
1342
+ ws.on("unexpected-response", (_req, res) => {
1343
+ clearTimeout(connectionTimeout);
1344
+ const chunks = [];
1345
+ res.on("data", (chunk) => chunks.push(chunk));
1346
+ res.on("end", () => {
1347
+ const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
1348
+ let detail = bodyRaw;
1349
+ try {
1350
+ const parsed = JSON.parse(bodyRaw);
1351
+ detail = parsed.error ?? parsed.message ?? bodyRaw;
1352
+ if (parsed.details) detail += ` (${parsed.details})`;
1353
+ } catch {
1354
+ }
1355
+ const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
1356
+ upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
1357
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
1358
+ reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
1359
+ });
1360
+ });
1260
1361
  ws.on("open", () => {
1261
1362
  onInfo?.("WebSocket connection established");
1262
1363
  });
1263
- ws.on("message", async (data) => {
1364
+ ws.on("message", (data) => {
1365
+ let message;
1264
1366
  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
- }
1367
+ message = JSON.parse(data.toString());
1323
1368
  } catch (error2) {
1324
1369
  const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1325
1370
  onError?.(`Failed to handle message: ${errorMessage}`);
1371
+ return;
1372
+ }
1373
+ if (isStreamFrame(message)) {
1374
+ forwarder.handleFrame(message);
1375
+ return;
1376
+ }
1377
+ switch (message.type) {
1378
+ case "connected": {
1379
+ clearTimeout(connectionTimeout);
1380
+ const connectedAgentId = message.agent_id ?? agentId;
1381
+ onConnected?.(connectedAgentId);
1382
+ resolve({
1383
+ ws,
1384
+ close: () => ws.close(1e3, "CLI shutdown")
1385
+ });
1386
+ break;
1387
+ }
1388
+ case "error":
1389
+ clearTimeout(connectionTimeout);
1390
+ onError?.(message.message || "Unknown tunnel error");
1391
+ if (message.code === "unauthorized") {
1392
+ ws.close();
1393
+ reject(new Error("Unauthorized"));
1394
+ }
1395
+ break;
1396
+ case "ping":
1397
+ ws.send(JSON.stringify({ type: "pong" }));
1398
+ break;
1326
1399
  }
1327
1400
  });
1328
1401
  ws.on("error", (error2) => {
1329
1402
  clearTimeout(connectionTimeout);
1330
- onError?.(`Connection error: ${error2.message}`);
1331
- reject(error2);
1403
+ const detail = upgradeRejection ?? describeSocketError(error2, url);
1404
+ onError?.(`Connection error: ${detail}`);
1405
+ reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
1332
1406
  });
1333
1407
  ws.on("close", (code, reason) => {
1334
- const reasonStr = reason.toString() || "No reason provided";
1408
+ const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1409
+ forwarder.abortAll();
1410
+ streamStartTimes.clear();
1335
1411
  onDisconnected?.(code, reasonStr);
1336
- for (const [, controller] of activeEventSubscriptions) {
1337
- controller.abort();
1338
- }
1339
- activeEventSubscriptions.clear();
1340
1412
  });
1341
1413
  });
1342
1414
  }
1343
1415
 
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 };
1416
+ // src/lib/tunnel/runner-connection.ts
1417
+ var RunnerConnection = class {
1418
+ opts;
1419
+ sleep;
1420
+ connection = null;
1421
+ resolvedAgentId;
1422
+ /** True while a (re)connect loop is in flight. */
1423
+ reconnecting = false;
1424
+ /** The in-flight reconnect promise, awaitable by the caller. */
1425
+ reconnectPromise = null;
1426
+ /** 1-based count of the current reconnect attempt streak. */
1427
+ reconnectAttempt = 0;
1428
+ constructor(opts) {
1429
+ this.opts = opts;
1430
+ this.resolvedAgentId = opts.agentId;
1431
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1432
+ }
1433
+ get agentId() {
1434
+ return this.resolvedAgentId;
1435
+ }
1436
+ /** Establish the initial tunnel connection (with retry/backoff). */
1437
+ async connect() {
1438
+ await this.connectWithRetry(false);
1439
+ }
1440
+ /** Close the active connection (idempotent). */
1441
+ close() {
1442
+ if (this.connection) {
1443
+ try {
1444
+ this.connection.close();
1445
+ } catch {
1446
+ }
1447
+ this.connection = null;
1361
1448
  }
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
1449
  }
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
- };
1450
+ async connectWithRetry(isReconnect) {
1451
+ if (isReconnect && this.reconnecting) return;
1452
+ this.reconnecting = true;
1453
+ this.close();
1454
+ const { events } = this.opts;
1455
+ while (this.opts.isRunning()) {
1456
+ try {
1457
+ this.connection = await connectTunnel({
1458
+ agentId: this.resolvedAgentId,
1459
+ authHeader: this.opts.getAuthHeader(),
1460
+ port: this.opts.port,
1461
+ onConnected: (agentId) => {
1462
+ this.reconnectAttempt = 0;
1463
+ this.reconnecting = false;
1464
+ this.resolvedAgentId = agentId;
1465
+ events.onConnected(agentId, isReconnect);
1466
+ },
1467
+ onDisconnected: (code, reason) => {
1468
+ events.onDisconnected(code, reason);
1469
+ if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
1470
+ this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
1471
+ events.onError?.(`Reconnection failed: ${err.message}`);
1472
+ });
1473
+ }
1474
+ },
1475
+ onError: (error2) => events.onError?.(error2),
1476
+ onResponse: () => events.onResponse?.(),
1477
+ onDrainPing: () => events.onDrainPing?.(),
1478
+ onInfo: (message) => events.onInfo?.(message)
1479
+ });
1480
+ return;
1481
+ } catch (error2) {
1482
+ this.reconnectAttempt++;
1483
+ if (error2.message === "Unauthorized") {
1484
+ this.reconnecting = false;
1485
+ throw error2;
1486
+ }
1487
+ const delay = getReconnectDelay(this.reconnectAttempt);
1488
+ events.onReconnecting?.(this.reconnectAttempt);
1489
+ events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
1490
+ await this.sleep(delay);
1491
+ }
1391
1492
  }
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}` };
1493
+ this.reconnecting = false;
1396
1494
  }
1397
- }
1398
- var AuthenticationError = class extends Error {
1495
+ };
1496
+
1497
+ // src/lib/channels/driver.ts
1498
+ function messageIdOf(m) {
1499
+ if (!m || typeof m !== "object") return void 0;
1500
+ if (typeof m.id === "string") return m.id;
1501
+ const infoId = m.info?.id;
1502
+ return typeof infoId === "string" ? infoId : void 0;
1503
+ }
1504
+ var DEFAULT_RETRY_POLICY = {
1505
+ maxAttempts: 6,
1506
+ baseDelayMs: 500,
1507
+ maxDelayMs: 3e4
1508
+ };
1509
+ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1510
+ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1511
+ var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
1512
+ var ChannelAuthError = class extends Error {
1399
1513
  constructor(message) {
1400
1514
  super(message);
1401
- this.name = "AuthenticationError";
1515
+ this.name = "ChannelAuthError";
1402
1516
  }
1403
1517
  };
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);
1518
+ var ChannelTerminalError = class extends Error {
1519
+ status;
1520
+ constructor(message, status) {
1521
+ super(message);
1522
+ this.name = "ChannelTerminalError";
1523
+ this.status = status;
1424
1524
  }
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}`);
1525
+ };
1526
+ function backoffDelay(attempt, policy) {
1527
+ const exp = policy.baseDelayMs * Math.pow(2, attempt);
1528
+ const capped = Math.min(policy.maxDelayMs, exp);
1529
+ return Math.floor(Math.random() * capped);
1530
+ }
1531
+ function isRetryableStatus(status) {
1532
+ return status === 429 || status >= 500 && status <= 599;
1533
+ }
1534
+ var ChannelDriver = class {
1535
+ agentId;
1536
+ port;
1537
+ apiUrl;
1538
+ getAuthHeader;
1539
+ conversationFilter;
1540
+ retry;
1541
+ log;
1542
+ fetchImpl;
1543
+ sleep;
1544
+ pausedPollIntervalMs;
1545
+ pausedMaxWaitMs;
1546
+ dispatchConfirmMs;
1547
+ now;
1548
+ /** Cache of conversationId → opencode sessionId. */
1549
+ sessions = /* @__PURE__ */ new Map();
1550
+ /**
1551
+ * Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
1552
+ * session: one polling loop services all of that session's in-flight messages.
1553
+ * A session entry exists while it has any in-flight (dispatched-but-not-done)
1554
+ * message; it is removed once its in-flight set empties.
1555
+ */
1556
+ watchers = /* @__PURE__ */ new Map();
1557
+ /**
1558
+ * AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
1559
+ * dispatched and are still in-flight. A message in this set is never
1560
+ * re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
1561
+ * Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
1562
+ * idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
1563
+ * a steady-state-poll re-dispatch will not double-run the message.
1564
+ */
1565
+ dispatched = /* @__PURE__ */ new Set();
1566
+ /**
1567
+ * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1568
+ * first session creation so drain-created sessions are rooted at the project
1569
+ * directory and thus visible in `opencode web`'s session list. `undefined` =
1570
+ * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1571
+ */
1572
+ opencodeDirectory = void 0;
1573
+ /** Serialises drains so a reconnect during a drain doesn't double-process. */
1574
+ draining = false;
1575
+ constructor(config2) {
1576
+ this.agentId = config2.agentId;
1577
+ this.port = config2.port;
1578
+ this.apiUrl = config2.apiUrl.replace(/\/$/, "");
1579
+ this.getAuthHeader = config2.getAuthHeader;
1580
+ this.conversationFilter = config2.conversationFilter ?? null;
1581
+ this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
1582
+ this.log = config2.log ?? (() => {
1583
+ });
1584
+ this.fetchImpl = config2.fetchImpl ?? fetch;
1585
+ this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1586
+ this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1587
+ this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1588
+ this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
1589
+ this.now = config2.now ?? (() => Date.now());
1590
+ }
1591
+ /** The IPv4-loopback base URL for the local `opencode serve`. */
1592
+ get opencodeBase() {
1593
+ return `http://127.0.0.1:${this.port}`;
1594
+ }
1595
+ // -------------------------------------------------------------------------
1596
+ // Public API
1597
+ // -------------------------------------------------------------------------
1598
+ /**
1599
+ * Drain all pending channel conversations once: poll → dispatch → register.
1600
+ * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1601
+ * Re-entrant calls while a drain is in flight are skipped (return 0).
1602
+ *
1603
+ * @returns the number of messages NEWLY dispatched to opencode's native queue.
1604
+ */
1605
+ async drainPending() {
1606
+ if (this.draining) return 0;
1607
+ this.draining = true;
1608
+ let dispatched = 0;
1609
+ try {
1610
+ const conversations = await this.getPendingConversations();
1611
+ if (conversations.length > 0) {
1612
+ const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
1613
+ this.log({
1614
+ level: "info",
1615
+ message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
1616
+ });
1617
+ }
1618
+ for (const conv of conversations) {
1619
+ dispatched += await this.processConversation(conv);
1620
+ }
1621
+ } finally {
1622
+ this.draining = false;
1623
+ }
1624
+ return dispatched;
1436
1625
  }
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" })
1626
+ /**
1627
+ * True while any per-session watcher has a non-empty in-flight dispatched set
1628
+ * (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
1629
+ * the process while a dispatched message is still queued/running — which would
1630
+ * kill the turn and orphan its reply.
1631
+ */
1632
+ hasInFlightWatchers() {
1633
+ for (const watcher of this.watchers.values()) {
1634
+ if (watcher.inFlight.size > 0) return true;
1447
1635
  }
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 })
1636
+ return false;
1637
+ }
1638
+ /**
1639
+ * Await all outstanding per-session watchers (WI-3).
1640
+ *
1641
+ * In production the watcher loops are deliberately started-not-awaited so the
1642
+ * drain loop never blocks on them and process exit is not held up (the cron
1643
+ * recovers any abandoned ones). This helper exists primarily for deterministic
1644
+ * tests that need to observe a watcher's effect (the `processing`/`done` PATCH
1645
+ * or its giving up) after a non-blocking `drainPending`. Watcher loops never
1646
+ * reject, so this resolves.
1647
+ */
1648
+ async flushPausedWatchers() {
1649
+ while (true) {
1650
+ const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
1651
+ if (loops.length === 0) return;
1652
+ await Promise.all(loops);
1653
+ const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
1654
+ if (!stillLive) return;
1460
1655
  }
1461
- );
1462
- checkAuthResponse(response, "reporting interactive event");
1463
- if (!response.ok) {
1464
- throw new Error(`Failed to report interactive event: HTTP ${response.status}`);
1465
1656
  }
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;
1657
+ // -------------------------------------------------------------------------
1658
+ // Conversation processing (WI-3 async dispatch)
1659
+ // -------------------------------------------------------------------------
1660
+ /**
1661
+ * Dispatch each pending message for a conversation to opencode's native queue
1662
+ * via `prompt_async` (Task 3.2) and register it with the conversation's
1663
+ * per-session watcher. Does NOT block on the turn and does NOT call
1664
+ * `markProcessing` here — that fires from the watcher on running-start.
1665
+ *
1666
+ * @returns the count of messages NEWLY dispatched (not already in-flight).
1667
+ */
1668
+ async processConversation(conv) {
1669
+ const sessionId = await this.ensureSession(conv);
1670
+ const messages = await this.getPendingMessages(conv.id);
1671
+ let dispatched = 0;
1672
+ for (const message of messages) {
1673
+ if (this.dispatched.has(message.id)) {
1674
+ continue;
1675
+ }
1676
+ const opencodeMessageId = opencodeMessageIdFor(message.id);
1677
+ const options = {
1678
+ agent: message.opencode_agent ?? void 0,
1679
+ model: message.opencode_model ?? void 0
1680
+ };
1681
+ try {
1682
+ this.log({
1683
+ level: "info",
1684
+ message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
1685
+ conversation_id: conv.id,
1686
+ message_id: message.id
1687
+ });
1688
+ await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
1689
+ } catch (err) {
1690
+ if (err instanceof ChannelAuthError) throw err;
1691
+ this.dispatched.delete(message.id);
1692
+ await this.markFailed(conv.id, message.id).catch(() => {
1693
+ });
1694
+ this.log({
1695
+ level: "error",
1696
+ message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
1697
+ conversation_id: conv.id,
1698
+ message_id: message.id
1699
+ });
1700
+ continue;
1701
+ }
1702
+ this.dispatched.add(message.id);
1703
+ this.registerInFlight(conv, sessionId, message, opencodeMessageId);
1704
+ dispatched += 1;
1705
+ }
1706
+ this.ensureWatcherRunning(sessionId);
1707
+ return dispatched;
1708
+ }
1709
+ async ensureSession(conv) {
1710
+ const cached = this.sessions.get(conv.id);
1711
+ if (cached) return cached;
1712
+ if (conv.opencode_session_id) {
1713
+ this.sessions.set(conv.id, conv.opencode_session_id);
1714
+ return conv.opencode_session_id;
1715
+ }
1716
+ const directory = await this.resolveOpenCodeDirectory();
1717
+ const sessionId = await createOpenCodeSession(this.port, directory);
1718
+ this.sessions.set(conv.id, sessionId);
1719
+ await this.persistSession(conv.id, sessionId).catch(() => {
1720
+ });
1721
+ return sessionId;
1472
1722
  }
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)
1723
+ /**
1724
+ * Lazily resolve (and cache) opencode's root directory via `GET /path`.
1725
+ * Resolved once per driver: `undefined` until first lookup, then the directory
1726
+ * string or `null` if unavailable (we don't keep retrying a missing `/path`).
1727
+ */
1728
+ async resolveOpenCodeDirectory() {
1729
+ if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
1730
+ this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1731
+ if (!this.opencodeDirectory) {
1732
+ this.log({
1733
+ level: "info",
1734
+ message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1735
+ });
1479
1736
  }
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" })
1737
+ return this.opencodeDirectory;
1738
+ }
1739
+ // -------------------------------------------------------------------------
1740
+ // Per-session watcher (WI-3)
1741
+ // -------------------------------------------------------------------------
1742
+ /** Register a freshly-dispatched message with its session's watcher state. */
1743
+ registerInFlight(conv, sessionId, message, opencodeMessageId) {
1744
+ let watcher = this.watchers.get(sessionId);
1745
+ if (!watcher) {
1746
+ watcher = {
1747
+ conv,
1748
+ inFlight: /* @__PURE__ */ new Map(),
1749
+ loop: null,
1750
+ reportedQuestions: /* @__PURE__ */ new Set(),
1751
+ reportedPermissions: /* @__PURE__ */ new Set()
1752
+ };
1753
+ this.watchers.set(sessionId, watcher);
1754
+ }
1755
+ const now = this.now();
1756
+ watcher.inFlight.set(message.id, {
1757
+ evidentMessageId: message.id,
1758
+ opencodeMessageId,
1759
+ message,
1760
+ dispatchedAt: now,
1761
+ deadline: now + this.pausedMaxWaitMs,
1762
+ started: false,
1763
+ done: false
1764
+ });
1765
+ }
1766
+ /**
1767
+ * Start (but do NOT await) the per-session watcher loop if it has in-flight
1768
+ * work and is not already running. Single-flight per session. The loop is
1769
+ * tracked on the watcher and cleared when it settles; it never rejects (fully
1770
+ * guarded), so a failed poll/callback can never crash the run loop — the cron
1771
+ * stays as the safety net.
1772
+ */
1773
+ ensureWatcherRunning(sessionId) {
1774
+ const watcher = this.watchers.get(sessionId);
1775
+ if (!watcher) return;
1776
+ if (watcher.loop) return;
1777
+ if (watcher.inFlight.size === 0) {
1778
+ this.watchers.delete(sessionId);
1779
+ return;
1491
1780
  }
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 })
1781
+ const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
1782
+ watcher.loop = null;
1783
+ if (watcher.inFlight.size === 0) {
1784
+ this.watchers.delete(sessionId);
1785
+ }
1502
1786
  });
1503
- checkAuthResponse(response, "acquiring conversation lock");
1504
- if (response.status === 409) {
1505
- return { acquired: false, error: "Conversation already locked by another runner" };
1787
+ watcher.loop = loop;
1788
+ }
1789
+ /**
1790
+ * The per-session polling loop (WI-3). Once per tick it:
1791
+ * 1. polls `GET /session/:id/message` once and, per in-flight message,
1792
+ * computes `messageRunState` and fires markProcessing (queued→running) /
1793
+ * markDone (done) exactly once per transition;
1794
+ * 2. applies the idle-path re-dispatch guard (a dispatched message that never
1795
+ * APPEARS → re-dispatch — D1 obligation 2);
1796
+ * 3. polls `/question` + `/permission` (scoped to the session) and surfaces
1797
+ * NEW ones via `reportInteraction`, carrying the PAUSED message's own
1798
+ * `source_message_id`;
1799
+ * 4. drops messages that completed or timed out from the in-flight set.
1800
+ * Exits when the in-flight set empties. Never throws.
1801
+ */
1802
+ async runWatcherLoop(sessionId, watcher) {
1803
+ try {
1804
+ while (watcher.inFlight.size > 0) {
1805
+ await this.sleep(this.pausedPollIntervalMs);
1806
+ let messages = null;
1807
+ try {
1808
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1809
+ if (res.ok) {
1810
+ const body = await res.json();
1811
+ messages = Array.isArray(body) ? body : null;
1812
+ }
1813
+ } catch {
1814
+ continue;
1815
+ }
1816
+ for (const inFlight of [...watcher.inFlight.values()]) {
1817
+ await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
1818
+ }
1819
+ await this.pollInteractions(sessionId, watcher, messages);
1820
+ }
1821
+ } catch (err) {
1822
+ if (err instanceof ChannelAuthError) {
1823
+ this.log({
1824
+ level: "error",
1825
+ message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
1826
+ conversation_id: watcher.conv.id
1827
+ });
1828
+ for (const evidentMessageId of [...watcher.inFlight.keys()]) {
1829
+ this.removeInFlight(watcher, evidentMessageId);
1830
+ }
1831
+ return;
1832
+ }
1833
+ this.log({
1834
+ level: "error",
1835
+ message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1836
+ conversation_id: watcher.conv.id
1837
+ });
1506
1838
  }
1507
- if (!response.ok) {
1508
- return { acquired: false, error: `Failed to acquire lock: HTTP ${response.status}` };
1839
+ }
1840
+ /**
1841
+ * Drive ONE in-flight message's lifecycle from the tick's message snapshot.
1842
+ * Fires markProcessing on queued→running and markDone on done (each once),
1843
+ * applies the idle-path re-dispatch guard, and removes the message from the
1844
+ * in-flight set on completion or timeout.
1845
+ */
1846
+ async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
1847
+ const conv = watcher.conv;
1848
+ const state = messageRunState(messages, inFlight.opencodeMessageId);
1849
+ if ((state === "running" || state === "done") && !inFlight.started) {
1850
+ let claimed;
1851
+ try {
1852
+ claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
1853
+ } catch (err) {
1854
+ if (err instanceof ChannelAuthError) throw err;
1855
+ this.log({
1856
+ level: "error",
1857
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1858
+ conversation_id: conv.id,
1859
+ message_id: inFlight.evidentMessageId
1860
+ });
1861
+ return;
1862
+ }
1863
+ inFlight.started = true;
1864
+ if (!claimed) {
1865
+ this.log({
1866
+ level: "info",
1867
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
1868
+ conversation_id: conv.id,
1869
+ message_id: inFlight.evidentMessageId
1870
+ });
1871
+ }
1872
+ }
1873
+ if (state === "done") {
1874
+ if (!inFlight.done) {
1875
+ this.log({
1876
+ level: "info",
1877
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
1878
+ conversation_id: conv.id,
1879
+ message_id: inFlight.evidentMessageId
1880
+ });
1881
+ try {
1882
+ await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
1883
+ } catch (err) {
1884
+ if (err instanceof ChannelAuthError) throw err;
1885
+ if (err instanceof ChannelTerminalError) {
1886
+ this.log({
1887
+ level: "error",
1888
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
1889
+ conversation_id: conv.id,
1890
+ message_id: inFlight.evidentMessageId
1891
+ });
1892
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1893
+ return;
1894
+ }
1895
+ if (this.now() >= inFlight.deadline) {
1896
+ this.log({
1897
+ level: "error",
1898
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
1899
+ conversation_id: conv.id,
1900
+ message_id: inFlight.evidentMessageId
1901
+ });
1902
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1903
+ return;
1904
+ }
1905
+ this.log({
1906
+ level: "error",
1907
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
1908
+ conversation_id: conv.id,
1909
+ message_id: inFlight.evidentMessageId
1910
+ });
1911
+ return;
1912
+ }
1913
+ inFlight.done = true;
1914
+ }
1915
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1916
+ return;
1917
+ }
1918
+ if (state === "unknown") {
1919
+ if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
1920
+ await this.redispatchInFlight(sessionId, inFlight);
1921
+ }
1922
+ }
1923
+ if (this.now() >= inFlight.deadline) {
1924
+ this.log({
1925
+ level: "info",
1926
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
1927
+ conversation_id: conv.id,
1928
+ message_id: inFlight.evidentMessageId
1929
+ });
1930
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
1509
1931
  }
1510
- return { acquired: true };
1511
- } catch (error2) {
1512
- if (error2 instanceof AuthenticationError) throw error2;
1513
- return { acquired: false, error: String(error2) };
1514
1932
  }
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`,
1933
+ /**
1934
+ * Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
1935
+ * opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
1936
+ * fact 9) — one user message + one reply even if the original DID land. Resets
1937
+ * the dispatch timestamp so the guard doesn't immediately fire again.
1938
+ */
1939
+ async redispatchInFlight(sessionId, inFlight) {
1940
+ const options = {
1941
+ agent: inFlight.message.opencode_agent ?? void 0,
1942
+ model: inFlight.message.opencode_model ?? void 0
1943
+ };
1944
+ this.log({
1945
+ level: "info",
1946
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
1947
+ message_id: inFlight.evidentMessageId
1948
+ });
1949
+ try {
1950
+ await sendPromptAsync(
1951
+ this.port,
1952
+ sessionId,
1953
+ inFlight.message.content,
1954
+ options,
1955
+ inFlight.opencodeMessageId
1956
+ );
1957
+ } catch (err) {
1958
+ this.log({
1959
+ level: "error",
1960
+ message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1961
+ message_id: inFlight.evidentMessageId
1962
+ });
1963
+ }
1964
+ inFlight.dispatchedAt = this.now();
1965
+ }
1966
+ /**
1967
+ * Remove a message from the in-flight set AND the authoritative dispatched
1968
+ * set. Once the in-flight set empties, the watcher loop's `while` guard exits
1969
+ * and its `.finally` removes the session entry from `this.watchers`.
1970
+ */
1971
+ removeInFlight(watcher, evidentMessageId) {
1972
+ watcher.inFlight.delete(evidentMessageId);
1973
+ this.dispatched.delete(evidentMessageId);
1974
+ }
1975
+ /**
1976
+ * Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
1977
+ * via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
1978
+ * `source_message_id` so the server @mentions the correct person under
1979
+ * concurrency. Dedups by interaction id across ticks (reused per-session sets).
1980
+ *
1981
+ * The interaction is attributed to the in-flight message it paused on. opencode
1982
+ * stamps a `messageID` on a permission (and `tool.messageID` on a question) =
1983
+ * the assistant message id, whose `parentID` is the user message id — but the
1984
+ * simplest robust attribution here is: the single in-flight message that is
1985
+ * RUNNING (not done) is the one that paused. With one running message that is
1986
+ * unambiguous; with several we prefer an explicit messageID match, else the
1987
+ * oldest running message.
1988
+ */
1989
+ async pollInteractions(sessionId, watcher, messages) {
1990
+ let questions = [];
1991
+ try {
1992
+ const res = await this.fetchImpl(`${this.opencodeBase}/question`);
1993
+ if (res.ok) {
1994
+ const body = await res.json();
1995
+ questions = Array.isArray(body) ? body : [];
1996
+ }
1997
+ } catch {
1998
+ }
1999
+ for (const q of questions) {
2000
+ if (q.sessionID !== sessionId) continue;
2001
+ if (watcher.reportedQuestions.has(q.id)) continue;
2002
+ const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
2003
+ const reported = await this.reportInteraction(
2004
+ watcher.conv.id,
2005
+ "question",
2006
+ q,
2007
+ paused?.message.source_message_id ?? void 0
2008
+ );
2009
+ if (reported) watcher.reportedQuestions.add(q.id);
2010
+ }
2011
+ let permissions = [];
2012
+ try {
2013
+ const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
2014
+ if (res.ok) {
2015
+ const body = await res.json();
2016
+ permissions = Array.isArray(body) ? body : [];
2017
+ }
2018
+ } catch {
2019
+ }
2020
+ for (const p of permissions) {
2021
+ if (p.sessionID !== sessionId) continue;
2022
+ if (watcher.reportedPermissions.has(p.id)) continue;
2023
+ const paused = this.attributeInteraction(watcher, p.messageID, messages);
2024
+ const reported = await this.reportInteraction(
2025
+ watcher.conv.id,
2026
+ "permission",
2027
+ p,
2028
+ paused?.message.source_message_id ?? void 0
2029
+ );
2030
+ if (reported) watcher.reportedPermissions.add(p.id);
2031
+ }
2032
+ }
2033
+ /**
2034
+ * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
2035
+ *
2036
+ * The interaction carries `interactionMessageId` — the ASSISTANT message id
2037
+ * that raised it (a question's `tool.messageID` / a permission's `messageID`).
2038
+ * That assistant message is the reply to ONE of our minted user messages
2039
+ * (correlated by `parentID`, GATE-B). So when we have the tick's message
2040
+ * snapshot, we resolve each running in-flight message's correlated assistant
2041
+ * reply (`findAssistantReplyAfter`) and match its id against
2042
+ * `interactionMessageId` — giving an EXACT attribution even with several
2043
+ * messages in flight concurrently in one session.
2044
+ *
2045
+ * We fall back to the oldest running message ONLY when no exact match is
2046
+ * possible (the id is absent, the snapshot is missing, or the reply has not yet
2047
+ * been correlated). With a single running message either path is exact. Never
2048
+ * throws.
2049
+ *
2050
+ * Attribution must NOT depend on our own `started` PATCH flag: opencode can
2051
+ * START a turn AND raise a question/permission BEFORE our next tick fires
2052
+ * `markProcessing` (which sets `started`). Relying on `started` would leave the
2053
+ * running set empty in that window and let the server fall back to "newest
2054
+ * processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
2055
+ * person whose active turn actually paused. So we derive "running" from the
2056
+ * tick's `messages` snapshot via `messageRunState` instead.
2057
+ */
2058
+ attributeInteraction(watcher, interactionMessageId, messages) {
2059
+ const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
2060
+ if (inFlight.length === 0) return void 0;
2061
+ if (interactionMessageId && messages) {
2062
+ const exact = inFlight.find((m) => {
2063
+ const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
2064
+ return reply != null && messageIdOf(reply) === interactionMessageId;
2065
+ });
2066
+ if (exact) return exact;
2067
+ }
2068
+ const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
2069
+ if (messages) {
2070
+ const runningPerSnapshot = inFlight.filter(
2071
+ (m) => messageRunState(messages, m.opencodeMessageId) === "running"
2072
+ );
2073
+ if (runningPerSnapshot.length > 0) {
2074
+ return runningPerSnapshot.sort(byOldest)[0];
2075
+ }
2076
+ }
2077
+ const startedRunning = inFlight.filter((m) => m.started);
2078
+ if (startedRunning.length > 0) {
2079
+ return startedRunning.sort(byOldest)[0];
2080
+ }
2081
+ return inFlight.sort(byOldest)[0];
2082
+ }
2083
+ // -------------------------------------------------------------------------
2084
+ // Evident API calls (combinedAuth thread routes)
2085
+ // -------------------------------------------------------------------------
2086
+ async getPendingConversations() {
2087
+ const res = await this.fetchImpl(
2088
+ `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
1521
2089
  {
1522
- method: "POST",
1523
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1524
- body: JSON.stringify({ correlation_id: correlationId })
2090
+ headers: { Authorization: this.getAuthHeader() }
1525
2091
  }
1526
2092
  );
1527
- return response.ok;
1528
- } catch {
2093
+ this.assertAuth(res, "fetching pending conversations");
2094
+ if (!res.ok) {
2095
+ throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
2096
+ }
2097
+ const data = await res.json();
2098
+ let conversations = data.conversations;
2099
+ if (this.conversationFilter) {
2100
+ conversations = conversations.filter((c) => c.id === this.conversationFilter);
2101
+ }
2102
+ return conversations;
2103
+ }
2104
+ async getPendingMessages(conversationId) {
2105
+ const res = await this.fetchImpl(
2106
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
2107
+ { headers: { Authorization: this.getAuthHeader() } }
2108
+ );
2109
+ this.assertAuth(res, "fetching pending messages");
2110
+ if (!res.ok) {
2111
+ throw new Error(`Failed to get messages: HTTP ${res.status}`);
2112
+ }
2113
+ return await res.json();
2114
+ }
2115
+ /**
2116
+ * EXISTING combinedAuth route — now fired by the watcher on queued→running
2117
+ * (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
2118
+ * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
2119
+ * deep-linked "View in Evident" notice).
2120
+ *
2121
+ * Return/throw contract (consumed by the watcher's swap-to-running guard):
2122
+ * - returns `true` → the server transitioned the row to processing;
2123
+ * - returns `false` → the server gave a DEFINITIVE "already-processing"
2124
+ * answer (a non-retryable, non-auth status — e.g. a
2125
+ * conflict because a duplicate already transitioned it),
2126
+ * so the caller treats it as already-started and does NOT
2127
+ * retry;
2128
+ * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
2129
+ * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
2130
+ * network-level error from `fetch`) — i.e. NO definitive server response —
2131
+ * so the caller leaves the message un-started and retries the swap on the
2132
+ * next tick.
2133
+ * A single attempt (no internal retry): the watcher's per-tick loop is the
2134
+ * retry vehicle for the swap-to-running.
2135
+ */
2136
+ async markProcessing(conversationId, messageId, sessionId) {
2137
+ const res = await this.fetchImpl(
2138
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2139
+ {
2140
+ method: "PATCH",
2141
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2142
+ body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
2143
+ }
2144
+ );
2145
+ this.assertAuth(res, "marking message as processing");
2146
+ if (res.ok) return true;
2147
+ if (isRetryableStatus(res.status)) {
2148
+ throw new Error(`marking message as processing: HTTP ${res.status}`);
2149
+ }
1529
2150
  return false;
1530
2151
  }
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)}`,
2152
+ /**
2153
+ * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
2154
+ * .../messages/:id {status:'done', opencode_session_id}`. The server's
2155
+ * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
2156
+ * for an already-`done` message a no-op (no double Slack post). Fired by the
2157
+ * watcher on per-message completion (Task 3.4) — no `confirmCompletion`
2158
+ * round-trip (we already observed completion via the message list).
2159
+ *
2160
+ * SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
2161
+ * services its in-flight messages SEQUENTIALLY within a tick
2162
+ * (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
2163
+ * backoff here would BLOCK sibling messages in the SAME session/tick: while
2164
+ * message A's done PATCH burned its internal retries, message B could not be
2165
+ * swapped to running even though opencode had already started it. Instead this
2166
+ * does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
2167
+ * handler already relies on, leaning on the per-tick retry across ticks
2168
+ * (bounded by `inFlight.deadline`) rather than an in-call retry:
2169
+ * - resolves (`void`) → the server transitioned the row to done
2170
+ * (or idempotently confirmed already-done);
2171
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
2172
+ * cleanup, Finding 1);
2173
+ * - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
2174
+ * succeed → straight to the cron, Finding 4);
2175
+ * - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
2176
+ * (no definitive server response → the
2177
+ * watcher retries next tick within the
2178
+ * deadline, Finding 4).
2179
+ */
2180
+ async markDone(conversationId, messageId, sessionId) {
2181
+ const res = await this.fetchImpl(
2182
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1537
2183
  {
1538
- method: "DELETE",
1539
- headers: { Authorization: authHeader }
2184
+ method: "PATCH",
2185
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2186
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1540
2187
  }
1541
2188
  );
2189
+ this.assertAuth(res, "marking message as done");
2190
+ if (res.ok) return;
2191
+ if (isRetryableStatus(res.status)) {
2192
+ throw new Error(`marking message as done: HTTP ${res.status}`);
2193
+ }
2194
+ throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
2195
+ }
2196
+ async markFailed(conversationId, messageId) {
2197
+ await this.callWithRetry(
2198
+ "marking message as failed",
2199
+ () => this.fetchImpl(
2200
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
2201
+ {
2202
+ method: "PATCH",
2203
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2204
+ body: JSON.stringify({ status: "failed" })
2205
+ }
2206
+ )
2207
+ );
2208
+ }
2209
+ async persistSession(conversationId, sessionId) {
2210
+ const res = await this.fetchImpl(
2211
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
2212
+ {
2213
+ method: "PATCH",
2214
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2215
+ body: JSON.stringify({ opencode_session_id: sessionId })
2216
+ }
2217
+ );
2218
+ this.assertAuth(res, "persisting session id");
2219
+ }
2220
+ /**
2221
+ * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
2222
+ * `POST .../interactive-event {type, data, source_message_id?}`. The server
2223
+ * persists the interaction and posts a link to the proxied opencode-web
2224
+ * conversation, @mentioning the user who triggered THIS message's turn.
2225
+ *
2226
+ * WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
2227
+ * ts (`message.source_message_id`). The server resolves the @mention from that
2228
+ * message's user FIRST (falling back to the old "newest processing" precedence
2229
+ * only when absent), so the correct person is mentioned under concurrency. It
2230
+ * is OPTIONAL for back-compat with older clients / legacy rows.
2231
+ */
2232
+ async reportInteraction(conversationId, type, data, sourceMessageId) {
2233
+ try {
2234
+ await this.callWithRetry(
2235
+ "reporting interactive event",
2236
+ () => this.fetchImpl(
2237
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
2238
+ {
2239
+ method: "POST",
2240
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
2241
+ body: JSON.stringify(
2242
+ sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
2243
+ )
2244
+ }
2245
+ )
2246
+ );
2247
+ this.log({
2248
+ level: "info",
2249
+ message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
2250
+ conversation_id: conversationId
2251
+ });
2252
+ return true;
2253
+ } catch (err) {
2254
+ if (err instanceof ChannelAuthError) throw err;
2255
+ this.log({
2256
+ level: "error",
2257
+ message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
2258
+ conversation_id: conversationId
2259
+ });
2260
+ return false;
2261
+ }
2262
+ }
2263
+ // -------------------------------------------------------------------------
2264
+ // Retry wrapper
2265
+ // -------------------------------------------------------------------------
2266
+ /**
2267
+ * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
2268
+ * network errors) with exponential backoff + jitter (capped). Auth failures
2269
+ * (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
2270
+ * terminal too. No on-disk persistence — a crash mid-retry drops the callback
2271
+ * (accepted by ADR-0039).
2272
+ */
2273
+ async callWithRetry(context, call) {
2274
+ let lastError;
2275
+ for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
2276
+ let res;
2277
+ try {
2278
+ res = await call();
2279
+ } catch (err) {
2280
+ lastError = err;
2281
+ if (attempt < this.retry.maxAttempts - 1) {
2282
+ await this.sleep(backoffDelay(attempt, this.retry));
2283
+ continue;
2284
+ }
2285
+ throw err;
2286
+ }
2287
+ if (res.status === 401 || res.status === 403) {
2288
+ throw new ChannelAuthError(
2289
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
2290
+ );
2291
+ }
2292
+ if (res.ok) return;
2293
+ if (isRetryableStatus(res.status)) {
2294
+ lastError = new Error(`${context}: HTTP ${res.status}`);
2295
+ if (attempt < this.retry.maxAttempts - 1) {
2296
+ await this.sleep(backoffDelay(attempt, this.retry));
2297
+ continue;
2298
+ }
2299
+ break;
2300
+ }
2301
+ throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
2302
+ }
2303
+ throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
2304
+ }
2305
+ assertAuth(res, context) {
2306
+ if (res.status === 401 || res.status === 403) {
2307
+ throw new ChannelAuthError(
2308
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
2309
+ );
2310
+ }
2311
+ }
2312
+ };
2313
+
2314
+ // src/commands/ensure-opencode.ts
2315
+ import chalk5 from "chalk";
2316
+ import ora2 from "ora";
2317
+ import { select as select2 } from "@inquirer/prompts";
2318
+ async function ensureOpenCodeRunning(ctx) {
2319
+ const healthCheck = await checkOpenCodeHealth(ctx.port);
2320
+ if (healthCheck.healthy) {
2321
+ return { port: ctx.port, process: null, version: healthCheck.version ?? null };
2322
+ }
2323
+ const runningInstances = await findHealthyOpenCodeInstances();
2324
+ if (runningInstances.length > 0) {
2325
+ if (!ctx.interactive) {
2326
+ throw new Error(
2327
+ `OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
2328
+ );
2329
+ }
2330
+ blank();
2331
+ console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
2332
+ for (const instance of runningInstances) {
2333
+ const ver = instance.version ? ` (v${instance.version})` : "";
2334
+ const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
2335
+ console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
2336
+ }
2337
+ blank();
2338
+ if (runningInstances.length === 1) {
2339
+ console.log(chalk5.yellow("Tip: Run with the correct port:"));
2340
+ console.log(
2341
+ chalk5.dim(
2342
+ ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
2343
+ )
2344
+ );
2345
+ }
2346
+ blank();
2347
+ throw new Error(`OpenCode not running on port ${ctx.port}`);
2348
+ }
2349
+ if (!isOpenCodeInstalled()) {
2350
+ if (!ctx.interactive) {
2351
+ throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
2352
+ }
2353
+ const result = await promptOpenCodeInstall(true);
2354
+ if (result === "exit") process.exit(0);
2355
+ if (result !== "installed" && !isOpenCodeInstalled()) {
2356
+ throw new Error("OpenCode is not installed");
2357
+ }
2358
+ }
2359
+ if (!ctx.interactive) {
2360
+ ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
2361
+ const proc = await startOpenCode(ctx.port);
2362
+ const health = await waitForOpenCodeHealth(ctx.port, 3e4);
2363
+ if (!health.healthy) {
2364
+ throw new Error(
2365
+ `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
2366
+ );
2367
+ }
2368
+ ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
2369
+ return { port: ctx.port, process: proc, version: health.version ?? null };
2370
+ }
2371
+ let port = ctx.port;
2372
+ if (isPortInUse(port)) {
2373
+ console.log(chalk5.yellow(`
2374
+ Port ${port} is already in use.`));
2375
+ const alternativePort = findAvailablePort(port + 1);
2376
+ if (alternativePort) {
2377
+ const useAlternative = await select2({
2378
+ message: `Use port ${alternativePort} instead?`,
2379
+ choices: [
2380
+ { name: `Yes, use port ${alternativePort}`, value: "yes" },
2381
+ { name: "No, I will free the port manually", value: "no" }
2382
+ ]
2383
+ });
2384
+ if (useAlternative === "yes") {
2385
+ port = alternativePort;
2386
+ } else {
2387
+ throw new Error(`Port ${ctx.port} is in use`);
2388
+ }
2389
+ }
2390
+ }
2391
+ const action = await select2({
2392
+ message: "OpenCode is not running. What would you like to do?",
2393
+ choices: [
2394
+ {
2395
+ name: "Start OpenCode for me",
2396
+ value: "start",
2397
+ description: `Run 'opencode serve --port ${port}'`
2398
+ },
2399
+ {
2400
+ name: "Show me the command",
2401
+ value: "manual",
2402
+ description: "Display the command to run manually"
2403
+ },
2404
+ {
2405
+ name: "Continue without OpenCode",
2406
+ value: "continue",
2407
+ description: "Requests will fail until OpenCode starts"
2408
+ }
2409
+ ]
2410
+ });
2411
+ if (action === "manual") {
2412
+ blank();
2413
+ console.log(chalk5.bold("Run this command in another terminal:"));
2414
+ blank();
2415
+ console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
2416
+ blank();
2417
+ throw new Error("Please start OpenCode manually");
2418
+ }
2419
+ if (action === "start") {
2420
+ const spinner = ora2("Starting OpenCode...").start();
2421
+ const proc = await startOpenCode(port);
2422
+ const health = await waitForOpenCodeHealth(port, 3e4);
2423
+ if (!health.healthy) {
2424
+ spinner.fail("Failed to start OpenCode");
2425
+ throw new Error("OpenCode failed to start");
2426
+ }
2427
+ spinner.stop();
2428
+ return { port, process: proc, version: health.version ?? null };
2429
+ }
2430
+ return { port, process: null, version: null };
2431
+ }
2432
+
2433
+ // src/commands/agent-lookup.ts
2434
+ async function readErrorMessage(response) {
2435
+ const text = await response.text().catch(() => "");
2436
+ if (!text) return response.statusText || void 0;
2437
+ try {
2438
+ const data = JSON.parse(text);
2439
+ const message = data.message ?? data.error;
2440
+ if (typeof message === "string" && message.trim()) {
2441
+ return message;
2442
+ }
1542
2443
  } catch {
1543
2444
  }
2445
+ return text.trim() || response.statusText || void 0;
2446
+ }
2447
+ function authFailureHint(apiUrl, serverMessage) {
2448
+ const reason = serverMessage ? `: ${serverMessage}` : "";
2449
+ return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
1544
2450
  }
1545
- async function updateConversationSession(agentId, conversationId, sessionId, authHeader) {
2451
+ async function resolveAgentIdFromKey(authHeader) {
1546
2452
  const apiUrl = getApiUrlConfig();
1547
- const response = await fetch(`${apiUrl}/agents/${agentId}/threads/${conversationId}`, {
1548
- method: "PATCH",
1549
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
1550
- body: JSON.stringify({ opencode_session_id: sessionId })
1551
- });
1552
- checkAuthResponse(response, "updating conversation session");
1553
- if (!response.ok) {
1554
- const text = await response.text().catch(() => "");
1555
- throw new Error(
1556
- `Failed to update conversation session: HTTP ${response.status}${text ? `: ${text}` : ""}`
1557
- );
2453
+ try {
2454
+ const response = await fetch(`${apiUrl}/me`, {
2455
+ headers: { Authorization: authHeader }
2456
+ });
2457
+ if (response.status === 401) {
2458
+ const serverMessage = await readErrorMessage(response);
2459
+ return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2460
+ }
2461
+ if (!response.ok) {
2462
+ const serverMessage = await readErrorMessage(response);
2463
+ return {
2464
+ error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2465
+ };
2466
+ }
2467
+ const data = await response.json();
2468
+ if (data.auth_type === "agent_key" && data.agent_id) {
2469
+ return { agent_id: data.agent_id };
2470
+ }
2471
+ return {
2472
+ error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
2473
+ };
2474
+ } catch (error2) {
2475
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2476
+ return { error: `Failed to resolve agent from key: ${message}` };
2477
+ }
2478
+ }
2479
+ async function getAgentInfo(agentId, authHeader) {
2480
+ const apiUrl = getApiUrlConfig();
2481
+ try {
2482
+ const response = await fetch(`${apiUrl}/agents/${agentId}`, {
2483
+ headers: { Authorization: authHeader }
2484
+ });
2485
+ if (response.status === 401) {
2486
+ const serverMessage = await readErrorMessage(response);
2487
+ return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2488
+ }
2489
+ if (response.status === 403) {
2490
+ const serverMessage = await readErrorMessage(response);
2491
+ return {
2492
+ valid: false,
2493
+ error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
2494
+ };
2495
+ }
2496
+ if (response.status === 404) {
2497
+ const serverMessage = await readErrorMessage(response);
2498
+ return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
2499
+ }
2500
+ if (!response.ok) {
2501
+ const serverMessage = await readErrorMessage(response);
2502
+ return {
2503
+ valid: false,
2504
+ error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2505
+ };
2506
+ }
2507
+ const agent = await response.json();
2508
+ if (agent.agent_type !== "local") {
2509
+ return {
2510
+ valid: false,
2511
+ error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
2512
+ };
2513
+ }
2514
+ return { valid: true, agent };
2515
+ } catch (error2) {
2516
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2517
+ return { valid: false, error: `Failed to validate agent: ${message}` };
1558
2518
  }
1559
2519
  }
1560
- async function updateConversationTitle(agentId, conversationId, title, authHeader) {
1561
- 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");
1568
- }
2520
+
2521
+ // src/commands/run.ts
2522
+ var MAX_ACTIVITY_LOG_ENTRIES = 10;
2523
+ var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
1569
2524
  function log(state, message, isError = false) {
1570
2525
  if (state.json) {
1571
2526
  console.log(
@@ -1576,7 +2531,7 @@ function log(state, message, isError = false) {
1576
2531
  })
1577
2532
  );
1578
2533
  } else if (!state.interactive) {
1579
- const prefix = isError ? chalk5.red("\u2717") : chalk5.green("\u2022");
2534
+ const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
1580
2535
  console.log(`${prefix} ${message}`);
1581
2536
  }
1582
2537
  }
@@ -1589,7 +2544,6 @@ function logActivity(state, entry) {
1589
2544
  if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
1590
2545
  state.activityLog.shift();
1591
2546
  }
1592
- state.lastActivity = fullEntry.timestamp;
1593
2547
  if (!state.interactive) {
1594
2548
  if (entry.type === "error") {
1595
2549
  log(state, entry.error ?? "Unknown error", true);
@@ -1598,130 +2552,21 @@ function logActivity(state, entry) {
1598
2552
  }
1599
2553
  }
1600
2554
  }
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
2555
  function displayStatus(state) {
1647
2556
  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`)
2557
+ const attempt = state.connection?.reconnectAttempt ?? 0;
2558
+ const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
2559
+ const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
2560
+ const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
2561
+ const last = state.activityLog[state.activityLog.length - 1];
2562
+ const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
2563
+ const agent = state.agentName ?? state.agentId;
2564
+ console.log(
2565
+ `${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
1700
2566
  );
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
2567
  }
1723
2568
  async function promptForLogin(promptMessage, successMessage) {
1724
- const action = await select2({
2569
+ const action = await select3({
1725
2570
  message: promptMessage,
1726
2571
  choices: [
1727
2572
  {
@@ -1737,7 +2582,7 @@ async function promptForLogin(promptMessage, successMessage) {
1737
2582
  ]
1738
2583
  });
1739
2584
  if (action === "exit") {
1740
- console.log(chalk5.dim(`
2585
+ console.log(chalk6.dim(`
1741
2586
  You can log in later by running: ${getCliName()} login`));
1742
2587
  process.exit(0);
1743
2588
  }
@@ -1748,137 +2593,10 @@ You can log in later by running: ${getCliName()} login`));
1748
2593
  process.exit(1);
1749
2594
  }
1750
2595
  blank();
1751
- console.log(chalk5.green(successMessage));
2596
+ console.log(chalk6.green(successMessage));
1752
2597
  blank();
1753
2598
  return { token: credentials2.token, authType: "bearer", user: credentials2.user };
1754
2599
  }
1755
- async function ensureOpenCodeRunning(state) {
1756
- const healthCheck = await checkOpenCodeHealth(state.port);
1757
- if (healthCheck.healthy) {
1758
- state.opencodeConnected = true;
1759
- state.opencodeVersion = healthCheck.version ?? null;
1760
- return;
1761
- }
1762
- const runningInstances = await findHealthyOpenCodeInstances();
1763
- if (runningInstances.length > 0) {
1764
- if (!state.interactive) {
1765
- throw new Error(
1766
- `OpenCode not found on port ${state.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
1767
- );
1768
- }
1769
- blank();
1770
- console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
1771
- for (const instance of runningInstances) {
1772
- const ver = instance.version ? ` (v${instance.version})` : "";
1773
- const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
1774
- console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
1775
- }
1776
- blank();
1777
- if (runningInstances.length === 1) {
1778
- console.log(chalk5.yellow("Tip: Run with the correct port:"));
1779
- console.log(
1780
- chalk5.dim(
1781
- ` ${getCliName()} run --agent ${state.agentId} --port ${runningInstances[0].port}`
1782
- )
1783
- );
1784
- }
1785
- blank();
1786
- throw new Error(`OpenCode not running on port ${state.port}`);
1787
- }
1788
- if (!isOpenCodeInstalled()) {
1789
- if (!state.interactive) {
1790
- throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
1791
- }
1792
- const result = await promptOpenCodeInstall(true);
1793
- if (result === "exit") {
1794
- process.exit(0);
1795
- }
1796
- if (result === "installed" || isOpenCodeInstalled()) {
1797
- } else {
1798
- throw new Error("OpenCode is not installed");
1799
- }
1800
- }
1801
- if (state.interactive) {
1802
- let actualPort = state.port;
1803
- if (isPortInUse(state.port)) {
1804
- console.log(chalk5.yellow(`
1805
- Port ${state.port} is already in use.`));
1806
- const alternativePort = findAvailablePort(state.port + 1);
1807
- if (alternativePort) {
1808
- const useAlternative = await select2({
1809
- message: `Use port ${alternativePort} instead?`,
1810
- choices: [
1811
- { name: `Yes, use port ${alternativePort}`, value: "yes" },
1812
- { name: "No, I will free the port manually", value: "no" }
1813
- ]
1814
- });
1815
- if (useAlternative === "yes") {
1816
- actualPort = alternativePort;
1817
- state.port = actualPort;
1818
- } else {
1819
- throw new Error(`Port ${state.port} is in use`);
1820
- }
1821
- }
1822
- }
1823
- const action = await select2({
1824
- message: "OpenCode is not running. What would you like to do?",
1825
- choices: [
1826
- {
1827
- name: "Start OpenCode for me",
1828
- value: "start",
1829
- description: `Run 'opencode serve --port ${actualPort}'`
1830
- },
1831
- {
1832
- name: "Show me the command",
1833
- value: "manual",
1834
- description: "Display the command to run manually"
1835
- },
1836
- {
1837
- name: "Continue without OpenCode",
1838
- value: "continue",
1839
- description: "Requests will fail until OpenCode starts"
1840
- }
1841
- ]
1842
- });
1843
- if (action === "manual") {
1844
- blank();
1845
- console.log(chalk5.bold("Run this command in another terminal:"));
1846
- blank();
1847
- console.log(` ${chalk5.cyan(`opencode serve --port ${actualPort}`)}`);
1848
- blank();
1849
- throw new Error("Please start OpenCode manually");
1850
- }
1851
- if (action === "start") {
1852
- const spinner = ora2("Starting OpenCode...").start();
1853
- state.opencodeProcess = await startOpenCode(actualPort);
1854
- const health = await waitForOpenCodeHealth(actualPort, 3e4);
1855
- if (!health.healthy) {
1856
- spinner.fail("Failed to start OpenCode");
1857
- throw new Error("OpenCode failed to start");
1858
- }
1859
- spinner.succeed(
1860
- `OpenCode running on port ${actualPort}${health.version ? ` (v${health.version})` : ""}`
1861
- );
1862
- state.opencodeConnected = true;
1863
- state.opencodeVersion = health.version ?? null;
1864
- }
1865
- } else {
1866
- log(state, `OpenCode is not running on port ${state.port}. Starting it automatically...`);
1867
- state.opencodeProcess = await startOpenCode(state.port);
1868
- const health = await waitForOpenCodeHealth(state.port, 3e4);
1869
- if (!health.healthy) {
1870
- throw new Error(
1871
- `OpenCode failed to start on port ${state.port}. Install with: npm install -g opencode-ai`
1872
- );
1873
- }
1874
- log(
1875
- state,
1876
- `OpenCode started on port ${state.port}${health.version ? ` (v${health.version})` : ""}`
1877
- );
1878
- state.opencodeConnected = true;
1879
- state.opencodeVersion = health.version ?? null;
1880
- }
1881
- }
1882
2600
  var AUTH_EXPIRED_EXIT_CODE = 77;
1883
2601
  async function handleAuthError(state, error2) {
1884
2602
  logActivity(state, {
@@ -1888,12 +2606,12 @@ async function handleAuthError(state, error2) {
1888
2606
  if (state.interactive) displayStatus(state);
1889
2607
  if (!state.interactive) {
1890
2608
  blank();
1891
- console.log(chalk5.red("Authentication expired"));
1892
- console.log(chalk5.dim("Your authentication token is no longer valid."));
2609
+ console.log(chalk6.red("Authentication expired"));
2610
+ console.log(chalk6.dim("Your authentication token is no longer valid."));
1893
2611
  blank();
1894
- console.log(chalk5.dim("To fix this:"));
1895
- console.log(chalk5.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
1896
- console.log(chalk5.dim(" 2. Restart this command"));
2612
+ console.log(chalk6.dim("To fix this:"));
2613
+ console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
2614
+ console.log(chalk6.dim(" 2. Restart this command"));
1897
2615
  blank();
1898
2616
  await cleanup(state);
1899
2617
  await shutdownTelemetry();
@@ -1901,7 +2619,7 @@ async function handleAuthError(state, error2) {
1901
2619
  return { success: false };
1902
2620
  }
1903
2621
  blank();
1904
- console.log(chalk5.yellow("Your authentication has expired."));
2622
+ console.log(chalk6.yellow("Your authentication has expired."));
1905
2623
  blank();
1906
2624
  try {
1907
2625
  const credentials2 = await promptForLogin(
@@ -1914,285 +2632,62 @@ async function handleAuthError(state, error2) {
1914
2632
  return { success: false };
1915
2633
  }
1916
2634
  }
1917
- function isNetworkError(error2) {
1918
- if (error2 instanceof Error) {
1919
- const message = error2.message.toLowerCase();
1920
- return message.includes("fetch failed") || message.includes("network") || message.includes("econnrefused") || message.includes("econnreset") || message.includes("etimedout") || message.includes("socket hang up");
1921
- }
1922
- return false;
1923
- }
1924
- async function processQueue(state, authHeader, triggerReconnect) {
2635
+ async function driveChannels(state, driver) {
1925
2636
  let idlePolls = 0;
1926
- let currentAuthHeader = authHeader;
1927
2637
  while (state.running) {
1928
- if (state.reconnecting && state.reconnectPromise) {
1929
- logActivity(state, {
1930
- type: "info",
1931
- message: "Waiting for tunnel reconnection..."
1932
- });
2638
+ if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2639
+ logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
1933
2640
  if (state.interactive) displayStatus(state);
1934
- await state.reconnectPromise;
2641
+ await state.connection.reconnectPromise;
1935
2642
  }
1936
2643
  try {
1937
- const conversations = await getPendingConversations(
1938
- state.agentId,
1939
- currentAuthHeader,
1940
- state.conversationFilter ?? void 0
1941
- );
1942
- state.consecutiveFetchFailures = 0;
1943
- if (conversations.length > 0) {
2644
+ const processed = await driver.drainPending();
2645
+ state.messageCount += processed;
2646
+ if (processed > 0 || driver.hasInFlightWatchers()) {
1944
2647
  idlePolls = 0;
1945
- for (const conv of conversations) {
1946
- if (!state.running) break;
1947
- if (!state.lockedConversations.has(conv.id)) {
1948
- const lockResult = await acquireConversationLock(
1949
- state.agentId,
1950
- conv.id,
1951
- state.lockCorrelationId,
1952
- currentAuthHeader
1953
- );
1954
- if (!lockResult.acquired) {
1955
- logActivity(state, {
1956
- type: "info",
1957
- message: `Conversation ${conv.id.slice(0, 8)} locked by another runner \u2014 skipping`
1958
- });
1959
- if (state.interactive) displayStatus(state);
1960
- continue;
1961
- }
1962
- state.lockedConversations.add(conv.id);
1963
- logActivity(state, {
1964
- type: "info",
1965
- message: `Lock acquired on conversation ${conv.id.slice(0, 8)}`
1966
- });
1967
- }
1968
- logActivity(state, {
1969
- type: "info",
1970
- message: `Processing conversation ${conv.id.slice(0, 8)}... (${conv.pending_message_count} pending)`
1971
- });
1972
- if (state.interactive) displayStatus(state);
1973
- let sessionId = state.sessions.get(conv.id);
1974
- if (!sessionId) {
1975
- if (conv.opencode_session_id) {
1976
- sessionId = conv.opencode_session_id;
1977
- } else {
1978
- sessionId = await createOpenCodeSession(state.port);
1979
- await updateConversationSession(state.agentId, conv.id, sessionId, currentAuthHeader);
1980
- logActivity(state, {
1981
- type: "info",
1982
- message: `Created session ${sessionId.slice(0, 8)}`
1983
- });
1984
- }
1985
- state.sessions.set(conv.id, sessionId);
1986
- }
1987
- const messages = await getPendingMessages(state.agentId, conv.id, currentAuthHeader);
1988
- for (const message of messages) {
1989
- if (!state.running) break;
1990
- logActivity(state, {
1991
- type: "info",
1992
- message: `Processing message ${message.id.slice(0, 8)}...`
1993
- });
1994
- if (state.interactive) displayStatus(state);
1995
- const claimed = await markMessageProcessing(
1996
- state.agentId,
1997
- conv.id,
1998
- message.id,
1999
- currentAuthHeader
2000
- );
2001
- if (!claimed) {
2002
- logActivity(state, {
2003
- type: "info",
2004
- message: `Message ${message.id.slice(0, 8)} already claimed`
2005
- });
2006
- continue;
2007
- }
2008
- emitAgentMessageProcessing(state.agentId, {
2009
- message_id: message.id,
2010
- conversation_id: conv.id
2011
- });
2012
- try {
2013
- const result = await sendMessageToOpenCode(
2014
- state.port,
2015
- sessionId,
2016
- message.content,
2017
- {
2018
- agent: message.opencode_agent ?? void 0,
2019
- model: message.opencode_model ?? void 0
2020
- },
2021
- {
2022
- onQuestion: async (question) => {
2023
- try {
2024
- await reportInteractiveEvent(
2025
- state.agentId,
2026
- conv.id,
2027
- "question",
2028
- question,
2029
- currentAuthHeader
2030
- );
2031
- logActivity(state, {
2032
- type: "info",
2033
- message: `Question surfaced to user (id: ${question.id.slice(0, 8)})`
2034
- });
2035
- } catch (err) {
2036
- logActivity(state, {
2037
- type: "error",
2038
- error: `Failed to surface question: ${err}`
2039
- });
2040
- }
2041
- },
2042
- onPermission: async (permission) => {
2043
- try {
2044
- await reportInteractiveEvent(
2045
- state.agentId,
2046
- conv.id,
2047
- "permission",
2048
- permission,
2049
- currentAuthHeader
2050
- );
2051
- logActivity(state, {
2052
- type: "info",
2053
- message: `Permission request surfaced to user (id: ${permission.id.slice(0, 8)})`
2054
- });
2055
- } catch (err) {
2056
- logActivity(state, {
2057
- type: "error",
2058
- error: `Failed to surface permission: ${err}`
2059
- });
2060
- }
2061
- }
2062
- }
2063
- );
2064
- if (result.title) {
2065
- try {
2066
- await updateConversationTitle(
2067
- state.agentId,
2068
- conv.id,
2069
- result.title,
2070
- currentAuthHeader
2071
- );
2072
- } catch {
2073
- }
2074
- }
2075
- await markMessageDone(
2076
- state.agentId,
2077
- conv.id,
2078
- message.id,
2079
- currentAuthHeader,
2080
- sessionId
2081
- );
2082
- state.messageCount++;
2083
- logActivity(state, {
2084
- type: "info",
2085
- message: `Message ${message.id.slice(0, 8)} processed`
2086
- });
2087
- emitAgentMessageDone(state.agentId, {
2088
- message_id: message.id,
2089
- conversation_id: conv.id
2090
- });
2091
- } catch (error2) {
2092
- if (error2 instanceof AuthenticationError) {
2093
- throw error2;
2094
- }
2095
- await markMessageFailed(state.agentId, conv.id, message.id, currentAuthHeader);
2096
- logActivity(state, {
2097
- type: "error",
2098
- error: `Message ${message.id.slice(0, 8)} failed: ${error2}`
2099
- });
2100
- emitAgentMessageFailed(state.agentId, {
2101
- message_id: message.id,
2102
- conversation_id: conv.id,
2103
- error: String(error2)
2104
- });
2105
- }
2106
- if (state.interactive) displayStatus(state);
2107
- }
2108
- }
2109
- } else {
2110
- if (state.idleTimeout !== null) {
2111
- idlePolls++;
2112
- if (idlePolls === 1) {
2113
- logActivity(state, {
2114
- type: "info",
2115
- message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2116
- });
2117
- if (state.interactive) displayStatus(state);
2118
- }
2119
- }
2120
- }
2121
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2122
- if (state.idleTimeout !== null && idlePolls >= 2) {
2123
- const idleMs = idlePolls * MESSAGE_POLL_INTERVAL_MS;
2124
- if (idleMs > state.idleTimeout * 1e3) {
2648
+ if (processed > 0 && state.interactive) displayStatus(state);
2649
+ } else if (state.idleTimeout !== null) {
2650
+ idlePolls++;
2651
+ if (idlePolls === 1) {
2125
2652
  logActivity(state, {
2126
2653
  type: "info",
2127
- message: "Idle timeout reached"
2654
+ message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2128
2655
  });
2129
2656
  if (state.interactive) displayStatus(state);
2130
- break;
2131
2657
  }
2132
2658
  }
2133
2659
  } catch (error2) {
2134
- if (error2 instanceof AuthenticationError) {
2660
+ if (error2 instanceof ChannelAuthError) {
2135
2661
  const result = await handleAuthError(state, error2);
2136
2662
  if (result.success && result.newAuthHeader) {
2137
- currentAuthHeader = result.newAuthHeader;
2138
2663
  state.authHeader = result.newAuthHeader;
2139
- logActivity(state, {
2140
- type: "info",
2141
- message: "Continuing with new credentials..."
2142
- });
2664
+ logActivity(state, { type: "info", message: "Continuing with new credentials..." });
2143
2665
  if (state.interactive) displayStatus(state);
2144
2666
  continue;
2145
- } else {
2146
- state.running = false;
2147
- break;
2148
2667
  }
2668
+ state.running = false;
2669
+ break;
2149
2670
  }
2150
2671
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
2151
- logActivity(state, {
2152
- type: "error",
2153
- error: `Queue processing error: ${errorMessage}`
2154
- });
2672
+ logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
2155
2673
  if (state.interactive) displayStatus(state);
2156
- if (isNetworkError(error2)) {
2157
- state.consecutiveFetchFailures++;
2158
- if (state.consecutiveFetchFailures >= MAX_CONSECUTIVE_FETCH_FAILURES) {
2159
- logActivity(state, {
2160
- type: "info",
2161
- message: `Detected ${state.consecutiveFetchFailures} consecutive fetch failures, triggering reconnection...`
2162
- });
2163
- if (state.interactive) displayStatus(state);
2164
- await triggerReconnect();
2165
- state.consecutiveFetchFailures = 0;
2166
- }
2674
+ }
2675
+ await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
2676
+ if (state.idleTimeout !== null && idlePolls >= 2) {
2677
+ const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
2678
+ if (idleMs > state.idleTimeout * 1e3) {
2679
+ logActivity(state, { type: "info", message: "Idle timeout reached" });
2680
+ if (state.interactive) displayStatus(state);
2681
+ break;
2167
2682
  }
2168
- await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
2169
2683
  }
2170
2684
  }
2171
2685
  }
2172
- async function cleanup(state, authHeader) {
2686
+ async function cleanup(state) {
2173
2687
  state.running = false;
2174
- if (state.lockHeartbeatTimer) {
2175
- clearInterval(state.lockHeartbeatTimer);
2176
- state.lockHeartbeatTimer = null;
2177
- }
2178
- if (authHeader && state.lockedConversations.size > 0) {
2179
- for (const convId of state.lockedConversations) {
2180
- await releaseConversationLock(state.agentId, convId, state.lockCorrelationId, authHeader);
2181
- }
2182
- if (state.interactive) {
2183
- logActivity(state, {
2184
- type: "info",
2185
- message: `Released ${state.lockedConversations.size} lock(s)`
2186
- });
2187
- displayStatus(state);
2188
- } else {
2189
- log(state, `Released ${state.lockedConversations.size} lock(s)`);
2190
- }
2191
- state.lockedConversations.clear();
2192
- }
2193
- if (state.tunnelConnection) {
2194
- state.tunnelConnection.close();
2195
- state.tunnelConnection = null;
2688
+ if (state.connection) {
2689
+ state.connection.close();
2690
+ state.connection = null;
2196
2691
  }
2197
2692
  if (state.opencodeProcess) {
2198
2693
  stopOpenCode(state.opencodeProcess);
@@ -2211,7 +2706,6 @@ async function run(options) {
2211
2706
  agentId: options.agent || "",
2212
2707
  agentName: null,
2213
2708
  port: options.port ?? 4096,
2214
- verbose: options.verbose ?? false,
2215
2709
  conversationFilter: options.conversation ?? null,
2216
2710
  idleTimeout: options.idleTimeout ?? null,
2217
2711
  json: options.json ?? false,
@@ -2219,22 +2713,11 @@ async function run(options) {
2219
2713
  connected: false,
2220
2714
  opencodeConnected: false,
2221
2715
  opencodeVersion: null,
2222
- reconnectAttempt: 0,
2223
2716
  opencodeProcess: null,
2224
- tunnelConnection: null,
2717
+ connection: null,
2225
2718
  running: true,
2226
2719
  activityLog: [],
2227
- displayInitialized: false,
2228
- lastActivity: /* @__PURE__ */ new Date(),
2229
- pendingRequests: /* @__PURE__ */ new Map(),
2230
- sessions: /* @__PURE__ */ new Map(),
2231
2720
  messageCount: 0,
2232
- lockCorrelationId: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2233
- lockedConversations: /* @__PURE__ */ new Set(),
2234
- lockHeartbeatTimer: null,
2235
- consecutiveFetchFailures: 0,
2236
- reconnecting: false,
2237
- reconnectPromise: null,
2238
2721
  authHeader: ""
2239
2722
  };
2240
2723
  if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
@@ -2251,7 +2734,7 @@ async function run(options) {
2251
2734
  } else {
2252
2735
  log(state, "Shutting down...");
2253
2736
  }
2254
- await cleanup(state, state.authHeader);
2737
+ await cleanup(state);
2255
2738
  await shutdownTelemetry();
2256
2739
  process.exit(0);
2257
2740
  };
@@ -2263,13 +2746,13 @@ async function run(options) {
2263
2746
  if (!interactive) {
2264
2747
  printError("Authentication required");
2265
2748
  blank();
2266
- console.log(chalk5.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2267
- console.log(chalk5.dim("Or run `evident login` for interactive authentication"));
2749
+ console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2750
+ console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
2268
2751
  blank();
2269
2752
  process.exit(1);
2270
2753
  }
2271
2754
  blank();
2272
- console.log(chalk5.yellow("You are not logged in to Evident."));
2755
+ console.log(chalk6.yellow("You are not logged in to Evident."));
2273
2756
  blank();
2274
2757
  credentials2 = await promptForLogin(
2275
2758
  "Would you like to log in now?",
@@ -2283,6 +2766,12 @@ async function run(options) {
2283
2766
  if (resolved.agent_id) {
2284
2767
  state.agentId = resolved.agent_id;
2285
2768
  log(state, `Resolved agent ID from key: ${state.agentId}`);
2769
+ if (state.interactive && !state.json) {
2770
+ logActivity(state, {
2771
+ type: "info",
2772
+ message: `Agent ID resolved from key: ${state.agentId}`
2773
+ });
2774
+ }
2286
2775
  } else {
2287
2776
  printError(resolved.error || "Failed to resolve agent ID from key");
2288
2777
  process.exit(1);
@@ -2290,7 +2779,7 @@ async function run(options) {
2290
2779
  } else {
2291
2780
  printError("--agent is required when not using EVIDENT_AGENT_KEY");
2292
2781
  blank();
2293
- console.log(chalk5.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2782
+ console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2294
2783
  blank();
2295
2784
  process.exit(1);
2296
2785
  }
@@ -2309,15 +2798,15 @@ async function run(options) {
2309
2798
  );
2310
2799
  if (interactive && !state.json) {
2311
2800
  blank();
2312
- console.log(chalk5.bold("Evident Run"));
2313
- console.log(chalk5.dim("-".repeat(40)));
2801
+ console.log(chalk6.bold("Evident Run"));
2802
+ console.log(chalk6.dim("-".repeat(40)));
2314
2803
  }
2315
- const spinner = interactive && !state.json ? ora2("Validating agent...").start() : null;
2804
+ const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
2316
2805
  let validation = await getAgentInfo(state.agentId, state.authHeader);
2317
2806
  if (!validation.valid && validation.authFailed && interactive) {
2318
2807
  spinner?.fail("Authentication failed");
2319
2808
  blank();
2320
- console.log(chalk5.yellow("Your authentication token is invalid or expired."));
2809
+ console.log(chalk6.yellow("Your authentication token is invalid or expired."));
2321
2810
  blank();
2322
2811
  credentials2 = await promptForLogin(
2323
2812
  "Would you like to log in again?",
@@ -2333,172 +2822,136 @@ async function run(options) {
2333
2822
  }
2334
2823
  spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
2335
2824
  state.agentName = validation.agent.name;
2336
- const ocSpinner = interactive && !state.json ? ora2("Checking OpenCode...").start() : null;
2825
+ const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
2337
2826
  try {
2338
- await ensureOpenCodeRunning(state);
2827
+ const oc = await ensureOpenCodeRunning({
2828
+ port: state.port,
2829
+ interactive: state.interactive,
2830
+ agentId: state.agentId,
2831
+ log: (message) => log(state, message)
2832
+ });
2833
+ state.port = oc.port;
2834
+ state.opencodeProcess = oc.process;
2835
+ state.opencodeVersion = oc.version;
2836
+ state.opencodeConnected = oc.process !== null || oc.version !== null;
2339
2837
  const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2340
2838
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2839
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
2840
+ if (versionWarning) {
2841
+ log(state, versionWarning, false);
2842
+ if (state.interactive && !state.json) {
2843
+ logActivity(state, { type: "info", message: versionWarning });
2844
+ }
2845
+ }
2341
2846
  } catch (error2) {
2342
2847
  ocSpinner?.fail(error2.message);
2343
2848
  throw error2;
2344
2849
  }
2345
- const tunnelSpinner = interactive && !state.json ? ora2("Connecting tunnel...").start() : null;
2346
- const connectWithRetry = async (isReconnect = false) => {
2347
- if (isReconnect && state.reconnecting) {
2348
- return;
2349
- }
2350
- state.reconnecting = true;
2351
- if (state.tunnelConnection) {
2352
- try {
2353
- state.tunnelConnection.close();
2354
- } catch {
2355
- }
2356
- state.tunnelConnection = null;
2357
- }
2358
- while (state.running) {
2359
- try {
2360
- state.tunnelConnection = await connectTunnel({
2361
- agentId: state.agentId,
2362
- authHeader: state.authHeader,
2363
- port: state.port,
2364
- onConnected: (agentId) => {
2365
- state.connected = true;
2366
- state.reconnectAttempt = 0;
2367
- state.reconnecting = false;
2368
- state.consecutiveFetchFailures = 0;
2369
- state.agentId = agentId;
2370
- logActivity(state, {
2371
- type: "info",
2372
- message: isReconnect ? `Tunnel reconnected (agent: ${agentId})` : `Tunnel connected (agent: ${agentId})`
2373
- });
2374
- emitAgentConnected(state.agentId, { port: state.port });
2375
- if (state.interactive) displayStatus(state);
2376
- },
2377
- onDisconnected: (code, reason) => {
2378
- state.connected = false;
2850
+ const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
2851
+ const channelDriver = new ChannelDriver({
2852
+ agentId: state.agentId,
2853
+ port: state.port,
2854
+ apiUrl: getApiUrlConfig(),
2855
+ getAuthHeader: () => state.authHeader,
2856
+ conversationFilter: state.conversationFilter,
2857
+ log: (entry) => logActivity(state, {
2858
+ type: entry.level === "error" ? "error" : "info",
2859
+ message: entry.message,
2860
+ error: entry.level === "error" ? entry.message : void 0
2861
+ })
2862
+ });
2863
+ const connection = new RunnerConnection({
2864
+ agentId: state.agentId,
2865
+ getAuthHeader: () => state.authHeader,
2866
+ port: state.port,
2867
+ isRunning: () => state.running,
2868
+ events: {
2869
+ onConnected: (agentId, isReconnect) => {
2870
+ state.connected = true;
2871
+ state.agentId = agentId;
2872
+ logActivity(state, {
2873
+ type: "info",
2874
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
2875
+ });
2876
+ emitAgentConnected(state.agentId, { port: state.port });
2877
+ if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2878
+ if (state.interactive) displayStatus(state);
2879
+ channelDriver.drainPending().then((processed) => {
2880
+ if (processed > 0) {
2881
+ state.messageCount += processed;
2379
2882
  logActivity(state, {
2380
2883
  type: "info",
2381
- message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2382
- });
2383
- emitAgentDisconnected(state.agentId, { code, reason });
2384
- if (state.interactive) displayStatus(state);
2385
- if (state.running && code !== 1e3 && !state.reconnecting) {
2386
- logActivity(state, {
2387
- type: "info",
2388
- message: "Attempting automatic reconnection..."
2389
- });
2390
- if (state.interactive) displayStatus(state);
2391
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2392
- logActivity(state, {
2393
- type: "error",
2394
- error: `Reconnection failed: ${err.message}`
2395
- });
2396
- if (state.interactive) displayStatus(state);
2397
- });
2398
- }
2399
- },
2400
- onError: (error2) => {
2401
- logActivity(state, { type: "error", error: error2 });
2402
- if (state.interactive) displayStatus(state);
2403
- },
2404
- onRequest: (method, path, requestId) => {
2405
- state.pendingRequests.set(requestId, {
2406
- startTime: Date.now(),
2407
- method,
2408
- path
2884
+ message: `Drained ${processed} queued message(s) on connect`
2409
2885
  });
2410
- logActivity(state, { type: "request", method, path, requestId });
2411
- if (state.interactive) displayStatus(state);
2412
- },
2413
- onResponse: (status, durationMs, requestId) => {
2414
- const pending = state.pendingRequests.get(requestId);
2415
- state.pendingRequests.delete(requestId);
2416
- state.opencodeConnected = true;
2417
- const lastEntry = state.activityLog[state.activityLog.length - 1];
2418
- if (lastEntry && lastEntry.requestId === requestId) {
2419
- lastEntry.type = "response";
2420
- lastEntry.status = status;
2421
- lastEntry.durationMs = durationMs;
2422
- } else if (pending) {
2423
- logActivity(state, {
2424
- type: "response",
2425
- method: pending.method,
2426
- path: pending.path,
2427
- status,
2428
- durationMs,
2429
- requestId
2430
- });
2431
- }
2432
- if (state.interactive) displayStatus(state);
2433
- },
2434
- onInfo: (message) => {
2435
- logActivity(state, { type: "info", message });
2436
2886
  if (state.interactive) displayStatus(state);
2437
2887
  }
2888
+ }).catch((error2) => {
2889
+ const message = error2 instanceof Error ? error2.message : String(error2);
2890
+ logActivity(state, {
2891
+ type: "error",
2892
+ error: `Failed to drain queued messages on connect: ${message}`
2893
+ });
2894
+ if (state.interactive) displayStatus(state);
2438
2895
  });
2439
- if (!isReconnect) {
2440
- tunnelSpinner?.succeed("Tunnel connected");
2441
- }
2442
- return;
2443
- } catch (error2) {
2444
- state.reconnectAttempt++;
2445
- const delay = getReconnectDelay(state.reconnectAttempt);
2446
- if (error2.message === "Unauthorized") {
2447
- state.reconnecting = false;
2448
- if (!isReconnect) {
2449
- tunnelSpinner?.fail("Unauthorized");
2450
- }
2451
- throw error2;
2452
- }
2896
+ },
2897
+ onDisconnected: (code, reason) => {
2898
+ state.connected = false;
2453
2899
  logActivity(state, {
2454
- type: "error",
2455
- error: `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`
2900
+ type: "info",
2901
+ message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2456
2902
  });
2903
+ emitAgentDisconnected(state.agentId, { code, reason });
2457
2904
  if (state.interactive) displayStatus(state);
2458
- await new Promise((resolve) => setTimeout(resolve, delay));
2459
- }
2460
- }
2461
- state.reconnecting = false;
2462
- };
2463
- const triggerReconnect = async () => {
2464
- if (!state.reconnecting) {
2465
- state.reconnectPromise = connectWithRetry(true).catch((err) => {
2466
- logActivity(state, {
2467
- type: "error",
2468
- error: `Reconnection failed: ${err.message}`
2469
- });
2905
+ },
2906
+ onError: (error2) => {
2907
+ logActivity(state, { type: "error", error: error2 });
2470
2908
  if (state.interactive) displayStatus(state);
2471
- });
2472
- }
2473
- if (state.reconnectPromise) {
2474
- await state.reconnectPromise;
2475
- }
2476
- };
2477
- await connectWithRetry(false);
2478
- state.lockHeartbeatTimer = setInterval(async () => {
2479
- for (const convId of state.lockedConversations) {
2480
- const extended = await extendConversationLock(
2481
- state.agentId,
2482
- convId,
2483
- state.lockCorrelationId,
2484
- state.authHeader
2485
- );
2486
- if (!extended) {
2487
- logActivity(state, {
2488
- type: "error",
2489
- error: `Failed to extend lock on conversation ${convId.slice(0, 8)}`
2909
+ },
2910
+ // Web traffic is proxied transparently; only note opencode is live.
2911
+ onResponse: () => {
2912
+ state.opencodeConnected = true;
2913
+ },
2914
+ // A channel message was queued and the api-worker pinged us over the
2915
+ // tunnel to drain immediately instead of waiting for the next poll tick.
2916
+ // Best-effort + non-fatal: mirror the on-connect drain block. A failed
2917
+ // drain here is logged and swallowed — the steady-state poll retries, so
2918
+ // a lost/failed ping can never orphan a message (§2 invariant).
2919
+ onDrainPing: () => {
2920
+ if (!state.running) return;
2921
+ logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
2922
+ channelDriver.drainPending().then((processed) => {
2923
+ if (processed > 0) {
2924
+ state.messageCount += processed;
2925
+ logActivity(state, {
2926
+ type: "info",
2927
+ message: `Drained ${processed} queued message(s) on ping`
2928
+ });
2929
+ if (state.interactive) displayStatus(state);
2930
+ }
2931
+ }).catch((error2) => {
2932
+ const message = error2 instanceof Error ? error2.message : String(error2);
2933
+ logActivity(state, {
2934
+ type: "error",
2935
+ error: `Failed to drain queued messages on ping: ${message}`
2936
+ });
2937
+ if (state.interactive) displayStatus(state);
2490
2938
  });
2491
- state.lockedConversations.delete(convId);
2492
- }
2939
+ },
2940
+ onInfo: (message) => logActivity(state, { type: "info", message })
2493
2941
  }
2494
- }, LOCK_HEARTBEAT_INTERVAL_MS);
2495
- if (interactive && !state.json) {
2496
- displayStatus(state);
2497
- } else {
2498
- log(state, "Processing queue...");
2942
+ });
2943
+ state.connection = connection;
2944
+ try {
2945
+ await connection.connect();
2946
+ } catch (error2) {
2947
+ if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2948
+ throw error2;
2499
2949
  }
2500
- await processQueue(state, state.authHeader, triggerReconnect);
2501
- await cleanup(state, state.authHeader);
2950
+ if (!interactive || state.json) {
2951
+ log(state, "Driving channel messages...");
2952
+ }
2953
+ await driveChannels(state, channelDriver);
2954
+ await cleanup(state);
2502
2955
  if (state.json) {
2503
2956
  console.log(
2504
2957
  JSON.stringify({
@@ -2512,7 +2965,7 @@ async function run(options) {
2512
2965
  await shutdownTelemetry();
2513
2966
  process.exit(0);
2514
2967
  } catch (error2) {
2515
- await cleanup(state, state.authHeader);
2968
+ await cleanup(state);
2516
2969
  const message = error2 instanceof Error ? error2.message : String(error2);
2517
2970
  if (state.json) {
2518
2971
  console.log(JSON.stringify({ status: "error", error: message }));
@@ -2530,16 +2983,22 @@ async function run(options) {
2530
2983
 
2531
2984
  // src/index.ts
2532
2985
  var program = new Command();
2533
- program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option("-e, --env <environment>", "Environment to use (local, dev, production)", "production").hook("preAction", (thisCommand) => {
2534
- const env = thisCommand.opts().env;
2535
- if (env) {
2536
- setEnvironment(env);
2986
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
2987
+ "--endpoint <url>",
2988
+ "Evident API base URL (default: production; e.g. http://localhost:3001)"
2989
+ ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
2990
+ const { endpoint, tunnel } = thisCommand.opts();
2991
+ if (endpoint) {
2992
+ setEndpoint(endpoint);
2993
+ }
2994
+ if (tunnel) {
2995
+ setTunnelUrl(tunnel);
2537
2996
  }
2538
2997
  });
2539
2998
  program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
2540
- program.command("logout").description("Remove stored credentials").action(logout);
2999
+ program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
2541
3000
  program.command("whoami").description("Show the currently logged in user").action(whoami);
2542
- program.command("run").description("Connect to Evident and process messages").requiredOption("-a, --agent <id>", "Agent ID to connect to").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
3001
+ program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
2543
3002
  (options) => {
2544
3003
  run({
2545
3004
  agent: options.agent,