@evident-ai/cli 0.1.6 → 0.2.1-dev.042a051

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:3000/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,21 +54,41 @@ 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
  }
81
+ function getCliName() {
82
+ const argv1 = process.argv[1] || "";
83
+ const isNpx = process.env.npm_execpath?.includes("npx") || process.env.npm_command === "exec" || argv1.includes("_npx") || argv1.includes(".npm/_cacache");
84
+ if (isNpx) {
85
+ return "npx @evident-ai/cli@latest";
86
+ }
87
+ if (argv1.includes("tsx") || argv1.includes("ts-node")) {
88
+ return "pnpm --filter @evident-ai/cli dev:run";
89
+ }
90
+ return "evident";
91
+ }
83
92
 
84
93
  // src/lib/api.ts
85
94
  var ApiClient = class {
@@ -176,7 +185,6 @@ var api = {
176
185
 
177
186
  // src/lib/keychain.ts
178
187
  var SERVICE_NAME = "evident-cli";
179
- var ACCOUNT_NAME = "default";
180
188
  async function getKeytar() {
181
189
  try {
182
190
  const keytar = await import("keytar");
@@ -188,10 +196,13 @@ async function getKeytar() {
188
196
  return null;
189
197
  }
190
198
  }
199
+ function keychainAccount() {
200
+ return getApiUrlConfig();
201
+ }
191
202
  async function storeToken(credentials2) {
192
203
  const keytar = await getKeytar();
193
204
  if (keytar) {
194
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
205
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
195
206
  } else {
196
207
  setCredentials({
197
208
  token: credentials2.token,
@@ -203,12 +214,13 @@ async function storeToken(credentials2) {
203
214
  async function getToken() {
204
215
  const keytar = await getKeytar();
205
216
  if (keytar) {
206
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
217
+ const account = keychainAccount();
218
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
207
219
  if (stored) {
208
220
  try {
209
221
  return JSON.parse(stored);
210
222
  } catch {
211
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
223
+ await keytar.deletePassword(SERVICE_NAME, account);
212
224
  return null;
213
225
  }
214
226
  }
@@ -223,12 +235,26 @@ async function getToken() {
223
235
  }
224
236
  return null;
225
237
  }
226
- async function deleteToken() {
238
+ async function deleteToken(options = {}) {
227
239
  const keytar = await getKeytar();
228
240
  if (keytar) {
229
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
241
+ if (options.all) {
242
+ const all = await keytar.findCredentials(SERVICE_NAME).catch(() => []);
243
+ await Promise.all(
244
+ all.map(
245
+ (entry) => keytar.deletePassword(SERVICE_NAME, entry.account).catch(() => {
246
+ })
247
+ )
248
+ );
249
+ } else {
250
+ await keytar.deletePassword(SERVICE_NAME, keychainAccount());
251
+ }
252
+ }
253
+ if (options.all) {
254
+ clearAllCredentials();
255
+ } else {
256
+ clearCredentials();
230
257
  }
231
- clearCredentials();
232
258
  }
233
259
 
234
260
  // src/utils/ui.ts
@@ -396,25 +422,32 @@ async function login(options) {
396
422
  }
397
423
 
398
424
  // src/commands/logout.ts
399
- async function logout() {
425
+ async function logout(options = {}) {
426
+ if (options.all) {
427
+ await deleteToken({ all: true });
428
+ printSuccess("Logged out of all endpoints.");
429
+ return;
430
+ }
400
431
  const credentials2 = await getToken();
401
432
  if (!credentials2) {
402
- printWarning("You are not logged in.");
433
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
403
434
  return;
404
435
  }
405
436
  await deleteToken();
406
- printSuccess("Logged out successfully.");
437
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
407
438
  }
408
439
 
409
440
  // src/commands/whoami.ts
410
441
  import chalk3 from "chalk";
411
442
  async function whoami() {
443
+ const apiUrl = getApiUrlConfig();
412
444
  const credentials2 = await getToken();
413
445
  if (!credentials2) {
414
- printError("Not logged in. Run the `login` command to authenticate.");
446
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
415
447
  process.exit(1);
416
448
  }
417
449
  blank();
450
+ console.log(keyValue("Endpoint", apiUrl));
418
451
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
419
452
  console.log(keyValue("User ID", credentials2.user.id));
420
453
  if (credentials2.expiresAt) {
@@ -432,10 +465,24 @@ async function whoami() {
432
465
  blank();
433
466
  }
434
467
 
435
- // src/commands/tunnel.ts
436
- import WebSocket from "ws";
437
- import chalk4 from "chalk";
438
- import ora2 from "ora";
468
+ // src/commands/run.ts
469
+ import chalk6 from "chalk";
470
+ import ora3 from "ora";
471
+ import { select as select3 } from "@inquirer/prompts";
472
+
473
+ // ../../packages/types/src/telemetry/index.ts
474
+ var TelemetryEventTypes = {
475
+ // Agent activity events (shown in web UI activity log)
476
+ AGENT_CONNECTED: "agent.connected",
477
+ AGENT_DISCONNECTED: "agent.disconnected",
478
+ AGENT_MESSAGE_PROCESSING: "agent.message_processing",
479
+ AGENT_MESSAGE_DONE: "agent.message_done",
480
+ AGENT_MESSAGE_FAILED: "agent.message_failed"
481
+ };
482
+
483
+ // ../../packages/types/src/tunnel/index.ts
484
+ var MAX_FRAME_BYTES = 256 * 1024;
485
+ var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
439
486
 
440
487
  // src/lib/telemetry.ts
441
488
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -451,7 +498,7 @@ function logEvent(eventType, options = {}) {
451
498
  severity: options.severity || "info",
452
499
  message: options.message,
453
500
  metadata: options.metadata,
454
- sandbox_id: options.sandboxId,
501
+ agent_id: options.agentId,
455
502
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
456
503
  };
457
504
  eventBuffer.push(event);
@@ -465,10 +512,10 @@ function logEvent(eventType, options = {}) {
465
512
  }
466
513
  }
467
514
  var telemetry = {
468
- debug: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "debug", message, metadata, sandboxId }),
469
- info: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "info", message, metadata, sandboxId }),
470
- warn: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "warning", message, metadata, sandboxId }),
471
- error: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "error", message, metadata, sandboxId })
515
+ debug: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "debug", message, metadata, agentId }),
516
+ info: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "info", message, metadata, agentId }),
517
+ warn: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "warning", message, metadata, agentId }),
518
+ error: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "error", message, metadata, agentId })
472
519
  };
473
520
  async function flushEvents() {
474
521
  if (eventBuffer.length === 0) return;
@@ -487,17 +534,18 @@ async function flushEvents() {
487
534
  const controller = new AbortController();
488
535
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
489
536
  try {
537
+ const request = {
538
+ events,
539
+ client_type: "cli",
540
+ client_version: CLI_VERSION
541
+ };
490
542
  const response = await fetch(`${apiUrl}/telemetry/events`, {
491
543
  method: "POST",
492
544
  headers: {
493
545
  "Content-Type": "application/json",
494
546
  Authorization: `Bearer ${credentials2.token}`
495
547
  },
496
- body: JSON.stringify({
497
- events,
498
- client_type: "cli",
499
- client_version: CLI_VERSION
500
- }),
548
+ body: JSON.stringify(request),
501
549
  signal: controller.signal
502
550
  });
503
551
  if (!response.ok) {
@@ -520,6 +568,32 @@ async function shutdownTelemetry() {
520
568
  }
521
569
  await flushEvents();
522
570
  }
571
+ function emitEvent(event) {
572
+ logEvent(event.event_type, {
573
+ severity: event.severity,
574
+ message: event.message,
575
+ metadata: event.metadata,
576
+ agentId: event.agent_id
577
+ });
578
+ }
579
+ function emitAgentConnected(agentId, metadata) {
580
+ emitEvent({
581
+ event_type: TelemetryEventTypes.AGENT_CONNECTED,
582
+ severity: "info",
583
+ message: "Agent CLI connected",
584
+ metadata,
585
+ agent_id: agentId
586
+ });
587
+ }
588
+ function emitAgentDisconnected(agentId, metadata) {
589
+ emitEvent({
590
+ event_type: TelemetryEventTypes.AGENT_DISCONNECTED,
591
+ severity: "info",
592
+ message: `Agent CLI disconnected (code: ${metadata.code})`,
593
+ metadata,
594
+ agent_id: agentId
595
+ });
596
+ }
523
597
  var EventTypes = {
524
598
  // Tunnel lifecycle
525
599
  TUNNEL_STARTING: "tunnel.starting",
@@ -547,631 +621,2062 @@ var EventTypes = {
547
621
  CLI_ERROR: "cli.error"
548
622
  };
549
623
 
550
- // src/commands/tunnel.ts
551
- var MAX_RECONNECT_DELAY = 3e4;
552
- var BASE_RECONNECT_DELAY = 500;
553
- var MAX_ACTIVITY_LOG_ENTRIES = 10;
554
- function logActivity(state, entry) {
555
- const fullEntry = {
556
- ...entry,
557
- timestamp: /* @__PURE__ */ new Date()
558
- };
559
- state.activityLog.push(fullEntry);
560
- if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
561
- state.activityLog.shift();
624
+ // src/lib/auth.ts
625
+ async function getAuthCredentials() {
626
+ const agentKey = process.env.EVIDENT_AGENT_KEY;
627
+ if (agentKey) {
628
+ return { token: agentKey, authType: "agent_key" };
562
629
  }
563
- state.lastActivity = fullEntry.timestamp;
564
- }
565
- function formatActivityEntry(entry, _verbose) {
566
- const time = entry.timestamp.toLocaleTimeString("en-US", {
567
- hour12: false,
568
- hour: "2-digit",
569
- minute: "2-digit",
570
- second: "2-digit"
571
- });
572
- switch (entry.type) {
573
- case "request": {
574
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
575
- const status = entry.status ? ` \u2192 ${colorizeStatus(entry.status)}` : " ...";
576
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.cyan("\u2190")} ${entry.method} ${entry.path}${status}${duration}`;
577
- }
578
- case "response": {
579
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
580
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.green("\u2192")} ${entry.method} ${entry.path} ${colorizeStatus(entry.status)}${duration}`;
581
- }
582
- case "error": {
583
- const errorMsg = entry.error || "Unknown error";
584
- const path = entry.path ? ` ${entry.method} ${entry.path}` : "";
585
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.red("\u2717")}${path} - ${chalk4.red(errorMsg)}`;
586
- }
587
- case "info": {
588
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.blue("\u25CF")} ${entry.message}`;
589
- }
590
- default:
591
- return ` ${chalk4.dim(`[${time}]`)} ${entry.message || "Unknown"}`;
630
+ const userToken = process.env.EVIDENT_TOKEN;
631
+ if (userToken) {
632
+ return { token: userToken, authType: "bearer" };
592
633
  }
593
- }
594
- function colorizeStatus(status) {
595
- if (status >= 200 && status < 300) {
596
- return chalk4.green(status.toString());
597
- } else if (status >= 300 && status < 400) {
598
- return chalk4.yellow(status.toString());
599
- } else if (status >= 400 && status < 500) {
600
- return chalk4.red(status.toString());
601
- } else if (status >= 500) {
602
- return chalk4.bgRed.white(` ${status} `);
634
+ const keychainCreds = await getToken();
635
+ if (keychainCreds) {
636
+ return {
637
+ token: keychainCreds.token,
638
+ authType: "bearer",
639
+ user: keychainCreds.user
640
+ };
603
641
  }
604
- return status.toString();
642
+ return null;
605
643
  }
606
- function displayStatus(state) {
607
- console.clear();
608
- console.log(chalk4.bold("Evident Tunnel"));
609
- console.log(chalk4.dim("\u2500".repeat(60)));
610
- blank();
611
- if (state.connected) {
612
- console.log(` ${chalk4.green("\u25CF")} Status: ${chalk4.green("Connected")}`);
613
- console.log(` Sandbox: ${state.sandboxId ?? "Unknown"}`);
614
- if (state.sandboxName) {
615
- console.log(` Name: ${state.sandboxName}`);
616
- }
617
- } else {
618
- console.log(` ${chalk4.yellow("\u25CB")} Status: ${chalk4.yellow("Reconnecting...")}`);
619
- if (state.reconnectAttempt > 0) {
620
- console.log(` Attempt: ${state.reconnectAttempt}`);
621
- }
622
- }
623
- blank();
624
- if (state.activityLog.length > 0) {
625
- console.log(chalk4.bold(" Activity:"));
626
- for (const entry of state.activityLog) {
627
- console.log(formatActivityEntry(entry, state.verbose));
628
- }
629
- } else {
630
- console.log(chalk4.dim(" No activity yet. Waiting for requests..."));
631
- }
632
- blank();
633
- console.log(chalk4.dim("\u2500".repeat(60)));
634
- if (state.verbose) {
635
- console.log(chalk4.dim(" Verbose mode: ON (request/response bodies will be logged)"));
644
+ function getAuthHeader(credentials2) {
645
+ if (credentials2.authType === "agent_key") {
646
+ return `SandboxKey ${credentials2.token}`;
636
647
  }
637
- console.log(chalk4.dim(" Press Ctrl+C to disconnect"));
648
+ return `Bearer ${credentials2.token}`;
638
649
  }
639
- function displayError(_state, error2, details) {
640
- blank();
641
- console.log(chalk4.bgRed.white.bold(" ERROR "));
642
- console.log(chalk4.red(` ${error2}`));
643
- if (details) {
644
- console.log(chalk4.dim(` ${details}`));
645
- }
646
- blank();
650
+ function isInteractive(jsonOutput) {
651
+ if (jsonOutput) return false;
652
+ if (process.env.CI) return false;
653
+ if (process.env.GITHUB_ACTIONS) return false;
654
+ if (!process.stdin.isTTY) return false;
655
+ return true;
647
656
  }
648
- async function validateSandbox(token, sandboxId) {
649
- const apiUrl = getApiUrlConfig();
657
+
658
+ // src/lib/opencode/health.ts
659
+ async function checkOpenCodeHealth(port) {
650
660
  try {
651
- const response = await fetch(`${apiUrl}/sandboxes/${sandboxId}`, {
652
- headers: {
653
- Authorization: `Bearer ${token}`
654
- }
661
+ const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
662
+ signal: AbortSignal.timeout(2e3)
663
+ // 2 second timeout
655
664
  });
656
- if (response.status === 404) {
657
- return { valid: false, error: "Sandbox not found" };
658
- }
659
- if (response.status === 401) {
660
- return { valid: false, error: "Authentication failed. Please run `evident login` again." };
661
- }
662
665
  if (!response.ok) {
663
- return { valid: false, error: `API error: ${response.status}` };
664
- }
665
- const sandbox = await response.json();
666
- if (sandbox.sandbox_type !== "remote") {
667
- return {
668
- valid: false,
669
- error: `Sandbox is type '${sandbox.sandbox_type}', must be 'remote' for tunnel connection`
670
- };
666
+ return { healthy: false, error: `HTTP ${response.status}` };
671
667
  }
672
- return { valid: true, name: sandbox.name };
668
+ const data = await response.json().catch(() => ({}));
669
+ return { healthy: true, version: data.version };
673
670
  } catch (error2) {
674
671
  const message = error2 instanceof Error ? error2.message : "Unknown error";
675
- return { valid: false, error: `Failed to validate sandbox: ${message}` };
672
+ return { healthy: false, error: message };
676
673
  }
677
674
  }
678
- async function forwardToOpenCode(port, request, requestId, state) {
679
- const url = `http://localhost:${port}${request.path}`;
675
+ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
680
676
  const startTime = Date.now();
681
- state.pendingRequests.set(requestId, {
682
- startTime,
683
- method: request.method,
684
- path: request.path
685
- });
686
- logActivity(state, {
687
- type: "request",
688
- method: request.method,
689
- path: request.path,
690
- requestId
691
- });
692
- displayStatus(state);
693
- if (state.verbose && request.body) {
694
- console.log(chalk4.dim(` Request body: ${JSON.stringify(request.body, null, 2)}`));
695
- }
696
- telemetry.debug(
697
- EventTypes.OPENCODE_REQUEST_FORWARDED,
698
- `Forwarding ${request.method} ${request.path}`,
699
- {
700
- method: request.method,
701
- path: request.path,
702
- port,
703
- requestId
704
- },
705
- state.sandboxId ?? void 0
706
- );
677
+ while (Date.now() - startTime < timeoutMs) {
678
+ const health = await checkOpenCodeHealth(port);
679
+ if (health.healthy) {
680
+ return health;
681
+ }
682
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
683
+ }
684
+ return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
685
+ }
686
+
687
+ // src/lib/opencode/process.ts
688
+ import { execSync, spawn } from "child_process";
689
+ var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
690
+ function getProcessCwd(pid) {
691
+ const platform = process.platform;
707
692
  try {
708
- const response = await fetch(url, {
709
- method: request.method,
710
- headers: {
711
- "Content-Type": "application/json",
712
- ...request.headers
713
- },
714
- body: request.body ? JSON.stringify(request.body) : void 0
715
- });
716
- let body;
717
- const contentType = response.headers.get("Content-Type");
718
- if (contentType?.includes("application/json")) {
719
- body = await response.json();
720
- } else {
721
- body = await response.text();
722
- }
723
- const durationMs = Date.now() - startTime;
724
- state.pendingRequests.delete(requestId);
725
- const lastEntry = state.activityLog[state.activityLog.length - 1];
726
- if (lastEntry && lastEntry.requestId === requestId) {
727
- lastEntry.type = "response";
728
- lastEntry.status = response.status;
729
- lastEntry.durationMs = durationMs;
730
- } else {
731
- logActivity(state, {
732
- type: "response",
733
- method: request.method,
734
- path: request.path,
735
- status: response.status,
736
- durationMs,
737
- requestId
738
- });
693
+ if (platform === "darwin") {
694
+ const output = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null`, {
695
+ encoding: "utf-8",
696
+ stdio: ["pipe", "pipe", "pipe"]
697
+ }).trim();
698
+ const lines = output.split("\n");
699
+ for (const line of lines) {
700
+ if (line.startsWith("n") && !line.startsWith("n ")) {
701
+ return line.slice(1);
702
+ }
703
+ }
704
+ } else if (platform === "linux") {
705
+ const output = execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, {
706
+ encoding: "utf-8",
707
+ stdio: ["pipe", "pipe", "pipe"]
708
+ }).trim();
709
+ if (output) return output;
739
710
  }
740
- displayStatus(state);
741
- if (state.verbose && body) {
742
- const bodyStr = typeof body === "string" ? body : JSON.stringify(body, null, 2);
743
- const truncated = bodyStr.length > 500 ? bodyStr.substring(0, 500) + "..." : bodyStr;
744
- console.log(chalk4.dim(` Response body: ${truncated}`));
711
+ } catch {
712
+ }
713
+ return void 0;
714
+ }
715
+ function isPortInUse(port) {
716
+ const platform = process.platform;
717
+ try {
718
+ if (platform === "darwin" || platform === "linux") {
719
+ execSync(`lsof -i :${port} -sTCP:LISTEN 2>/dev/null`, {
720
+ encoding: "utf-8",
721
+ stdio: ["pipe", "pipe", "pipe"]
722
+ });
723
+ return true;
745
724
  }
746
- telemetry.debug(
747
- EventTypes.OPENCODE_RESPONSE_SENT,
748
- `Response ${response.status}`,
749
- {
750
- status: response.status,
751
- path: request.path,
752
- durationMs,
753
- requestId
754
- },
755
- state.sandboxId ?? void 0
756
- );
757
- return {
758
- status: response.status,
759
- body
760
- };
761
- } catch (error2) {
762
- const message = error2 instanceof Error ? error2.message : "Unknown error";
763
- const durationMs = Date.now() - startTime;
764
- state.pendingRequests.delete(requestId);
765
- logActivity(state, {
766
- type: "error",
767
- method: request.method,
768
- path: request.path,
769
- error: `OpenCode unreachable: ${message}`,
770
- durationMs,
771
- requestId
772
- });
773
- displayStatus(state);
774
- telemetry.error(
775
- EventTypes.OPENCODE_UNREACHABLE,
776
- `Failed to connect to OpenCode: ${message}`,
777
- {
778
- port,
779
- path: request.path,
780
- error: message,
781
- requestId
782
- },
783
- state.sandboxId ?? void 0
784
- );
785
- return {
786
- status: 502,
787
- body: { error: "Failed to connect to OpenCode", message }
788
- };
725
+ } catch {
789
726
  }
727
+ return false;
790
728
  }
791
- function getReconnectDelay(attempt) {
792
- const exponentialDelay = BASE_RECONNECT_DELAY * Math.pow(2, attempt);
793
- const jitter = Math.random() * 1e3;
794
- return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
729
+ function findAvailablePort(startPort, maxAttempts = 10) {
730
+ for (let i = 0; i < maxAttempts; i++) {
731
+ const port = startPort + i;
732
+ if (!isPortInUse(port)) {
733
+ return port;
734
+ }
735
+ }
736
+ return null;
795
737
  }
796
- async function connect(token, sandboxId, port, state) {
797
- const tunnelUrl = getTunnelUrlConfig();
798
- const url = `${tunnelUrl}/tunnel/${sandboxId}/connect`;
799
- logActivity(state, {
800
- type: "info",
801
- message: "Connecting to tunnel relay..."
802
- });
803
- displayStatus(state);
804
- telemetry.info(
805
- EventTypes.TUNNEL_STARTING,
806
- `Connecting to ${url}`,
807
- {
808
- sandboxId,
809
- port,
810
- tunnelUrl
811
- },
812
- sandboxId
813
- );
814
- return new Promise((resolve, reject) => {
815
- const ws = new WebSocket(url, {
816
- headers: {
817
- Authorization: `Bearer ${token}`
818
- }
819
- });
820
- ws.on("open", () => {
821
- state.connected = true;
822
- state.reconnectAttempt = 0;
823
- logActivity(state, {
824
- type: "info",
825
- message: "WebSocket connection established"
826
- });
827
- displayStatus(state);
828
- });
829
- ws.on("message", async (data) => {
738
+ function findOpenCodeProcesses() {
739
+ const instances = [];
740
+ try {
741
+ const platform = process.platform;
742
+ if (platform === "darwin" || platform === "linux") {
743
+ let pids = [];
830
744
  try {
831
- const message = JSON.parse(data.toString());
832
- switch (message.type) {
833
- case "connected":
834
- state.sandboxId = message.sandbox_id ?? sandboxId;
835
- logActivity(state, {
836
- type: "info",
837
- message: `Tunnel connected (sandbox: ${state.sandboxId})`
838
- });
839
- telemetry.info(
840
- EventTypes.TUNNEL_CONNECTED,
841
- `Tunnel connected`,
842
- {
843
- sandboxId: message.sandbox_id
844
- },
845
- message.sandbox_id
846
- );
847
- displayStatus(state);
848
- break;
849
- case "error":
850
- logActivity(state, {
851
- type: "error",
852
- error: message.message || "Unknown tunnel error"
853
- });
854
- telemetry.error(
855
- EventTypes.TUNNEL_ERROR,
856
- `Tunnel error: ${message.message}`,
857
- {
858
- code: message.code,
859
- message: message.message
860
- },
861
- state.sandboxId ?? void 0
862
- );
863
- displayStatus(state);
864
- if (message.code === "unauthorized") {
865
- ws.close();
866
- reject(new Error("Unauthorized"));
745
+ const pgrepOutput = execSync('pgrep -f "opencode serve|opencode-serve"', {
746
+ encoding: "utf-8",
747
+ stdio: ["pipe", "pipe", "pipe"]
748
+ }).trim();
749
+ if (pgrepOutput) {
750
+ pids = pgrepOutput.split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
751
+ }
752
+ } catch {
753
+ try {
754
+ const psOutput = execSync('ps aux | grep -E "opencode (serve|--port)" | grep -v grep', {
755
+ encoding: "utf-8",
756
+ stdio: ["pipe", "pipe", "pipe"]
757
+ }).trim();
758
+ if (psOutput) {
759
+ for (const line of psOutput.split("\n")) {
760
+ const parts = line.trim().split(/\s+/);
761
+ if (parts.length >= 2) {
762
+ const pid = parseInt(parts[1], 10);
763
+ if (!isNaN(pid)) pids.push(pid);
764
+ }
867
765
  }
868
- break;
869
- case "ping":
870
- ws.send(JSON.stringify({ type: "pong" }));
871
- break;
872
- case "request":
873
- if (message.id && message.payload) {
874
- telemetry.debug(
875
- EventTypes.OPENCODE_REQUEST_RECEIVED,
876
- `Request: ${message.payload.method} ${message.payload.path}`,
877
- {
878
- requestId: message.id,
879
- method: message.payload.method,
880
- path: message.payload.path
881
- },
882
- state.sandboxId ?? void 0
883
- );
884
- const response = await forwardToOpenCode(port, message.payload, message.id, state);
885
- ws.send(
886
- JSON.stringify({
887
- type: "response",
888
- id: message.id,
889
- payload: response
890
- })
891
- );
766
+ }
767
+ } catch {
768
+ }
769
+ }
770
+ for (const pid of pids) {
771
+ try {
772
+ const lsofOutput = execSync(`lsof -Pan -p ${pid} -i TCP -sTCP:LISTEN 2>/dev/null`, {
773
+ encoding: "utf-8",
774
+ stdio: ["pipe", "pipe", "pipe"]
775
+ }).trim();
776
+ for (const line of lsofOutput.split("\n")) {
777
+ const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
778
+ if (portMatch) {
779
+ const port = parseInt(portMatch[1], 10);
780
+ if (!isNaN(port) && !instances.some((i) => i.port === port)) {
781
+ const cwd = getProcessCwd(pid);
782
+ instances.push({ pid, port, cwd });
783
+ }
892
784
  }
893
- break;
785
+ }
786
+ } catch {
894
787
  }
895
- } catch (error2) {
896
- const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
897
- logActivity(state, {
898
- type: "error",
899
- error: `Failed to handle message: ${errorMessage}`
900
- });
901
- telemetry.error(
902
- EventTypes.TUNNEL_ERROR,
903
- `Failed to handle message: ${errorMessage}`,
904
- {
905
- error: errorMessage
906
- },
907
- state.sandboxId ?? void 0
908
- );
909
- displayStatus(state);
910
788
  }
911
- });
912
- ws.on("close", (code, reason) => {
913
- state.connected = false;
914
- const reasonStr = reason.toString() || "No reason provided";
915
- logActivity(state, {
916
- type: "info",
917
- message: `Disconnected (code: ${code}, reason: ${reasonStr})`
918
- });
919
- telemetry.info(
920
- EventTypes.TUNNEL_DISCONNECTED,
921
- "Tunnel disconnected",
922
- {
923
- sandboxId: state.sandboxId,
924
- code,
925
- reason: reasonStr
926
- },
927
- state.sandboxId ?? void 0
928
- );
929
- displayStatus(state);
930
- resolve();
931
- });
932
- ws.on("error", (error2) => {
933
- state.connected = false;
934
- logActivity(state, {
935
- type: "error",
936
- error: `Connection error: ${error2.message}`
937
- });
938
- telemetry.error(
939
- EventTypes.TUNNEL_ERROR,
940
- `Connection error: ${error2.message}`,
941
- {
942
- error: error2.message
943
- },
944
- state.sandboxId ?? void 0
945
- );
946
- displayStatus(state);
947
- });
948
- const cleanup = async () => {
949
- process.removeAllListeners("SIGINT");
950
- process.removeAllListeners("SIGTERM");
951
- logActivity(state, {
952
- type: "info",
953
- message: "Shutting down..."
954
- });
955
- displayStatus(state);
956
- telemetry.info(
957
- EventTypes.TUNNEL_DISCONNECTED,
958
- "Tunnel stopped by user",
959
- {
960
- sandboxId: state.sandboxId
961
- },
962
- state.sandboxId ?? void 0
963
- );
964
- await shutdownTelemetry();
965
- ws.close();
966
- process.exit(0);
967
- };
968
- process.removeAllListeners("SIGINT");
969
- process.removeAllListeners("SIGTERM");
970
- process.once("SIGINT", () => void cleanup());
971
- process.once("SIGTERM", () => void cleanup());
789
+ }
790
+ } catch {
791
+ }
792
+ return instances;
793
+ }
794
+ async function scanPortsForOpenCode() {
795
+ const instances = [];
796
+ const checks = OPENCODE_PORT_RANGE.map(async (port) => {
797
+ const health = await checkOpenCodeHealth(port);
798
+ if (health.healthy) {
799
+ let pid = 0;
800
+ try {
801
+ const lsofOutput = execSync(`lsof -ti :${port} -sTCP:LISTEN 2>/dev/null`, {
802
+ encoding: "utf-8",
803
+ stdio: ["pipe", "pipe", "pipe"]
804
+ }).trim();
805
+ if (lsofOutput) {
806
+ pid = parseInt(lsofOutput.split("\n")[0], 10) || 0;
807
+ }
808
+ } catch {
809
+ }
810
+ const cwd = pid ? getProcessCwd(pid) : void 0;
811
+ return { pid, port, cwd, version: health.version };
812
+ }
813
+ return null;
972
814
  });
815
+ const results = await Promise.all(checks);
816
+ for (const result of results) {
817
+ if (result) {
818
+ instances.push(result);
819
+ }
820
+ }
821
+ return instances;
973
822
  }
974
- async function tunnel(options) {
975
- const verbose = options.verbose ?? false;
976
- const credentials2 = await getToken();
977
- if (!credentials2) {
978
- telemetry.error(EventTypes.CLI_ERROR, "Not logged in", { command: "tunnel" });
979
- printError("Not logged in. Run `evident login` first.");
980
- process.exit(1);
823
+ async function findHealthyOpenCodeInstances() {
824
+ const processes = findOpenCodeProcesses();
825
+ const healthy = [];
826
+ for (const proc of processes) {
827
+ const health = await checkOpenCodeHealth(proc.port);
828
+ if (health.healthy) {
829
+ healthy.push({ ...proc, version: health.version });
830
+ }
981
831
  }
982
- const port = options.port ?? 4096;
983
- const sandboxId = options.sandbox;
984
- if (!sandboxId) {
985
- printError("--sandbox <id> is required");
986
- blank();
987
- console.log(chalk4.dim("To find your sandbox ID:"));
988
- console.log(chalk4.dim(" 1. Create a remote sandbox in the Evident web UI"));
989
- console.log(chalk4.dim(" 2. Copy the sandbox ID from the URL or settings"));
990
- console.log(chalk4.dim(" 3. Run: evident tunnel --sandbox <id>"));
991
- blank();
992
- telemetry.error(EventTypes.CLI_ERROR, "Missing sandbox ID", { command: "tunnel" });
993
- process.exit(1);
832
+ if (healthy.length === 0) {
833
+ const scanned = await scanPortsForOpenCode();
834
+ return scanned;
994
835
  }
995
- const state = {
996
- connected: false,
997
- sandboxId,
998
- sandboxName: null,
999
- reconnectAttempt: 0,
1000
- lastActivity: /* @__PURE__ */ new Date(),
1001
- activityLog: [],
1002
- pendingRequests: /* @__PURE__ */ new Map(),
1003
- verbose
1004
- };
1005
- telemetry.info(
1006
- EventTypes.CLI_COMMAND,
1007
- "Starting tunnel command",
1008
- {
1009
- command: "tunnel",
1010
- port,
1011
- sandboxId,
1012
- verbose
1013
- },
1014
- sandboxId
1015
- );
1016
- logActivity(state, {
1017
- type: "info",
1018
- message: `Starting tunnel (port: ${port}, verbose: ${verbose})`
1019
- });
1020
- logActivity(state, {
1021
- type: "info",
1022
- message: "Validating sandbox..."
1023
- });
1024
- const validateSpinner = ora2("Validating sandbox...").start();
1025
- const validation = await validateSandbox(credentials2.token, sandboxId);
1026
- if (!validation.valid) {
1027
- validateSpinner.fail(`Sandbox validation failed: ${validation.error}`);
1028
- logActivity(state, {
1029
- type: "error",
1030
- error: `Sandbox validation failed: ${validation.error}`
1031
- });
1032
- telemetry.error(EventTypes.CLI_ERROR, `Sandbox validation failed: ${validation.error}`, {
1033
- command: "tunnel",
1034
- sandboxId
1035
- });
1036
- displayStatus(state);
1037
- process.exit(1);
836
+ return healthy;
837
+ }
838
+ async function startOpenCode(port) {
839
+ let command = "opencode";
840
+ let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
841
+ try {
842
+ execSync("which opencode", { stdio: "ignore" });
843
+ } catch {
844
+ command = "npx";
845
+ args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
1038
846
  }
1039
- state.sandboxName = validation.name ?? null;
1040
- validateSpinner.succeed(`Sandbox: ${validation.name || sandboxId}`);
1041
- logActivity(state, {
1042
- type: "info",
1043
- message: `Sandbox validated: ${validation.name || sandboxId}`
1044
- });
1045
- logActivity(state, {
1046
- type: "info",
1047
- message: `Checking OpenCode on port ${port}...`
847
+ const child = spawn(command, args, {
848
+ detached: true,
849
+ stdio: "ignore",
850
+ cwd: process.cwd()
1048
851
  });
1049
- const opencodeSpinner = ora2("Checking OpenCode connection...").start();
852
+ return child;
853
+ }
854
+ function stopOpenCode(opencodeProcess) {
855
+ if (!opencodeProcess || !opencodeProcess.pid) {
856
+ return;
857
+ }
1050
858
  try {
1051
- telemetry.debug(
1052
- EventTypes.OPENCODE_HEALTH_CHECK,
1053
- `Checking OpenCode on port ${port}`,
1054
- { port },
1055
- sandboxId
1056
- );
1057
- const response = await fetch(`http://localhost:${port}/health`);
1058
- if (!response.ok) {
1059
- throw new Error(`Health check returned ${response.status}`);
859
+ if (process.platform === "win32") {
860
+ opencodeProcess.kill("SIGTERM");
861
+ } else {
862
+ process.kill(-opencodeProcess.pid, "SIGTERM");
1060
863
  }
1061
- const healthData = await response.json().catch(() => ({}));
1062
- const version = healthData.version ? ` (v${healthData.version})` : "";
1063
- telemetry.info(
1064
- EventTypes.OPENCODE_HEALTH_OK,
1065
- `OpenCode healthy on port ${port}`,
864
+ } catch {
865
+ }
866
+ }
867
+
868
+ // src/lib/opencode/install.ts
869
+ import { execSync as execSync2 } from "child_process";
870
+ import chalk4 from "chalk";
871
+ import { select } from "@inquirer/prompts";
872
+ var OPENCODE_INSTALL_URL = "https://opencode.ai";
873
+ function isOpenCodeInstalled() {
874
+ try {
875
+ const platform = process.platform;
876
+ if (platform === "win32") {
877
+ execSync2("where opencode", { stdio: "ignore" });
878
+ } else {
879
+ execSync2("which opencode", { stdio: "ignore" });
880
+ }
881
+ return true;
882
+ } catch {
883
+ return false;
884
+ }
885
+ }
886
+ async function promptOpenCodeInstall(interactive) {
887
+ if (!interactive) {
888
+ console.log(
889
+ JSON.stringify({
890
+ status: "error",
891
+ error: "OpenCode is not installed",
892
+ install_url: OPENCODE_INSTALL_URL,
893
+ install_commands: {
894
+ npm: "npm install -g opencode-ai",
895
+ curl: "curl -fsSL https://opencode.ai/install.sh | sh"
896
+ }
897
+ })
898
+ );
899
+ return "exit";
900
+ }
901
+ blank();
902
+ console.log(chalk4.yellow("OpenCode is not installed on your system."));
903
+ blank();
904
+ console.log(chalk4.dim("OpenCode is an AI coding agent that runs locally on your machine."));
905
+ console.log(chalk4.dim(`Learn more at: ${chalk4.cyan(OPENCODE_INSTALL_URL)}`));
906
+ blank();
907
+ const action = await select({
908
+ message: "How would you like to proceed?",
909
+ choices: [
1066
910
  {
1067
- port,
1068
- healthData
911
+ name: "Show installation instructions",
912
+ value: "instructions",
913
+ description: "Display commands to install OpenCode"
1069
914
  },
1070
- sandboxId
1071
- );
1072
- opencodeSpinner.succeed(`OpenCode running on port ${port}${version}`);
1073
- logActivity(state, {
1074
- type: "info",
1075
- message: `OpenCode running on port ${port}${version}`
1076
- });
1077
- } catch (error2) {
1078
- const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1079
- telemetry.warn(
1080
- EventTypes.OPENCODE_HEALTH_FAILED,
1081
- `Could not connect to OpenCode: ${errorMessage}`,
1082
915
  {
1083
- port,
1084
- error: errorMessage
916
+ name: "Continue without OpenCode",
917
+ value: "continue",
918
+ description: "Connect anyway (requests will fail until OpenCode is installed)"
1085
919
  },
1086
- sandboxId
1087
- );
1088
- opencodeSpinner.warn(`Could not connect to OpenCode on port ${port}`);
1089
- logActivity(state, {
1090
- type: "error",
1091
- error: `OpenCode not reachable on port ${port}: ${errorMessage}`
1092
- });
1093
- printWarning("Make sure OpenCode is running before starting the tunnel:");
1094
- console.log(chalk4.dim(` opencode serve --port ${port}`));
920
+ {
921
+ name: "Exit",
922
+ value: "exit",
923
+ description: "Exit and install OpenCode manually"
924
+ }
925
+ ]
926
+ });
927
+ if (action === "instructions") {
1095
928
  blank();
1096
- }
1097
- while (true) {
1098
- try {
1099
- await connect(credentials2.token, sandboxId, port, state);
1100
- state.reconnectAttempt++;
1101
- const delay = getReconnectDelay(state.reconnectAttempt);
1102
- logActivity(state, {
1103
- type: "info",
1104
- message: `Reconnecting in ${Math.round(delay / 1e3)}s (attempt ${state.reconnectAttempt})...`
1105
- });
1106
- telemetry.info(
1107
- EventTypes.TUNNEL_RECONNECTING,
1108
- `Reconnecting (attempt ${state.reconnectAttempt})`,
929
+ console.log(chalk4.bold("Install OpenCode using one of these methods:"));
930
+ blank();
931
+ console.log(chalk4.dim(" # Option 1: Install via npm (recommended)"));
932
+ console.log(` ${chalk4.cyan("npm install -g opencode-ai")}`);
933
+ blank();
934
+ console.log(chalk4.dim(" # Option 2: Install via curl"));
935
+ console.log(` ${chalk4.cyan("curl -fsSL https://opencode.ai/install.sh | sh")}`);
936
+ blank();
937
+ console.log(chalk4.dim(`For more options, visit: ${chalk4.cyan(OPENCODE_INSTALL_URL)}`));
938
+ blank();
939
+ const afterInstall = await select({
940
+ message: "After installing, what would you like to do?",
941
+ choices: [
1109
942
  {
1110
- attempt: state.reconnectAttempt,
1111
- delayMs: delay
943
+ name: "I installed it - continue",
944
+ value: "continue",
945
+ description: "Proceed with the run command"
1112
946
  },
1113
- state.sandboxId ?? void 0
1114
- );
1115
- displayStatus(state);
1116
- await sleep(delay);
1117
- } catch (error2) {
1118
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1119
- if (message === "Unauthorized") {
1120
- telemetry.error(
1121
- EventTypes.CLI_ERROR,
1122
- "Authentication failed",
947
+ {
948
+ name: "Exit",
949
+ value: "exit",
950
+ description: "Exit now and run the command again later"
951
+ }
952
+ ]
953
+ });
954
+ if (afterInstall === "continue") {
955
+ if (isOpenCodeInstalled()) {
956
+ console.log(chalk4.green("\n\u2713 OpenCode detected!"));
957
+ return "installed";
958
+ } else {
959
+ console.log(chalk4.yellow("\nOpenCode still not detected in PATH."));
960
+ console.log(chalk4.dim("You may need to restart your terminal or add it to your PATH."));
961
+ const proceed = await select({
962
+ message: "Continue anyway?",
963
+ choices: [
964
+ { name: "Yes, continue", value: "continue" },
965
+ { name: "No, exit", value: "exit" }
966
+ ]
967
+ });
968
+ return proceed === "continue" ? "continue" : "exit";
969
+ }
970
+ }
971
+ return "exit";
972
+ }
973
+ return action;
974
+ }
975
+
976
+ // src/lib/opencode/session.ts
977
+ function opencodeBase(port) {
978
+ return `http://127.0.0.1:${port}`;
979
+ }
980
+ async function getOpenCodeDirectory(port) {
981
+ try {
982
+ const res = await fetch(`${opencodeBase(port)}/path`);
983
+ if (!res.ok) return null;
984
+ const body = await res.json();
985
+ const dir = typeof body.directory === "string" && body.directory || typeof body.worktree === "string" && body.worktree || typeof body.path?.cwd === "string" && body.path.cwd || typeof body.path?.directory === "string" && body.path.directory || null;
986
+ return dir && dir.trim() ? dir.trim() : null;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
991
+ function roleOf(m) {
992
+ if (!m || typeof m !== "object") return void 0;
993
+ if (typeof m.role === "string") return m.role;
994
+ const infoRole = m.info?.role;
995
+ return typeof infoRole === "string" ? infoRole : void 0;
996
+ }
997
+ function completedOf(m) {
998
+ if (!m || typeof m !== "object") return void 0;
999
+ return m.info?.time?.completed;
1000
+ }
1001
+ async function getSessionMessages(port, sessionId) {
1002
+ try {
1003
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
1004
+ if (!res.ok) return null;
1005
+ const body = await res.json();
1006
+ return Array.isArray(body) ? body : null;
1007
+ } catch {
1008
+ return null;
1009
+ }
1010
+ }
1011
+ function isTurnComplete(messages) {
1012
+ if (!messages || messages.length === 0) return false;
1013
+ const last = messages[messages.length - 1];
1014
+ if (roleOf(last) !== "assistant") return false;
1015
+ return completedOf(last) != null;
1016
+ }
1017
+ async function createOpenCodeSession(port, directory) {
1018
+ const url = new URL(`${opencodeBase(port)}/session`);
1019
+ if (directory && directory.trim()) {
1020
+ url.searchParams.set("directory", directory.trim());
1021
+ }
1022
+ const response = await fetch(url, {
1023
+ method: "POST",
1024
+ headers: { "Content-Type": "application/json" },
1025
+ body: JSON.stringify({})
1026
+ });
1027
+ if (!response.ok) {
1028
+ const text = await response.text().catch(() => "");
1029
+ throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
1030
+ }
1031
+ const data = await response.json();
1032
+ return data.id;
1033
+ }
1034
+ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
1035
+ const body = {
1036
+ parts: [{ type: "text", text: content }]
1037
+ };
1038
+ if (options?.agent) {
1039
+ body.agent = options.agent;
1040
+ }
1041
+ if (options?.model) {
1042
+ const slashIndex = options.model.indexOf("/");
1043
+ if (slashIndex !== -1) {
1044
+ body.model = {
1045
+ providerID: options.model.substring(0, slashIndex),
1046
+ modelID: options.model.substring(slashIndex + 1)
1047
+ };
1048
+ }
1049
+ }
1050
+ let pollDone = false;
1051
+ const reportedQuestions = /* @__PURE__ */ new Set();
1052
+ const reportedPermissions = /* @__PURE__ */ new Set();
1053
+ const pollInteractive = async () => {
1054
+ while (!pollDone) {
1055
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
1056
+ if (pollDone) break;
1057
+ if (hooks?.onQuestion) {
1058
+ try {
1059
+ const res = await fetch(`${opencodeBase(port)}/question`);
1060
+ if (res.ok) {
1061
+ const questions = await res.json();
1062
+ for (const q of questions) {
1063
+ if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
1064
+ reportedQuestions.add(q.id);
1065
+ await hooks.onQuestion(q);
1066
+ }
1067
+ }
1068
+ }
1069
+ } catch {
1070
+ }
1071
+ }
1072
+ if (hooks?.onPermission) {
1073
+ try {
1074
+ const res = await fetch(`${opencodeBase(port)}/permission`);
1075
+ if (res.ok) {
1076
+ const permissions = await res.json();
1077
+ for (const p of permissions) {
1078
+ if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
1079
+ reportedPermissions.add(p.id);
1080
+ await hooks.onPermission(p);
1081
+ }
1082
+ }
1083
+ }
1084
+ } catch {
1085
+ }
1086
+ }
1087
+ }
1088
+ };
1089
+ const sendMessage = async () => {
1090
+ const controller = new AbortController();
1091
+ const timer = setTimeout(() => controller.abort(), maxWaitMs);
1092
+ try {
1093
+ const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
1094
+ method: "POST",
1095
+ headers: { "Content-Type": "application/json" },
1096
+ body: JSON.stringify(body),
1097
+ signal: controller.signal
1098
+ });
1099
+ if (!res.ok) {
1100
+ const text = await res.text().catch(() => "");
1101
+ throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
1102
+ }
1103
+ const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
1104
+ () => null
1105
+ );
1106
+ const session = sessionRes?.ok ? await sessionRes.json() : null;
1107
+ const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
1108
+ const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
1109
+ const awaitingInteraction = reportedInteraction && !turnComplete;
1110
+ return { title: session?.title, awaitingInteraction };
1111
+ } catch (err) {
1112
+ if (err instanceof Error && err.name === "AbortError") {
1113
+ throw new Error("Message processing timed out");
1114
+ }
1115
+ throw err;
1116
+ } finally {
1117
+ clearTimeout(timer);
1118
+ pollDone = true;
1119
+ }
1120
+ };
1121
+ const [result] = await Promise.all([sendMessage(), pollInteractive()]);
1122
+ return result;
1123
+ }
1124
+
1125
+ // src/lib/tunnel/connection.ts
1126
+ import WebSocket2 from "ws";
1127
+
1128
+ // src/lib/tunnel/forwarding.ts
1129
+ import WebSocket from "ws";
1130
+ var LOOPBACK_HOST = "127.0.0.1";
1131
+ var STRIP_REQ = /* @__PURE__ */ new Set([
1132
+ "host",
1133
+ "connection",
1134
+ "keep-alive",
1135
+ "proxy-authorization",
1136
+ "transfer-encoding",
1137
+ "upgrade",
1138
+ "content-length"
1139
+ ]);
1140
+ var STRIP_RES = /* @__PURE__ */ new Set([
1141
+ "connection",
1142
+ "keep-alive",
1143
+ "transfer-encoding",
1144
+ "content-encoding",
1145
+ "content-length"
1146
+ ]);
1147
+ var StreamForwarder = class {
1148
+ constructor(ws, port, callbacks = {}) {
1149
+ this.ws = ws;
1150
+ this.port = port;
1151
+ this.callbacks = callbacks;
1152
+ }
1153
+ inflight = /* @__PURE__ */ new Map();
1154
+ /**
1155
+ * Handle an edge→agent frame. Unknown frame types are ignored.
1156
+ */
1157
+ handleFrame(frame) {
1158
+ switch (frame.type) {
1159
+ case "open":
1160
+ this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
1161
+ void this.handleOpen(frame);
1162
+ break;
1163
+ case "req_data":
1164
+ this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
1165
+ break;
1166
+ case "req_end":
1167
+ this.inflight.get(frame.sid)?.endBody?.();
1168
+ break;
1169
+ case "abort":
1170
+ this.inflight.get(frame.sid)?.abort?.();
1171
+ break;
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Abort every in-flight stream (e.g. on WebSocket close).
1176
+ */
1177
+ abortAll() {
1178
+ for (const stream of this.inflight.values()) {
1179
+ try {
1180
+ stream.abort();
1181
+ } catch {
1182
+ }
1183
+ }
1184
+ this.inflight.clear();
1185
+ }
1186
+ send(frame) {
1187
+ if (this.ws.readyState === WebSocket.OPEN) {
1188
+ this.ws.send(JSON.stringify(frame));
1189
+ }
1190
+ }
1191
+ async handleOpen(frame) {
1192
+ const { sid, method, path, headers, has_body } = frame;
1193
+ if (path === TUNNEL_DRAIN_PING_PATH) {
1194
+ this.callbacks.onDrainPing?.();
1195
+ this.send({ type: "head", sid, status: 204, headers: {} });
1196
+ this.send({ type: "res_end", sid });
1197
+ return;
1198
+ }
1199
+ const ac = new AbortController();
1200
+ let bodyPromise;
1201
+ let pushBody;
1202
+ let endBody;
1203
+ if (has_body) {
1204
+ const chunks = [];
1205
+ bodyPromise = new Promise((resolve) => {
1206
+ pushBody = (buf) => {
1207
+ chunks.push(buf);
1208
+ };
1209
+ endBody = () => {
1210
+ resolve(Buffer.concat(chunks));
1211
+ };
1212
+ });
1213
+ }
1214
+ const fwdHeaders = {};
1215
+ for (const [k, v] of Object.entries(headers ?? {})) {
1216
+ if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
1217
+ }
1218
+ this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
1219
+ const body = bodyPromise ? await bodyPromise : void 0;
1220
+ if (ac.signal.aborted) {
1221
+ this.inflight.delete(sid);
1222
+ return;
1223
+ }
1224
+ let upstream;
1225
+ try {
1226
+ upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
1227
+ method,
1228
+ headers: fwdHeaders,
1229
+ body,
1230
+ redirect: "manual",
1231
+ signal: ac.signal
1232
+ });
1233
+ } catch (err) {
1234
+ this.inflight.delete(sid);
1235
+ if (!ac.signal.aborted) {
1236
+ this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
1237
+ }
1238
+ return;
1239
+ }
1240
+ const resHeaders = {};
1241
+ upstream.headers.forEach((value, key) => {
1242
+ if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
1243
+ });
1244
+ this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
1245
+ this.callbacks.onHead?.(sid, upstream.status);
1246
+ try {
1247
+ if (upstream.body) {
1248
+ const reader = upstream.body.getReader();
1249
+ while (true) {
1250
+ const { done, value } = await reader.read();
1251
+ if (done) break;
1252
+ const chunk = Buffer.from(value);
1253
+ for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
1254
+ const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
1255
+ this.send({ type: "res_data", sid, b64: slice.toString("base64") });
1256
+ }
1257
+ }
1258
+ }
1259
+ this.send({ type: "res_end", sid });
1260
+ } catch (err) {
1261
+ if (!ac.signal.aborted) {
1262
+ this.send({ type: "res_err", sid, message: String(err) });
1263
+ }
1264
+ } finally {
1265
+ this.inflight.delete(sid);
1266
+ }
1267
+ }
1268
+ };
1269
+
1270
+ // src/lib/tunnel/connection.ts
1271
+ var MAX_RECONNECT_DELAY = 3e4;
1272
+ var BASE_RECONNECT_DELAY = 500;
1273
+ function getReconnectDelay(attempt) {
1274
+ const exponentialDelay = BASE_RECONNECT_DELAY * Math.pow(2, attempt);
1275
+ const jitter = Math.random() * 1e3;
1276
+ return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
1277
+ }
1278
+ function describeSocketError(error2, url) {
1279
+ const code = error2.code;
1280
+ switch (code) {
1281
+ case "ECONNREFUSED":
1282
+ return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
1283
+ case "ENOTFOUND":
1284
+ return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
1285
+ case "ETIMEDOUT":
1286
+ return `connection timed out to ${url} (ETIMEDOUT)`;
1287
+ case "ECONNRESET":
1288
+ return `connection reset by ${url} (ECONNRESET)`;
1289
+ default: {
1290
+ const base = error2.message?.trim();
1291
+ const suffix = code ? ` (${code})` : "";
1292
+ return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
1293
+ }
1294
+ }
1295
+ }
1296
+ var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
1297
+ "open",
1298
+ "req_data",
1299
+ "req_end",
1300
+ "abort"
1301
+ ]);
1302
+ function isStreamFrame(message) {
1303
+ return STREAM_FRAME_TYPES.has(message.type);
1304
+ }
1305
+ function connectTunnel(options) {
1306
+ const {
1307
+ agentId,
1308
+ authHeader,
1309
+ port,
1310
+ onConnected,
1311
+ onDisconnected,
1312
+ onError,
1313
+ onRequest,
1314
+ onResponse,
1315
+ onInfo,
1316
+ onDrainPing
1317
+ } = options;
1318
+ const tunnelUrl = getTunnelUrlConfig();
1319
+ const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
1320
+ return new Promise((resolve, reject) => {
1321
+ const ws = new WebSocket2(url, {
1322
+ headers: {
1323
+ Authorization: authHeader
1324
+ }
1325
+ });
1326
+ const streamStartTimes = /* @__PURE__ */ new Map();
1327
+ const forwarder = new StreamForwarder(ws, port, {
1328
+ onOpen: (sid, method, path) => {
1329
+ if (path === TUNNEL_DRAIN_PING_PATH) return;
1330
+ streamStartTimes.set(sid, Date.now());
1331
+ onRequest?.(method, path, sid);
1332
+ },
1333
+ onHead: (sid, status) => {
1334
+ const startedAt = streamStartTimes.get(sid);
1335
+ streamStartTimes.delete(sid);
1336
+ onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
1337
+ },
1338
+ onDrainPing: () => onDrainPing?.()
1339
+ });
1340
+ const connectionTimeout = setTimeout(() => {
1341
+ ws.close();
1342
+ reject(new Error("Connection timeout"));
1343
+ }, 3e4);
1344
+ let upgradeRejection = null;
1345
+ ws.on("unexpected-response", (_req, res) => {
1346
+ clearTimeout(connectionTimeout);
1347
+ const chunks = [];
1348
+ res.on("data", (chunk) => chunks.push(chunk));
1349
+ res.on("end", () => {
1350
+ const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
1351
+ let detail = bodyRaw;
1352
+ try {
1353
+ const parsed = JSON.parse(bodyRaw);
1354
+ detail = parsed.error ?? parsed.message ?? bodyRaw;
1355
+ if (parsed.details) detail += ` (${parsed.details})`;
1356
+ } catch {
1357
+ }
1358
+ const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
1359
+ upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
1360
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
1361
+ reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
1362
+ });
1363
+ });
1364
+ ws.on("open", () => {
1365
+ onInfo?.("WebSocket connection established");
1366
+ });
1367
+ ws.on("message", (data) => {
1368
+ let message;
1369
+ try {
1370
+ message = JSON.parse(data.toString());
1371
+ } catch (error2) {
1372
+ const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1373
+ onError?.(`Failed to handle message: ${errorMessage}`);
1374
+ return;
1375
+ }
1376
+ if (isStreamFrame(message)) {
1377
+ forwarder.handleFrame(message);
1378
+ return;
1379
+ }
1380
+ switch (message.type) {
1381
+ case "connected": {
1382
+ clearTimeout(connectionTimeout);
1383
+ const connectedAgentId = message.agent_id ?? agentId;
1384
+ onConnected?.(connectedAgentId);
1385
+ resolve({
1386
+ ws,
1387
+ close: () => ws.close(1e3, "CLI shutdown")
1388
+ });
1389
+ break;
1390
+ }
1391
+ case "error":
1392
+ clearTimeout(connectionTimeout);
1393
+ onError?.(message.message || "Unknown tunnel error");
1394
+ if (message.code === "unauthorized") {
1395
+ ws.close();
1396
+ reject(new Error("Unauthorized"));
1397
+ }
1398
+ break;
1399
+ case "ping":
1400
+ ws.send(JSON.stringify({ type: "pong" }));
1401
+ break;
1402
+ }
1403
+ });
1404
+ ws.on("error", (error2) => {
1405
+ clearTimeout(connectionTimeout);
1406
+ const detail = upgradeRejection ?? describeSocketError(error2, url);
1407
+ onError?.(`Connection error: ${detail}`);
1408
+ reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
1409
+ });
1410
+ ws.on("close", (code, reason) => {
1411
+ const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
1412
+ forwarder.abortAll();
1413
+ streamStartTimes.clear();
1414
+ onDisconnected?.(code, reasonStr);
1415
+ });
1416
+ });
1417
+ }
1418
+
1419
+ // src/lib/tunnel/runner-connection.ts
1420
+ var RunnerConnection = class {
1421
+ opts;
1422
+ sleep;
1423
+ connection = null;
1424
+ resolvedAgentId;
1425
+ /** True while a (re)connect loop is in flight. */
1426
+ reconnecting = false;
1427
+ /** The in-flight reconnect promise, awaitable by the caller. */
1428
+ reconnectPromise = null;
1429
+ /** 1-based count of the current reconnect attempt streak. */
1430
+ reconnectAttempt = 0;
1431
+ constructor(opts) {
1432
+ this.opts = opts;
1433
+ this.resolvedAgentId = opts.agentId;
1434
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1435
+ }
1436
+ get agentId() {
1437
+ return this.resolvedAgentId;
1438
+ }
1439
+ /** Establish the initial tunnel connection (with retry/backoff). */
1440
+ async connect() {
1441
+ await this.connectWithRetry(false);
1442
+ }
1443
+ /** Close the active connection (idempotent). */
1444
+ close() {
1445
+ if (this.connection) {
1446
+ try {
1447
+ this.connection.close();
1448
+ } catch {
1449
+ }
1450
+ this.connection = null;
1451
+ }
1452
+ }
1453
+ async connectWithRetry(isReconnect) {
1454
+ if (isReconnect && this.reconnecting) return;
1455
+ this.reconnecting = true;
1456
+ this.close();
1457
+ const { events } = this.opts;
1458
+ while (this.opts.isRunning()) {
1459
+ try {
1460
+ this.connection = await connectTunnel({
1461
+ agentId: this.resolvedAgentId,
1462
+ authHeader: this.opts.getAuthHeader(),
1463
+ port: this.opts.port,
1464
+ onConnected: (agentId) => {
1465
+ this.reconnectAttempt = 0;
1466
+ this.reconnecting = false;
1467
+ this.resolvedAgentId = agentId;
1468
+ events.onConnected(agentId, isReconnect);
1469
+ },
1470
+ onDisconnected: (code, reason) => {
1471
+ events.onDisconnected(code, reason);
1472
+ if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
1473
+ this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
1474
+ events.onError?.(`Reconnection failed: ${err.message}`);
1475
+ });
1476
+ }
1477
+ },
1478
+ onError: (error2) => events.onError?.(error2),
1479
+ onResponse: () => events.onResponse?.(),
1480
+ onDrainPing: () => events.onDrainPing?.(),
1481
+ onInfo: (message) => events.onInfo?.(message)
1482
+ });
1483
+ return;
1484
+ } catch (error2) {
1485
+ this.reconnectAttempt++;
1486
+ if (error2.message === "Unauthorized") {
1487
+ this.reconnecting = false;
1488
+ throw error2;
1489
+ }
1490
+ const delay = getReconnectDelay(this.reconnectAttempt);
1491
+ events.onReconnecting?.(this.reconnectAttempt);
1492
+ events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
1493
+ await this.sleep(delay);
1494
+ }
1495
+ }
1496
+ this.reconnecting = false;
1497
+ }
1498
+ };
1499
+
1500
+ // src/lib/channels/driver.ts
1501
+ var DEFAULT_RETRY_POLICY = {
1502
+ maxAttempts: 6,
1503
+ baseDelayMs: 500,
1504
+ maxDelayMs: 3e4
1505
+ };
1506
+ var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
1507
+ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
1508
+ var ChannelAuthError = class extends Error {
1509
+ constructor(message) {
1510
+ super(message);
1511
+ this.name = "ChannelAuthError";
1512
+ }
1513
+ };
1514
+ function backoffDelay(attempt, policy) {
1515
+ const exp = policy.baseDelayMs * Math.pow(2, attempt);
1516
+ const capped = Math.min(policy.maxDelayMs, exp);
1517
+ return Math.floor(Math.random() * capped);
1518
+ }
1519
+ function isRetryableStatus(status) {
1520
+ return status === 429 || status >= 500 && status <= 599;
1521
+ }
1522
+ var ChannelDriver = class {
1523
+ agentId;
1524
+ port;
1525
+ apiUrl;
1526
+ getAuthHeader;
1527
+ conversationFilter;
1528
+ retry;
1529
+ log;
1530
+ fetchImpl;
1531
+ sleep;
1532
+ pausedPollIntervalMs;
1533
+ pausedMaxWaitMs;
1534
+ /** Cache of conversationId → opencode sessionId. */
1535
+ sessions = /* @__PURE__ */ new Map();
1536
+ /**
1537
+ * Outstanding paused-session watchers, keyed by `message.id` (WI-2-CLI).
1538
+ * Single-flight per message: while a watcher is live for a message we never
1539
+ * start a second one. The message stays `processing` for the watcher's lifetime
1540
+ * so the `?status=pending` drain cannot double-claim it.
1541
+ */
1542
+ watchers = /* @__PURE__ */ new Map();
1543
+ /**
1544
+ * Cache of the opencode root directory (from `GET /path`). Resolved lazily on
1545
+ * first session creation so drain-created sessions are rooted at the project
1546
+ * directory and thus visible in `opencode web`'s session list. `undefined` =
1547
+ * not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
1548
+ */
1549
+ opencodeDirectory = void 0;
1550
+ /** Serialises drains so a reconnect during a drain doesn't double-process. */
1551
+ draining = false;
1552
+ constructor(config2) {
1553
+ this.agentId = config2.agentId;
1554
+ this.port = config2.port;
1555
+ this.apiUrl = config2.apiUrl.replace(/\/$/, "");
1556
+ this.getAuthHeader = config2.getAuthHeader;
1557
+ this.conversationFilter = config2.conversationFilter ?? null;
1558
+ this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
1559
+ this.log = config2.log ?? (() => {
1560
+ });
1561
+ this.fetchImpl = config2.fetchImpl ?? fetch;
1562
+ this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1563
+ this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
1564
+ this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
1565
+ }
1566
+ /** The IPv4-loopback base URL for the local `opencode serve`. */
1567
+ get opencodeBase() {
1568
+ return `http://127.0.0.1:${this.port}`;
1569
+ }
1570
+ // -------------------------------------------------------------------------
1571
+ // Public API
1572
+ // -------------------------------------------------------------------------
1573
+ /**
1574
+ * Drain all pending channel conversations once: poll → process → callback.
1575
+ * Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
1576
+ * Re-entrant calls while a drain is in flight are skipped (return 0).
1577
+ *
1578
+ * @returns the number of messages processed.
1579
+ */
1580
+ async drainPending() {
1581
+ if (this.draining) return 0;
1582
+ this.draining = true;
1583
+ let processed = 0;
1584
+ try {
1585
+ const conversations = await this.getPendingConversations();
1586
+ if (conversations.length > 0) {
1587
+ const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
1588
+ this.log({
1589
+ level: "info",
1590
+ message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
1591
+ });
1592
+ }
1593
+ for (const conv of conversations) {
1594
+ processed += await this.processConversation(conv);
1595
+ }
1596
+ } finally {
1597
+ this.draining = false;
1598
+ }
1599
+ return processed;
1600
+ }
1601
+ /**
1602
+ * Await all outstanding paused-session watchers (WI-2-CLI).
1603
+ *
1604
+ * In production the watchers are deliberately started-not-awaited so the drain
1605
+ * loop never blocks on them and process exit is not held up (the cron recovers
1606
+ * any abandoned ones). This helper exists primarily for deterministic tests
1607
+ * that need to observe the watcher's effect (the `done` PATCH or its giving up)
1608
+ * after a non-blocking `drainPending`. Watchers never reject, so this resolves.
1609
+ */
1610
+ async flushPausedWatchers() {
1611
+ await Promise.all([...this.watchers.values()]);
1612
+ }
1613
+ // -------------------------------------------------------------------------
1614
+ // Conversation processing
1615
+ // -------------------------------------------------------------------------
1616
+ async processConversation(conv) {
1617
+ const sessionId = await this.ensureSession(conv);
1618
+ const messages = await this.getPendingMessages(conv.id);
1619
+ let processed = 0;
1620
+ for (const message of messages) {
1621
+ const claimed = await this.markProcessing(conv.id, message.id);
1622
+ if (!claimed) {
1623
+ this.log({
1624
+ level: "info",
1625
+ message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
1626
+ conversation_id: conv.id,
1627
+ message_id: message.id
1628
+ });
1629
+ continue;
1630
+ }
1631
+ try {
1632
+ this.log({
1633
+ level: "info",
1634
+ message: `Sending queued message ${message.id.slice(0, 8)} to OpenCode (session ${sessionId.slice(0, 8)})`,
1635
+ conversation_id: conv.id,
1636
+ message_id: message.id
1637
+ });
1638
+ const result = await sendMessageToOpenCode(
1639
+ this.port,
1640
+ sessionId,
1641
+ message.content,
1123
1642
  {
1124
- command: "tunnel",
1125
- error: message
1643
+ agent: message.opencode_agent ?? void 0,
1644
+ model: message.opencode_model ?? void 0
1126
1645
  },
1127
- state.sandboxId ?? void 0
1646
+ {
1647
+ onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1648
+ onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1649
+ }
1128
1650
  );
1129
- await shutdownTelemetry();
1130
- displayError(state, "Authentication failed", "Please run `evident login` again.");
1131
- process.exit(1);
1651
+ if (result.awaitingInteraction) {
1652
+ this.log({
1653
+ level: "info",
1654
+ message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
1655
+ conversation_id: conv.id,
1656
+ message_id: message.id
1657
+ });
1658
+ this.startPausedWatcher(conv, message, sessionId);
1659
+ continue;
1660
+ }
1661
+ await this.confirmCompletion(sessionId);
1662
+ await this.markDone(conv.id, message.id, sessionId);
1663
+ processed += 1;
1664
+ this.log({
1665
+ level: "info",
1666
+ message: `Message ${message.id.slice(0, 8)} processed`,
1667
+ conversation_id: conv.id,
1668
+ message_id: message.id
1669
+ });
1670
+ } catch (err) {
1671
+ if (err instanceof ChannelAuthError) throw err;
1672
+ await this.markFailed(conv.id, message.id).catch(() => {
1673
+ });
1674
+ this.log({
1675
+ level: "error",
1676
+ message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
1677
+ conversation_id: conv.id,
1678
+ message_id: message.id
1679
+ });
1132
1680
  }
1133
- logActivity(state, {
1134
- type: "error",
1135
- error: message
1681
+ }
1682
+ return processed;
1683
+ }
1684
+ async ensureSession(conv) {
1685
+ const cached = this.sessions.get(conv.id);
1686
+ if (cached) return cached;
1687
+ if (conv.opencode_session_id) {
1688
+ this.sessions.set(conv.id, conv.opencode_session_id);
1689
+ return conv.opencode_session_id;
1690
+ }
1691
+ const directory = await this.resolveOpenCodeDirectory();
1692
+ const sessionId = await createOpenCodeSession(this.port, directory);
1693
+ this.sessions.set(conv.id, sessionId);
1694
+ await this.persistSession(conv.id, sessionId).catch(() => {
1695
+ });
1696
+ return sessionId;
1697
+ }
1698
+ /**
1699
+ * Lazily resolve (and cache) opencode's root directory via `GET /path`.
1700
+ * Resolved once per driver: `undefined` until first lookup, then the directory
1701
+ * string or `null` if unavailable (we don't keep retrying a missing `/path`).
1702
+ */
1703
+ async resolveOpenCodeDirectory() {
1704
+ if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
1705
+ this.opencodeDirectory = await getOpenCodeDirectory(this.port);
1706
+ if (!this.opencodeDirectory) {
1707
+ this.log({
1708
+ level: "info",
1709
+ message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
1136
1710
  });
1137
- telemetry.error(
1138
- EventTypes.TUNNEL_ERROR,
1139
- `Tunnel error: ${message}`,
1711
+ }
1712
+ return this.opencodeDirectory;
1713
+ }
1714
+ /**
1715
+ * Local reconcile: re-query `GET /session/:id/message` and check whether the
1716
+ * last assistant message is message-level complete (`isTurnComplete`).
1717
+ *
1718
+ * This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
1719
+ * blocking POST already returned, and `markDone` causes the server to re-fetch
1720
+ * the messages itself (via `extractTextFromMessages`) when delivering the
1721
+ * reply — so this round-trip never gates delivery. We keep it only to surface a
1722
+ * truthful diagnostic when opencode hasn't yet recorded a completed assistant
1723
+ * turn at reconcile time, then proceed to `markDone` regardless. Uses the
1724
+ * injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
1725
+ */
1726
+ async confirmCompletion(sessionId) {
1727
+ try {
1728
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1729
+ if (!res.ok) return;
1730
+ const body = await res.json();
1731
+ const messages = Array.isArray(body) ? body : null;
1732
+ if (!isTurnComplete(messages)) {
1733
+ this.log({
1734
+ level: "info",
1735
+ message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
1736
+ });
1737
+ }
1738
+ } catch {
1739
+ }
1740
+ }
1741
+ // -------------------------------------------------------------------------
1742
+ // Paused-session watcher (WI-2-CLI)
1743
+ // -------------------------------------------------------------------------
1744
+ /**
1745
+ * Start (but do NOT await) a watcher that resumes a paused turn to completion.
1746
+ *
1747
+ * Single-flight per message: if a watcher is already live for this message we
1748
+ * skip. The returned watcher promise is tracked in `this.watchers` and removed
1749
+ * when it settles; it never rejects (the body is fully guarded), so a failed
1750
+ * poll/markDone can never crash the run loop — the cron stays as the safety net.
1751
+ */
1752
+ startPausedWatcher(conv, message, sessionId) {
1753
+ if (this.watchers.has(message.id)) return;
1754
+ const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
1755
+ this.watchers.delete(message.id);
1756
+ });
1757
+ this.watchers.set(message.id, watcher);
1758
+ }
1759
+ /**
1760
+ * Poll `GET /session/:id/message` until the SAME paused turn is message-level
1761
+ * complete — the last message is an ASSISTANT message whose
1762
+ * `info.time.completed` is set (the user answered the question/permission in
1763
+ * opencode web and opencode finished the turn) — then complete it via the
1764
+ * EXISTING `markDone` PATCH — NO re-send of the original message. The server
1765
+ * re-fetches the assistant messages on completion, so the watcher does not
1766
+ * pass any reply text.
1767
+ *
1768
+ * Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
1769
+ * tests' injection) and reuses only the pure `isTurnComplete` predicate from
1770
+ * `session.ts` — we do NOT call `getSessionMessages` (which uses the global
1771
+ * `fetch`) here.
1772
+ *
1773
+ * Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
1774
+ * reset): on timeout we STOP and leave the message `processing` so the cron
1775
+ * remains the last-resort safety net. The whole body is wrapped so any
1776
+ * poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
1777
+ * out of the run loop.
1778
+ */
1779
+ async watchPausedSession(conv, message, sessionId) {
1780
+ const deadline = Date.now() + this.pausedMaxWaitMs;
1781
+ try {
1782
+ while (Date.now() < deadline) {
1783
+ await this.sleep(this.pausedPollIntervalMs);
1784
+ let completed = false;
1785
+ try {
1786
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
1787
+ if (res.ok) {
1788
+ const body = await res.json();
1789
+ const messages = Array.isArray(body) ? body : null;
1790
+ completed = isTurnComplete(messages);
1791
+ }
1792
+ } catch {
1793
+ continue;
1794
+ }
1795
+ if (!completed) continue;
1796
+ this.log({
1797
+ level: "info",
1798
+ message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
1799
+ conversation_id: conv.id,
1800
+ message_id: message.id
1801
+ });
1802
+ await this.markDone(conv.id, message.id, sessionId);
1803
+ return;
1804
+ }
1805
+ this.log({
1806
+ level: "info",
1807
+ message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
1808
+ conversation_id: conv.id,
1809
+ message_id: message.id
1810
+ });
1811
+ } catch (err) {
1812
+ this.log({
1813
+ level: "error",
1814
+ message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
1815
+ conversation_id: conv.id,
1816
+ message_id: message.id
1817
+ });
1818
+ }
1819
+ }
1820
+ // -------------------------------------------------------------------------
1821
+ // Evident API calls (combinedAuth thread routes)
1822
+ // -------------------------------------------------------------------------
1823
+ async getPendingConversations() {
1824
+ const res = await this.fetchImpl(
1825
+ `${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
1826
+ {
1827
+ headers: { Authorization: this.getAuthHeader() }
1828
+ }
1829
+ );
1830
+ this.assertAuth(res, "fetching pending conversations");
1831
+ if (!res.ok) {
1832
+ throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
1833
+ }
1834
+ const data = await res.json();
1835
+ let conversations = data.conversations;
1836
+ if (this.conversationFilter) {
1837
+ conversations = conversations.filter((c) => c.id === this.conversationFilter);
1838
+ }
1839
+ return conversations;
1840
+ }
1841
+ async getPendingMessages(conversationId) {
1842
+ const res = await this.fetchImpl(
1843
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
1844
+ { headers: { Authorization: this.getAuthHeader() } }
1845
+ );
1846
+ this.assertAuth(res, "fetching pending messages");
1847
+ if (!res.ok) {
1848
+ throw new Error(`Failed to get messages: HTTP ${res.status}`);
1849
+ }
1850
+ return await res.json();
1851
+ }
1852
+ async markProcessing(conversationId, messageId) {
1853
+ const res = await this.fetchImpl(
1854
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1855
+ {
1856
+ method: "PATCH",
1857
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1858
+ body: JSON.stringify({ status: "processing" })
1859
+ }
1860
+ );
1861
+ this.assertAuth(res, "marking message as processing");
1862
+ return res.ok;
1863
+ }
1864
+ /**
1865
+ * EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
1866
+ * `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
1867
+ * `queued_conversation_messages.status`/`processed_at` gate makes a re-call
1868
+ * for an already-`done` message a no-op (no double Slack post).
1869
+ */
1870
+ async markDone(conversationId, messageId, sessionId) {
1871
+ await this.callWithRetry(
1872
+ "marking message as done",
1873
+ () => this.fetchImpl(
1874
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1140
1875
  {
1141
- error: message,
1142
- attempt: state.reconnectAttempt
1143
- },
1144
- state.sandboxId ?? void 0
1876
+ method: "PATCH",
1877
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1878
+ body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
1879
+ }
1880
+ )
1881
+ );
1882
+ }
1883
+ async markFailed(conversationId, messageId) {
1884
+ await this.callWithRetry(
1885
+ "marking message as failed",
1886
+ () => this.fetchImpl(
1887
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
1888
+ {
1889
+ method: "PATCH",
1890
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1891
+ body: JSON.stringify({ status: "failed" })
1892
+ }
1893
+ )
1894
+ );
1895
+ }
1896
+ async persistSession(conversationId, sessionId) {
1897
+ const res = await this.fetchImpl(
1898
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
1899
+ {
1900
+ method: "PATCH",
1901
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1902
+ body: JSON.stringify({ opencode_session_id: sessionId })
1903
+ }
1904
+ );
1905
+ this.assertAuth(res, "persisting session id");
1906
+ }
1907
+ /**
1908
+ * EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
1909
+ * `POST .../interactive-event {type, data}`. The server persists the
1910
+ * interaction and posts a link to the proxied opencode-web conversation.
1911
+ */
1912
+ async reportInteraction(conversationId, type, data) {
1913
+ try {
1914
+ await this.callWithRetry(
1915
+ "reporting interactive event",
1916
+ () => this.fetchImpl(
1917
+ `${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
1918
+ {
1919
+ method: "POST",
1920
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
1921
+ body: JSON.stringify({ type, data })
1922
+ }
1923
+ )
1924
+ );
1925
+ this.log({
1926
+ level: "info",
1927
+ message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1928
+ conversation_id: conversationId
1929
+ });
1930
+ } catch (err) {
1931
+ if (err instanceof ChannelAuthError) throw err;
1932
+ this.log({
1933
+ level: "error",
1934
+ message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1935
+ conversation_id: conversationId
1936
+ });
1937
+ }
1938
+ }
1939
+ // -------------------------------------------------------------------------
1940
+ // Retry wrapper
1941
+ // -------------------------------------------------------------------------
1942
+ /**
1943
+ * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
1944
+ * network errors) with exponential backoff + jitter (capped). Auth failures
1945
+ * (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
1946
+ * terminal too. No on-disk persistence — a crash mid-retry drops the callback
1947
+ * (accepted by ADR-0039).
1948
+ */
1949
+ async callWithRetry(context, call) {
1950
+ let lastError;
1951
+ for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
1952
+ let res;
1953
+ try {
1954
+ res = await call();
1955
+ } catch (err) {
1956
+ lastError = err;
1957
+ if (attempt < this.retry.maxAttempts - 1) {
1958
+ await this.sleep(backoffDelay(attempt, this.retry));
1959
+ continue;
1960
+ }
1961
+ throw err;
1962
+ }
1963
+ if (res.status === 401 || res.status === 403) {
1964
+ throw new ChannelAuthError(
1965
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1966
+ );
1967
+ }
1968
+ if (res.ok) return;
1969
+ if (isRetryableStatus(res.status)) {
1970
+ lastError = new Error(`${context}: HTTP ${res.status}`);
1971
+ if (attempt < this.retry.maxAttempts - 1) {
1972
+ await this.sleep(backoffDelay(attempt, this.retry));
1973
+ continue;
1974
+ }
1975
+ }
1976
+ throw new Error(`${context}: HTTP ${res.status}`);
1977
+ }
1978
+ throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1979
+ }
1980
+ assertAuth(res, context) {
1981
+ if (res.status === 401 || res.status === 403) {
1982
+ throw new ChannelAuthError(
1983
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1984
+ );
1985
+ }
1986
+ }
1987
+ };
1988
+
1989
+ // src/commands/ensure-opencode.ts
1990
+ import chalk5 from "chalk";
1991
+ import ora2 from "ora";
1992
+ import { select as select2 } from "@inquirer/prompts";
1993
+ async function ensureOpenCodeRunning(ctx) {
1994
+ const healthCheck = await checkOpenCodeHealth(ctx.port);
1995
+ if (healthCheck.healthy) {
1996
+ return { port: ctx.port, process: null, version: healthCheck.version ?? null };
1997
+ }
1998
+ const runningInstances = await findHealthyOpenCodeInstances();
1999
+ if (runningInstances.length > 0) {
2000
+ if (!ctx.interactive) {
2001
+ throw new Error(
2002
+ `OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
2003
+ );
2004
+ }
2005
+ blank();
2006
+ console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
2007
+ for (const instance of runningInstances) {
2008
+ const ver = instance.version ? ` (v${instance.version})` : "";
2009
+ const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
2010
+ console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
2011
+ }
2012
+ blank();
2013
+ if (runningInstances.length === 1) {
2014
+ console.log(chalk5.yellow("Tip: Run with the correct port:"));
2015
+ console.log(
2016
+ chalk5.dim(
2017
+ ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
2018
+ )
2019
+ );
2020
+ }
2021
+ blank();
2022
+ throw new Error(`OpenCode not running on port ${ctx.port}`);
2023
+ }
2024
+ if (!isOpenCodeInstalled()) {
2025
+ if (!ctx.interactive) {
2026
+ throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
2027
+ }
2028
+ const result = await promptOpenCodeInstall(true);
2029
+ if (result === "exit") process.exit(0);
2030
+ if (result !== "installed" && !isOpenCodeInstalled()) {
2031
+ throw new Error("OpenCode is not installed");
2032
+ }
2033
+ }
2034
+ if (!ctx.interactive) {
2035
+ ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
2036
+ const proc = await startOpenCode(ctx.port);
2037
+ const health = await waitForOpenCodeHealth(ctx.port, 3e4);
2038
+ if (!health.healthy) {
2039
+ throw new Error(
2040
+ `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
1145
2041
  );
1146
- state.reconnectAttempt++;
1147
- const delay = getReconnectDelay(state.reconnectAttempt);
1148
- logActivity(state, {
1149
- type: "info",
1150
- message: `Reconnecting in ${Math.round(delay / 1e3)}s (attempt ${state.reconnectAttempt})...`
2042
+ }
2043
+ ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
2044
+ return { port: ctx.port, process: proc, version: health.version ?? null };
2045
+ }
2046
+ let port = ctx.port;
2047
+ if (isPortInUse(port)) {
2048
+ console.log(chalk5.yellow(`
2049
+ Port ${port} is already in use.`));
2050
+ const alternativePort = findAvailablePort(port + 1);
2051
+ if (alternativePort) {
2052
+ const useAlternative = await select2({
2053
+ message: `Use port ${alternativePort} instead?`,
2054
+ choices: [
2055
+ { name: `Yes, use port ${alternativePort}`, value: "yes" },
2056
+ { name: "No, I will free the port manually", value: "no" }
2057
+ ]
1151
2058
  });
2059
+ if (useAlternative === "yes") {
2060
+ port = alternativePort;
2061
+ } else {
2062
+ throw new Error(`Port ${ctx.port} is in use`);
2063
+ }
2064
+ }
2065
+ }
2066
+ const action = await select2({
2067
+ message: "OpenCode is not running. What would you like to do?",
2068
+ choices: [
2069
+ {
2070
+ name: "Start OpenCode for me",
2071
+ value: "start",
2072
+ description: `Run 'opencode serve --port ${port}'`
2073
+ },
2074
+ {
2075
+ name: "Show me the command",
2076
+ value: "manual",
2077
+ description: "Display the command to run manually"
2078
+ },
2079
+ {
2080
+ name: "Continue without OpenCode",
2081
+ value: "continue",
2082
+ description: "Requests will fail until OpenCode starts"
2083
+ }
2084
+ ]
2085
+ });
2086
+ if (action === "manual") {
2087
+ blank();
2088
+ console.log(chalk5.bold("Run this command in another terminal:"));
2089
+ blank();
2090
+ console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
2091
+ blank();
2092
+ throw new Error("Please start OpenCode manually");
2093
+ }
2094
+ if (action === "start") {
2095
+ const spinner = ora2("Starting OpenCode...").start();
2096
+ const proc = await startOpenCode(port);
2097
+ const health = await waitForOpenCodeHealth(port, 3e4);
2098
+ if (!health.healthy) {
2099
+ spinner.fail("Failed to start OpenCode");
2100
+ throw new Error("OpenCode failed to start");
2101
+ }
2102
+ spinner.stop();
2103
+ return { port, process: proc, version: health.version ?? null };
2104
+ }
2105
+ return { port, process: null, version: null };
2106
+ }
2107
+
2108
+ // src/commands/agent-lookup.ts
2109
+ async function readErrorMessage(response) {
2110
+ const text = await response.text().catch(() => "");
2111
+ if (!text) return response.statusText || void 0;
2112
+ try {
2113
+ const data = JSON.parse(text);
2114
+ const message = data.message ?? data.error;
2115
+ if (typeof message === "string" && message.trim()) {
2116
+ return message;
2117
+ }
2118
+ } catch {
2119
+ }
2120
+ return text.trim() || response.statusText || void 0;
2121
+ }
2122
+ function authFailureHint(apiUrl, serverMessage) {
2123
+ const reason = serverMessage ? `: ${serverMessage}` : "";
2124
+ return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
2125
+ }
2126
+ async function resolveAgentIdFromKey(authHeader) {
2127
+ const apiUrl = getApiUrlConfig();
2128
+ try {
2129
+ const response = await fetch(`${apiUrl}/me`, {
2130
+ headers: { Authorization: authHeader }
2131
+ });
2132
+ if (response.status === 401) {
2133
+ const serverMessage = await readErrorMessage(response);
2134
+ return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2135
+ }
2136
+ if (!response.ok) {
2137
+ const serverMessage = await readErrorMessage(response);
2138
+ return {
2139
+ error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2140
+ };
2141
+ }
2142
+ const data = await response.json();
2143
+ if (data.auth_type === "agent_key" && data.agent_id) {
2144
+ return { agent_id: data.agent_id };
2145
+ }
2146
+ return {
2147
+ error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
2148
+ };
2149
+ } catch (error2) {
2150
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2151
+ return { error: `Failed to resolve agent from key: ${message}` };
2152
+ }
2153
+ }
2154
+ async function getAgentInfo(agentId, authHeader) {
2155
+ const apiUrl = getApiUrlConfig();
2156
+ try {
2157
+ const response = await fetch(`${apiUrl}/agents/${agentId}`, {
2158
+ headers: { Authorization: authHeader }
2159
+ });
2160
+ if (response.status === 401) {
2161
+ const serverMessage = await readErrorMessage(response);
2162
+ return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
2163
+ }
2164
+ if (response.status === 403) {
2165
+ const serverMessage = await readErrorMessage(response);
2166
+ return {
2167
+ valid: false,
2168
+ error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
2169
+ };
2170
+ }
2171
+ if (response.status === 404) {
2172
+ const serverMessage = await readErrorMessage(response);
2173
+ return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
2174
+ }
2175
+ if (!response.ok) {
2176
+ const serverMessage = await readErrorMessage(response);
2177
+ return {
2178
+ valid: false,
2179
+ error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
2180
+ };
2181
+ }
2182
+ const agent = await response.json();
2183
+ if (agent.agent_type !== "local") {
2184
+ return {
2185
+ valid: false,
2186
+ error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
2187
+ };
2188
+ }
2189
+ return { valid: true, agent };
2190
+ } catch (error2) {
2191
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
2192
+ return { valid: false, error: `Failed to validate agent: ${message}` };
2193
+ }
2194
+ }
2195
+
2196
+ // src/commands/run.ts
2197
+ var MAX_ACTIVITY_LOG_ENTRIES = 10;
2198
+ var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
2199
+ function log(state, message, isError = false) {
2200
+ if (state.json) {
2201
+ console.log(
2202
+ JSON.stringify({
2203
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2204
+ level: isError ? "error" : "info",
2205
+ message
2206
+ })
2207
+ );
2208
+ } else if (!state.interactive) {
2209
+ const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
2210
+ console.log(`${prefix} ${message}`);
2211
+ }
2212
+ }
2213
+ function logActivity(state, entry) {
2214
+ const fullEntry = {
2215
+ ...entry,
2216
+ timestamp: /* @__PURE__ */ new Date()
2217
+ };
2218
+ state.activityLog.push(fullEntry);
2219
+ if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
2220
+ state.activityLog.shift();
2221
+ }
2222
+ if (!state.interactive) {
2223
+ if (entry.type === "error") {
2224
+ log(state, entry.error ?? "Unknown error", true);
2225
+ } else if (entry.type === "info" && entry.message) {
2226
+ log(state, entry.message);
2227
+ }
2228
+ }
2229
+ }
2230
+ function displayStatus(state) {
2231
+ if (!state.interactive) return;
2232
+ const attempt = state.connection?.reconnectAttempt ?? 0;
2233
+ const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
2234
+ const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
2235
+ const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
2236
+ const last = state.activityLog[state.activityLog.length - 1];
2237
+ const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
2238
+ const agent = state.agentName ?? state.agentId;
2239
+ console.log(
2240
+ `${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
2241
+ );
2242
+ }
2243
+ async function promptForLogin(promptMessage, successMessage) {
2244
+ const action = await select3({
2245
+ message: promptMessage,
2246
+ choices: [
2247
+ {
2248
+ name: "Yes, log me in",
2249
+ value: "login",
2250
+ description: "Opens a browser to authenticate with Evident"
2251
+ },
2252
+ {
2253
+ name: "No, exit",
2254
+ value: "exit",
2255
+ description: "Exit without logging in"
2256
+ }
2257
+ ]
2258
+ });
2259
+ if (action === "exit") {
2260
+ console.log(chalk6.dim(`
2261
+ You can log in later by running: ${getCliName()} login`));
2262
+ process.exit(0);
2263
+ }
2264
+ await login({ noBrowser: false });
2265
+ const credentials2 = await getToken();
2266
+ if (!credentials2) {
2267
+ printError("Login failed. Please try again.");
2268
+ process.exit(1);
2269
+ }
2270
+ blank();
2271
+ console.log(chalk6.green(successMessage));
2272
+ blank();
2273
+ return { token: credentials2.token, authType: "bearer", user: credentials2.user };
2274
+ }
2275
+ var AUTH_EXPIRED_EXIT_CODE = 77;
2276
+ async function handleAuthError(state, error2) {
2277
+ logActivity(state, {
2278
+ type: "error",
2279
+ error: error2.message
2280
+ });
2281
+ if (state.interactive) displayStatus(state);
2282
+ if (!state.interactive) {
2283
+ blank();
2284
+ console.log(chalk6.red("Authentication expired"));
2285
+ console.log(chalk6.dim("Your authentication token is no longer valid."));
2286
+ blank();
2287
+ console.log(chalk6.dim("To fix this:"));
2288
+ console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
2289
+ console.log(chalk6.dim(" 2. Restart this command"));
2290
+ blank();
2291
+ await cleanup(state);
2292
+ await shutdownTelemetry();
2293
+ process.exit(AUTH_EXPIRED_EXIT_CODE);
2294
+ return { success: false };
2295
+ }
2296
+ blank();
2297
+ console.log(chalk6.yellow("Your authentication has expired."));
2298
+ blank();
2299
+ try {
2300
+ const credentials2 = await promptForLogin(
2301
+ "Would you like to log in again?",
2302
+ "Re-authenticated successfully! Resuming..."
2303
+ );
2304
+ const newAuthHeader = getAuthHeader(credentials2);
2305
+ return { success: true, newAuthHeader };
2306
+ } catch {
2307
+ return { success: false };
2308
+ }
2309
+ }
2310
+ async function driveChannels(state, driver) {
2311
+ let idlePolls = 0;
2312
+ while (state.running) {
2313
+ if (state.connection?.reconnecting && state.connection.reconnectPromise) {
2314
+ logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
2315
+ if (state.interactive) displayStatus(state);
2316
+ await state.connection.reconnectPromise;
2317
+ }
2318
+ try {
2319
+ const processed = await driver.drainPending();
2320
+ state.messageCount += processed;
2321
+ if (processed > 0) {
2322
+ idlePolls = 0;
2323
+ if (state.interactive) displayStatus(state);
2324
+ } else if (state.idleTimeout !== null) {
2325
+ idlePolls++;
2326
+ if (idlePolls === 1) {
2327
+ logActivity(state, {
2328
+ type: "info",
2329
+ message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
2330
+ });
2331
+ if (state.interactive) displayStatus(state);
2332
+ }
2333
+ }
2334
+ } catch (error2) {
2335
+ if (error2 instanceof ChannelAuthError) {
2336
+ const result = await handleAuthError(state, error2);
2337
+ if (result.success && result.newAuthHeader) {
2338
+ state.authHeader = result.newAuthHeader;
2339
+ logActivity(state, { type: "info", message: "Continuing with new credentials..." });
2340
+ if (state.interactive) displayStatus(state);
2341
+ continue;
2342
+ }
2343
+ state.running = false;
2344
+ break;
2345
+ }
2346
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
2347
+ logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
2348
+ if (state.interactive) displayStatus(state);
2349
+ }
2350
+ await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
2351
+ if (state.idleTimeout !== null && idlePolls >= 2) {
2352
+ const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
2353
+ if (idleMs > state.idleTimeout * 1e3) {
2354
+ logActivity(state, { type: "info", message: "Idle timeout reached" });
2355
+ if (state.interactive) displayStatus(state);
2356
+ break;
2357
+ }
2358
+ }
2359
+ }
2360
+ }
2361
+ async function cleanup(state) {
2362
+ state.running = false;
2363
+ if (state.connection) {
2364
+ state.connection.close();
2365
+ state.connection = null;
2366
+ }
2367
+ if (state.opencodeProcess) {
2368
+ stopOpenCode(state.opencodeProcess);
2369
+ if (state.interactive) {
2370
+ logActivity(state, { type: "info", message: "Stopped OpenCode process" });
1152
2371
  displayStatus(state);
1153
- await sleep(delay);
2372
+ } else {
2373
+ log(state, "Stopped OpenCode process");
1154
2374
  }
2375
+ state.opencodeProcess = null;
2376
+ }
2377
+ }
2378
+ async function run(options) {
2379
+ const interactive = isInteractive(options.json);
2380
+ const state = {
2381
+ agentId: options.agent || "",
2382
+ agentName: null,
2383
+ port: options.port ?? 4096,
2384
+ conversationFilter: options.conversation ?? null,
2385
+ idleTimeout: options.idleTimeout ?? null,
2386
+ json: options.json ?? false,
2387
+ interactive,
2388
+ connected: false,
2389
+ opencodeConnected: false,
2390
+ opencodeVersion: null,
2391
+ opencodeProcess: null,
2392
+ connection: null,
2393
+ running: true,
2394
+ activityLog: [],
2395
+ messageCount: 0,
2396
+ authHeader: ""
2397
+ };
2398
+ if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
2399
+ log(
2400
+ state,
2401
+ "Warning: No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
2402
+ false
2403
+ );
2404
+ }
2405
+ const handleSignal = async () => {
2406
+ if (state.interactive) {
2407
+ logActivity(state, { type: "info", message: "Shutting down..." });
2408
+ displayStatus(state);
2409
+ } else {
2410
+ log(state, "Shutting down...");
2411
+ }
2412
+ await cleanup(state);
2413
+ await shutdownTelemetry();
2414
+ process.exit(0);
2415
+ };
2416
+ process.on("SIGINT", handleSignal);
2417
+ process.on("SIGTERM", handleSignal);
2418
+ try {
2419
+ let credentials2 = await getAuthCredentials();
2420
+ if (!credentials2) {
2421
+ if (!interactive) {
2422
+ printError("Authentication required");
2423
+ blank();
2424
+ console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
2425
+ console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
2426
+ blank();
2427
+ process.exit(1);
2428
+ }
2429
+ blank();
2430
+ console.log(chalk6.yellow("You are not logged in to Evident."));
2431
+ blank();
2432
+ credentials2 = await promptForLogin(
2433
+ "Would you like to log in now?",
2434
+ "Login successful! Continuing..."
2435
+ );
2436
+ }
2437
+ state.authHeader = getAuthHeader(credentials2);
2438
+ if (!state.agentId) {
2439
+ if (credentials2.authType === "agent_key") {
2440
+ const resolved = await resolveAgentIdFromKey(state.authHeader);
2441
+ if (resolved.agent_id) {
2442
+ state.agentId = resolved.agent_id;
2443
+ log(state, `Resolved agent ID from key: ${state.agentId}`);
2444
+ if (state.interactive && !state.json) {
2445
+ logActivity(state, {
2446
+ type: "info",
2447
+ message: `Agent ID resolved from key: ${state.agentId}`
2448
+ });
2449
+ }
2450
+ } else {
2451
+ printError(resolved.error || "Failed to resolve agent ID from key");
2452
+ process.exit(1);
2453
+ }
2454
+ } else {
2455
+ printError("--agent is required when not using EVIDENT_AGENT_KEY");
2456
+ blank();
2457
+ console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
2458
+ blank();
2459
+ process.exit(1);
2460
+ }
2461
+ }
2462
+ telemetry.info(
2463
+ EventTypes.CLI_COMMAND,
2464
+ "Starting run command",
2465
+ {
2466
+ command: "run",
2467
+ agentId: state.agentId,
2468
+ port: state.port,
2469
+ conversationFilter: state.conversationFilter,
2470
+ interactive
2471
+ },
2472
+ state.agentId
2473
+ );
2474
+ if (interactive && !state.json) {
2475
+ blank();
2476
+ console.log(chalk6.bold("Evident Run"));
2477
+ console.log(chalk6.dim("-".repeat(40)));
2478
+ }
2479
+ const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
2480
+ let validation = await getAgentInfo(state.agentId, state.authHeader);
2481
+ if (!validation.valid && validation.authFailed && interactive) {
2482
+ spinner?.fail("Authentication failed");
2483
+ blank();
2484
+ console.log(chalk6.yellow("Your authentication token is invalid or expired."));
2485
+ blank();
2486
+ credentials2 = await promptForLogin(
2487
+ "Would you like to log in again?",
2488
+ "Login successful! Retrying..."
2489
+ );
2490
+ state.authHeader = getAuthHeader(credentials2);
2491
+ spinner?.start("Validating agent...");
2492
+ validation = await getAgentInfo(state.agentId, state.authHeader);
2493
+ }
2494
+ if (!validation.valid) {
2495
+ spinner?.fail(`Agent validation failed: ${validation.error}`);
2496
+ throw new Error(validation.error);
2497
+ }
2498
+ spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
2499
+ state.agentName = validation.agent.name;
2500
+ const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
2501
+ try {
2502
+ const oc = await ensureOpenCodeRunning({
2503
+ port: state.port,
2504
+ interactive: state.interactive,
2505
+ agentId: state.agentId,
2506
+ log: (message) => log(state, message)
2507
+ });
2508
+ state.port = oc.port;
2509
+ state.opencodeProcess = oc.process;
2510
+ state.opencodeVersion = oc.version;
2511
+ state.opencodeConnected = oc.process !== null || oc.version !== null;
2512
+ const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
2513
+ ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
2514
+ } catch (error2) {
2515
+ ocSpinner?.fail(error2.message);
2516
+ throw error2;
2517
+ }
2518
+ const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
2519
+ const channelDriver = new ChannelDriver({
2520
+ agentId: state.agentId,
2521
+ port: state.port,
2522
+ apiUrl: getApiUrlConfig(),
2523
+ getAuthHeader: () => state.authHeader,
2524
+ conversationFilter: state.conversationFilter,
2525
+ log: (entry) => logActivity(state, {
2526
+ type: entry.level === "error" ? "error" : "info",
2527
+ message: entry.message,
2528
+ error: entry.level === "error" ? entry.message : void 0
2529
+ })
2530
+ });
2531
+ const connection = new RunnerConnection({
2532
+ agentId: state.agentId,
2533
+ getAuthHeader: () => state.authHeader,
2534
+ port: state.port,
2535
+ isRunning: () => state.running,
2536
+ events: {
2537
+ onConnected: (agentId, isReconnect) => {
2538
+ state.connected = true;
2539
+ state.agentId = agentId;
2540
+ logActivity(state, {
2541
+ type: "info",
2542
+ message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
2543
+ });
2544
+ emitAgentConnected(state.agentId, { port: state.port });
2545
+ if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
2546
+ if (state.interactive) displayStatus(state);
2547
+ channelDriver.drainPending().then((processed) => {
2548
+ if (processed > 0) {
2549
+ state.messageCount += processed;
2550
+ logActivity(state, {
2551
+ type: "info",
2552
+ message: `Drained ${processed} queued message(s) on connect`
2553
+ });
2554
+ if (state.interactive) displayStatus(state);
2555
+ }
2556
+ }).catch((error2) => {
2557
+ const message = error2 instanceof Error ? error2.message : String(error2);
2558
+ logActivity(state, {
2559
+ type: "error",
2560
+ error: `Failed to drain queued messages on connect: ${message}`
2561
+ });
2562
+ if (state.interactive) displayStatus(state);
2563
+ });
2564
+ },
2565
+ onDisconnected: (code, reason) => {
2566
+ state.connected = false;
2567
+ logActivity(state, {
2568
+ type: "info",
2569
+ message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
2570
+ });
2571
+ emitAgentDisconnected(state.agentId, { code, reason });
2572
+ if (state.interactive) displayStatus(state);
2573
+ },
2574
+ onError: (error2) => {
2575
+ logActivity(state, { type: "error", error: error2 });
2576
+ if (state.interactive) displayStatus(state);
2577
+ },
2578
+ // Web traffic is proxied transparently; only note opencode is live.
2579
+ onResponse: () => {
2580
+ state.opencodeConnected = true;
2581
+ },
2582
+ // A channel message was queued and the api-worker pinged us over the
2583
+ // tunnel to drain immediately instead of waiting for the next poll tick.
2584
+ // Best-effort + non-fatal: mirror the on-connect drain block. A failed
2585
+ // drain here is logged and swallowed — the steady-state poll retries, so
2586
+ // a lost/failed ping can never orphan a message (§2 invariant).
2587
+ onDrainPing: () => {
2588
+ if (!state.running) return;
2589
+ logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
2590
+ channelDriver.drainPending().then((processed) => {
2591
+ if (processed > 0) {
2592
+ state.messageCount += processed;
2593
+ logActivity(state, {
2594
+ type: "info",
2595
+ message: `Drained ${processed} queued message(s) on ping`
2596
+ });
2597
+ if (state.interactive) displayStatus(state);
2598
+ }
2599
+ }).catch((error2) => {
2600
+ const message = error2 instanceof Error ? error2.message : String(error2);
2601
+ logActivity(state, {
2602
+ type: "error",
2603
+ error: `Failed to drain queued messages on ping: ${message}`
2604
+ });
2605
+ if (state.interactive) displayStatus(state);
2606
+ });
2607
+ },
2608
+ onInfo: (message) => logActivity(state, { type: "info", message })
2609
+ }
2610
+ });
2611
+ state.connection = connection;
2612
+ try {
2613
+ await connection.connect();
2614
+ } catch (error2) {
2615
+ if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
2616
+ throw error2;
2617
+ }
2618
+ if (!interactive || state.json) {
2619
+ log(state, "Driving channel messages...");
2620
+ }
2621
+ await driveChannels(state, channelDriver);
2622
+ await cleanup(state);
2623
+ if (state.json) {
2624
+ console.log(
2625
+ JSON.stringify({
2626
+ status: "success",
2627
+ messages_processed: state.messageCount
2628
+ })
2629
+ );
2630
+ } else if (!interactive) {
2631
+ log(state, `Completed. Processed ${state.messageCount} message(s).`);
2632
+ }
2633
+ await shutdownTelemetry();
2634
+ process.exit(0);
2635
+ } catch (error2) {
2636
+ await cleanup(state);
2637
+ const message = error2 instanceof Error ? error2.message : String(error2);
2638
+ if (state.json) {
2639
+ console.log(JSON.stringify({ status: "error", error: message }));
2640
+ } else {
2641
+ printError(message);
2642
+ }
2643
+ telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
2644
+ command: "run",
2645
+ agentId: options.agent
2646
+ });
2647
+ await shutdownTelemetry();
2648
+ process.exit(1);
1155
2649
  }
1156
2650
  }
1157
2651
 
1158
2652
  // src/index.ts
1159
2653
  var program = new Command();
1160
- 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) => {
1161
- const env = thisCommand.opts().env;
1162
- if (env) {
1163
- setEnvironment(env);
2654
+ program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
2655
+ "--endpoint <url>",
2656
+ "Evident API base URL (default: production; e.g. http://localhost:3001)"
2657
+ ).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
2658
+ const { endpoint, tunnel } = thisCommand.opts();
2659
+ if (endpoint) {
2660
+ setEndpoint(endpoint);
2661
+ }
2662
+ if (tunnel) {
2663
+ setTunnelUrl(tunnel);
1164
2664
  }
1165
2665
  });
1166
2666
  program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
1167
- program.command("logout").description("Remove stored credentials").action(logout);
2667
+ program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
1168
2668
  program.command("whoami").description("Show the currently logged in user").action(whoami);
1169
- program.command("tunnel").description("Establish a tunnel to Evident for Local Mode").requiredOption("-s, --sandbox <id>", "Sandbox ID to connect to (required)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").action((options) => {
1170
- tunnel({
1171
- sandbox: options.sandbox,
1172
- port: parseInt(options.port, 10),
1173
- verbose: options.verbose
1174
- });
1175
- });
2669
+ program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
2670
+ (options) => {
2671
+ run({
2672
+ agent: options.agent,
2673
+ port: parseInt(options.port, 10),
2674
+ verbose: options.verbose,
2675
+ conversation: options.conversation,
2676
+ idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
2677
+ json: options.json
2678
+ });
2679
+ }
2680
+ );
1176
2681
  program.parse();
1177
2682
  //# sourceMappingURL=index.js.map