@evident-ai/cli 0.2.0 → 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
@@ -1,12 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- clearCredentials,
4
- getApiUrlConfig,
5
- getCredentials,
6
- getTunnelUrlConfig,
7
- setCredentials,
8
- setEnvironment
9
- } from "./chunk-MWOWXSOP.js";
10
2
 
11
3
  // src/index.ts
12
4
  import { Command } from "commander";
@@ -16,6 +8,88 @@ import open from "open";
16
8
  import ora from "ora";
17
9
  import chalk2 from "chalk";
18
10
 
11
+ // src/lib/config.ts
12
+ import Conf from "conf";
13
+ import { homedir } from "os";
14
+ import { join } from "path";
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
20
+ };
21
+ var endpointOverride;
22
+ var tunnelOverride;
23
+ function setEndpoint(url) {
24
+ if (!url) {
25
+ endpointOverride = void 0;
26
+ return;
27
+ }
28
+ const trimmed = url.replace(/\/+$/, "");
29
+ endpointOverride = /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
30
+ }
31
+ function setTunnelUrl(url) {
32
+ tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
33
+ }
34
+ function getApiUrl() {
35
+ return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
36
+ }
37
+ function getTunnelUrl() {
38
+ return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
39
+ }
40
+ var config = new Conf({
41
+ projectName: "evident",
42
+ projectSuffix: "",
43
+ defaults
44
+ });
45
+ var credentials = new Conf({
46
+ projectName: "evident",
47
+ projectSuffix: "",
48
+ configName: "credentials",
49
+ defaults: {}
50
+ });
51
+ function getApiUrlConfig() {
52
+ return getApiUrl();
53
+ }
54
+ function getTunnelUrlConfig() {
55
+ return getTunnelUrl();
56
+ }
57
+ function credentialsKey() {
58
+ return getApiUrl();
59
+ }
60
+ function getCredentials() {
61
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
62
+ return byEndpoint[credentialsKey()] ?? {};
63
+ }
64
+ function setCredentials(creds) {
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);
72
+ }
73
+ function clearCredentials() {
74
+ const byEndpoint = credentials.get("byEndpoint") ?? {};
75
+ delete byEndpoint[credentialsKey()];
76
+ credentials.set("byEndpoint", byEndpoint);
77
+ }
78
+ function clearAllCredentials() {
79
+ credentials.clear();
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
+ }
92
+
19
93
  // src/lib/api.ts
20
94
  var ApiClient = class {
21
95
  baseUrl;
@@ -111,7 +185,6 @@ var api = {
111
185
 
112
186
  // src/lib/keychain.ts
113
187
  var SERVICE_NAME = "evident-cli";
114
- var ACCOUNT_NAME = "default";
115
188
  async function getKeytar() {
116
189
  try {
117
190
  const keytar = await import("keytar");
@@ -123,27 +196,31 @@ async function getKeytar() {
123
196
  return null;
124
197
  }
125
198
  }
126
- async function storeToken(credentials) {
199
+ function keychainAccount() {
200
+ return getApiUrlConfig();
201
+ }
202
+ async function storeToken(credentials2) {
127
203
  const keytar = await getKeytar();
128
204
  if (keytar) {
129
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials));
205
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
130
206
  } else {
131
207
  setCredentials({
132
- token: credentials.token,
133
- user: credentials.user,
134
- expiresAt: credentials.expiresAt
208
+ token: credentials2.token,
209
+ user: credentials2.user,
210
+ expiresAt: credentials2.expiresAt
135
211
  });
136
212
  }
137
213
  }
138
214
  async function getToken() {
139
215
  const keytar = await getKeytar();
140
216
  if (keytar) {
141
- const stored = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
217
+ const account = keychainAccount();
218
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
142
219
  if (stored) {
143
220
  try {
144
221
  return JSON.parse(stored);
145
222
  } catch {
146
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
223
+ await keytar.deletePassword(SERVICE_NAME, account);
147
224
  return null;
148
225
  }
149
226
  }
@@ -158,12 +235,26 @@ async function getToken() {
158
235
  }
159
236
  return null;
160
237
  }
161
- async function deleteToken() {
238
+ async function deleteToken(options = {}) {
162
239
  const keytar = await getKeytar();
163
240
  if (keytar) {
164
- 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();
165
257
  }
166
- clearCredentials();
167
258
  }
168
259
 
169
260
  // src/utils/ui.ts
@@ -331,29 +422,36 @@ async function login(options) {
331
422
  }
332
423
 
333
424
  // src/commands/logout.ts
334
- async function logout() {
335
- const credentials = await getToken();
336
- if (!credentials) {
337
- printWarning("You are not logged in.");
425
+ async function logout(options = {}) {
426
+ if (options.all) {
427
+ await deleteToken({ all: true });
428
+ printSuccess("Logged out of all endpoints.");
429
+ return;
430
+ }
431
+ const credentials2 = await getToken();
432
+ if (!credentials2) {
433
+ printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
338
434
  return;
339
435
  }
340
436
  await deleteToken();
341
- printSuccess("Logged out successfully.");
437
+ printSuccess(`Logged out of ${getApiUrlConfig()}.`);
342
438
  }
343
439
 
344
440
  // src/commands/whoami.ts
345
441
  import chalk3 from "chalk";
346
442
  async function whoami() {
347
- const credentials = await getToken();
348
- if (!credentials) {
349
- printError("Not logged in. Run the `login` command to authenticate.");
443
+ const apiUrl = getApiUrlConfig();
444
+ const credentials2 = await getToken();
445
+ if (!credentials2) {
446
+ printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
350
447
  process.exit(1);
351
448
  }
352
449
  blank();
353
- console.log(keyValue("User", chalk3.bold(credentials.user.email)));
354
- console.log(keyValue("User ID", credentials.user.id));
355
- if (credentials.expiresAt) {
356
- const expiresAt = new Date(credentials.expiresAt);
450
+ console.log(keyValue("Endpoint", apiUrl));
451
+ console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
452
+ console.log(keyValue("User ID", credentials2.user.id));
453
+ if (credentials2.expiresAt) {
454
+ const expiresAt = new Date(credentials2.expiresAt);
357
455
  const now = /* @__PURE__ */ new Date();
358
456
  if (expiresAt < now) {
359
457
  console.log(keyValue("Status", chalk3.red("Token expired")));
@@ -367,12 +465,24 @@ async function whoami() {
367
465
  blank();
368
466
  }
369
467
 
370
- // src/commands/tunnel.ts
371
- import WebSocket from "ws";
372
- import chalk4 from "chalk";
373
- import ora2 from "ora";
374
- import { execSync, spawn } from "child_process";
375
- import { select } from "@inquirer/prompts";
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";
376
486
 
377
487
  // src/lib/telemetry.ts
378
488
  var CLI_VERSION = process.env.npm_package_version || "unknown";
@@ -388,7 +498,7 @@ function logEvent(eventType, options = {}) {
388
498
  severity: options.severity || "info",
389
499
  message: options.message,
390
500
  metadata: options.metadata,
391
- sandbox_id: options.sandboxId,
501
+ agent_id: options.agentId,
392
502
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
393
503
  };
394
504
  eventBuffer.push(event);
@@ -402,10 +512,10 @@ function logEvent(eventType, options = {}) {
402
512
  }
403
513
  }
404
514
  var telemetry = {
405
- debug: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "debug", message, metadata, sandboxId }),
406
- info: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "info", message, metadata, sandboxId }),
407
- warn: (eventType, message, metadata, sandboxId) => logEvent(eventType, { severity: "warning", message, metadata, sandboxId }),
408
- 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 })
409
519
  };
410
520
  async function flushEvents() {
411
521
  if (eventBuffer.length === 0) return;
@@ -416,25 +526,26 @@ async function flushEvents() {
416
526
  flushTimeout = null;
417
527
  }
418
528
  try {
419
- const credentials = await getToken();
420
- if (!credentials) {
529
+ const credentials2 = await getToken();
530
+ if (!credentials2) {
421
531
  return;
422
532
  }
423
533
  const apiUrl = getApiUrlConfig();
424
534
  const controller = new AbortController();
425
535
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
426
536
  try {
537
+ const request = {
538
+ events,
539
+ client_type: "cli",
540
+ client_version: CLI_VERSION
541
+ };
427
542
  const response = await fetch(`${apiUrl}/telemetry/events`, {
428
543
  method: "POST",
429
544
  headers: {
430
545
  "Content-Type": "application/json",
431
- Authorization: `Bearer ${credentials.token}`
546
+ Authorization: `Bearer ${credentials2.token}`
432
547
  },
433
- body: JSON.stringify({
434
- events,
435
- client_type: "cli",
436
- client_version: CLI_VERSION
437
- }),
548
+ body: JSON.stringify(request),
438
549
  signal: controller.signal
439
550
  });
440
551
  if (!response.ok) {
@@ -457,6 +568,32 @@ async function shutdownTelemetry() {
457
568
  }
458
569
  await flushEvents();
459
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
+ }
460
597
  var EventTypes = {
461
598
  // Tunnel lifecycle
462
599
  TUNNEL_STARTING: "tunnel.starting",
@@ -484,12 +621,71 @@ var EventTypes = {
484
621
  CLI_ERROR: "cli.error"
485
622
  };
486
623
 
487
- // src/commands/tunnel.ts
488
- var MAX_RECONNECT_DELAY = 3e4;
489
- var BASE_RECONNECT_DELAY = 500;
490
- var MAX_ACTIVITY_LOG_ENTRIES = 10;
491
- var CHUNK_THRESHOLD = 512 * 1024;
492
- var CHUNK_SIZE = 768 * 1024;
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" };
629
+ }
630
+ const userToken = process.env.EVIDENT_TOKEN;
631
+ if (userToken) {
632
+ return { token: userToken, authType: "bearer" };
633
+ }
634
+ const keychainCreds = await getToken();
635
+ if (keychainCreds) {
636
+ return {
637
+ token: keychainCreds.token,
638
+ authType: "bearer",
639
+ user: keychainCreds.user
640
+ };
641
+ }
642
+ return null;
643
+ }
644
+ function getAuthHeader(credentials2) {
645
+ if (credentials2.authType === "agent_key") {
646
+ return `SandboxKey ${credentials2.token}`;
647
+ }
648
+ return `Bearer ${credentials2.token}`;
649
+ }
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;
656
+ }
657
+
658
+ // src/lib/opencode/health.ts
659
+ async function checkOpenCodeHealth(port) {
660
+ try {
661
+ const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
662
+ signal: AbortSignal.timeout(2e3)
663
+ // 2 second timeout
664
+ });
665
+ if (!response.ok) {
666
+ return { healthy: false, error: `HTTP ${response.status}` };
667
+ }
668
+ const data = await response.json().catch(() => ({}));
669
+ return { healthy: true, version: data.version };
670
+ } catch (error2) {
671
+ const message = error2 instanceof Error ? error2.message : "Unknown error";
672
+ return { healthy: false, error: message };
673
+ }
674
+ }
675
+ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
676
+ const startTime = Date.now();
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";
493
689
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
494
690
  function getProcessCwd(pid) {
495
691
  const platform = process.platform;
@@ -624,22 +820,6 @@ async function scanPortsForOpenCode() {
624
820
  }
625
821
  return instances;
626
822
  }
627
- async function checkOpenCodeHealth(port) {
628
- try {
629
- const response = await fetch(`http://localhost:${port}/health`, {
630
- signal: AbortSignal.timeout(2e3)
631
- // 2 second timeout
632
- });
633
- if (!response.ok) {
634
- return { healthy: false, error: `HTTP ${response.status}` };
635
- }
636
- const data = await response.json().catch(() => ({}));
637
- return { healthy: true, version: data.version };
638
- } catch (error2) {
639
- const message = error2 instanceof Error ? error2.message : "Unknown error";
640
- return { healthy: false, error: message };
641
- }
642
- }
643
823
  async function findHealthyOpenCodeInstances() {
644
824
  const processes = findOpenCodeProcesses();
645
825
  const healthy = [];
@@ -655,948 +835,1848 @@ async function findHealthyOpenCodeInstances() {
655
835
  }
656
836
  return healthy;
657
837
  }
658
- function logActivity(state, entry) {
659
- const fullEntry = {
660
- ...entry,
661
- timestamp: /* @__PURE__ */ new Date()
662
- };
663
- state.activityLog.push(fullEntry);
664
- if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
665
- state.activityLog.shift();
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"];
666
846
  }
667
- state.lastActivity = fullEntry.timestamp;
668
- }
669
- function formatActivityEntry(entry, _verbose) {
670
- const time = entry.timestamp.toLocaleTimeString("en-US", {
671
- hour12: false,
672
- hour: "2-digit",
673
- minute: "2-digit",
674
- second: "2-digit"
847
+ const child = spawn(command, args, {
848
+ detached: true,
849
+ stdio: "ignore",
850
+ cwd: process.cwd()
675
851
  });
676
- switch (entry.type) {
677
- case "request": {
678
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
679
- const status = entry.status ? ` \u2192 ${colorizeStatus(entry.status)}` : " ...";
680
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.cyan("\u2190")} ${entry.method} ${entry.path}${status}${duration}`;
681
- }
682
- case "response": {
683
- const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
684
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.green("\u2192")} ${entry.method} ${entry.path} ${colorizeStatus(entry.status)}${duration}`;
685
- }
686
- case "error": {
687
- const errorMsg = entry.error || "Unknown error";
688
- const path = entry.path ? ` ${entry.method} ${entry.path}` : "";
689
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.red("\u2717")}${path} - ${chalk4.red(errorMsg)}`;
690
- }
691
- case "info": {
692
- return ` ${chalk4.dim(`[${time}]`)} ${chalk4.blue("\u25CF")} ${entry.message}`;
693
- }
694
- default:
695
- return ` ${chalk4.dim(`[${time}]`)} ${entry.message || "Unknown"}`;
696
- }
697
- }
698
- function colorizeStatus(status) {
699
- if (status >= 200 && status < 300) {
700
- return chalk4.green(status.toString());
701
- } else if (status >= 300 && status < 400) {
702
- return chalk4.yellow(status.toString());
703
- } else if (status >= 400 && status < 500) {
704
- return chalk4.red(status.toString());
705
- } else if (status >= 500) {
706
- return chalk4.bgRed.white(` ${status} `);
707
- }
708
- return status.toString();
709
- }
710
- var ANSI = {
711
- saveCursor: "\x1B[s",
712
- restoreCursor: "\x1B[u",
713
- clearToEnd: "\x1B[J",
714
- moveTo: (row) => `\x1B[${row};1H`,
715
- moveUp: (n) => `\x1B[${n}A`
716
- };
717
- var STATUS_DISPLAY_HEIGHT = 20;
718
- function displayStatus(state) {
719
- const lines = [];
720
- lines.push(chalk4.bold("Evident Tunnel"));
721
- lines.push(chalk4.dim("\u2500".repeat(60)));
722
- lines.push("");
723
- if (state.sandboxName) {
724
- lines.push(` Sandbox: ${state.sandboxName}`);
725
- }
726
- lines.push(` ID: ${state.sandboxId ?? "Unknown"}`);
727
- lines.push("");
728
- if (state.connected) {
729
- lines.push(` ${chalk4.green("\u25CF")} Tunnel: ${chalk4.green("Connected to Evident")}`);
730
- } else {
731
- if (state.reconnectAttempt > 0) {
732
- lines.push(
733
- ` ${chalk4.yellow("\u25CB")} Tunnel: ${chalk4.yellow(`Reconnecting... (attempt ${state.reconnectAttempt})`)}`
734
- );
735
- } else {
736
- lines.push(` ${chalk4.yellow("\u25CB")} Tunnel: ${chalk4.yellow("Connecting...")}`);
737
- }
738
- }
739
- if (state.opencodeConnected) {
740
- const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
741
- lines.push(` ${chalk4.green("\u25CF")} OpenCode: ${chalk4.green(`Running${version}`)}`);
742
- } else {
743
- lines.push(` ${chalk4.red("\u25CB")} OpenCode: ${chalk4.red("Not connected")}`);
744
- }
745
- lines.push("");
746
- if (state.activityLog.length > 0) {
747
- lines.push(chalk4.bold(" Activity:"));
748
- for (const entry of state.activityLog) {
749
- lines.push(formatActivityEntry(entry, state.verbose));
750
- }
751
- } else {
752
- lines.push(chalk4.dim(" No activity yet. Waiting for requests..."));
753
- }
754
- lines.push("");
755
- lines.push(chalk4.dim("\u2500".repeat(60)));
756
- if (state.verbose) {
757
- lines.push(chalk4.dim(" Verbose mode: ON (request/response bodies will be logged)"));
758
- }
759
- lines.push(chalk4.dim(" Press Ctrl+C to disconnect"));
760
- while (lines.length < STATUS_DISPLAY_HEIGHT) {
761
- lines.push("");
762
- }
763
- if (!state.displayInitialized) {
764
- console.log("");
765
- console.log(chalk4.dim("\u2550".repeat(60)));
766
- console.log("");
767
- for (const line of lines) {
768
- console.log(line);
769
- }
770
- state.displayInitialized = true;
771
- } else {
772
- process.stdout.write(ANSI.moveUp(STATUS_DISPLAY_HEIGHT + 3));
773
- console.log(chalk4.dim("\u2550".repeat(60)));
774
- console.log("");
775
- for (const line of lines) {
776
- process.stdout.write("\x1B[2K");
777
- console.log(line);
778
- }
779
- }
852
+ return child;
780
853
  }
781
- function displayError(_state, error2, details) {
782
- blank();
783
- console.log(chalk4.bgRed.white.bold(" ERROR "));
784
- console.log(chalk4.red(` ${error2}`));
785
- if (details) {
786
- console.log(chalk4.dim(` ${details}`));
854
+ function stopOpenCode(opencodeProcess) {
855
+ if (!opencodeProcess || !opencodeProcess.pid) {
856
+ return;
787
857
  }
788
- blank();
789
- }
790
- async function validateSandbox(token, sandboxId) {
791
- const apiUrl = getApiUrlConfig();
792
858
  try {
793
- const response = await fetch(`${apiUrl}/sandboxes/${sandboxId}`, {
794
- headers: {
795
- Authorization: `Bearer ${token}`
796
- }
797
- });
798
- if (response.status === 404) {
799
- return { valid: false, error: "Sandbox not found" };
800
- }
801
- if (response.status === 401) {
802
- return { valid: false, error: "Authentication failed. Please run `evident login` again." };
803
- }
804
- if (!response.ok) {
805
- return { valid: false, error: `API error: ${response.status}` };
806
- }
807
- const sandbox = await response.json();
808
- if (sandbox.sandbox_type !== "remote") {
809
- return {
810
- valid: false,
811
- error: `Sandbox is type '${sandbox.sandbox_type}', must be 'remote' for tunnel connection`
812
- };
859
+ if (process.platform === "win32") {
860
+ opencodeProcess.kill("SIGTERM");
861
+ } else {
862
+ process.kill(-opencodeProcess.pid, "SIGTERM");
813
863
  }
814
- return { valid: true, name: sandbox.name };
815
- } catch (error2) {
816
- const message = error2 instanceof Error ? error2.message : "Unknown error";
817
- return { valid: false, error: `Failed to validate sandbox: ${message}` };
864
+ } catch {
818
865
  }
819
866
  }
820
- async function forwardToOpenCode(port, request, requestId, state) {
821
- const url = `http://localhost:${port}${request.path}`;
822
- const startTime = Date.now();
823
- state.pendingRequests.set(requestId, {
824
- startTime,
825
- method: request.method,
826
- path: request.path
827
- });
828
- logActivity(state, {
829
- type: "request",
830
- method: request.method,
831
- path: request.path,
832
- requestId
833
- });
834
- displayStatus(state);
835
- if (state.verbose && request.body) {
836
- console.log(chalk4.dim(` Request body: ${JSON.stringify(request.body, null, 2)}`));
837
- }
838
- telemetry.debug(
839
- EventTypes.OPENCODE_REQUEST_FORWARDED,
840
- `Forwarding ${request.method} ${request.path}`,
841
- {
842
- method: request.method,
843
- path: request.path,
844
- port,
845
- requestId
846
- },
847
- state.sandboxId ?? void 0
848
- );
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() {
849
874
  try {
850
- const response = await fetch(url, {
851
- method: request.method,
852
- headers: {
853
- "Content-Type": "application/json",
854
- ...request.headers
855
- },
856
- body: request.body ? JSON.stringify(request.body) : void 0
857
- });
858
- let body;
859
- const contentType = response.headers.get("Content-Type");
860
- const text = await response.text();
861
- if (!text || text.length === 0) {
862
- body = null;
863
- } else if (contentType?.includes("application/json")) {
864
- try {
865
- body = JSON.parse(text);
866
- } catch {
867
- body = text;
868
- }
869
- } else {
870
- body = text;
871
- }
872
- const durationMs = Date.now() - startTime;
873
- state.pendingRequests.delete(requestId);
874
- if (!state.opencodeConnected) {
875
- state.opencodeConnected = true;
876
- }
877
- const lastEntry = state.activityLog[state.activityLog.length - 1];
878
- if (lastEntry && lastEntry.requestId === requestId) {
879
- lastEntry.type = "response";
880
- lastEntry.status = response.status;
881
- lastEntry.durationMs = durationMs;
875
+ const platform = process.platform;
876
+ if (platform === "win32") {
877
+ execSync2("where opencode", { stdio: "ignore" });
882
878
  } else {
883
- logActivity(state, {
884
- type: "response",
885
- method: request.method,
886
- path: request.path,
887
- status: response.status,
888
- durationMs,
889
- requestId
890
- });
879
+ execSync2("which opencode", { stdio: "ignore" });
891
880
  }
892
- displayStatus(state);
893
- if (state.verbose && body) {
894
- const bodyStr = typeof body === "string" ? body : JSON.stringify(body, null, 2);
895
- const truncated = bodyStr.length > 500 ? bodyStr.substring(0, 500) + "..." : bodyStr;
896
- console.log(chalk4.dim(` Response body: ${truncated}`));
897
- }
898
- telemetry.debug(
899
- EventTypes.OPENCODE_RESPONSE_SENT,
900
- `Response ${response.status}`,
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: [
901
910
  {
902
- status: response.status,
903
- path: request.path,
904
- durationMs,
905
- requestId
911
+ name: "Show installation instructions",
912
+ value: "instructions",
913
+ description: "Display commands to install OpenCode"
906
914
  },
907
- state.sandboxId ?? void 0
908
- );
909
- return {
910
- status: response.status,
911
- body
912
- };
913
- } catch (error2) {
914
- const message = error2 instanceof Error ? error2.message : "Unknown error";
915
- const durationMs = Date.now() - startTime;
916
- state.pendingRequests.delete(requestId);
917
- state.opencodeConnected = false;
918
- logActivity(state, {
919
- type: "error",
920
- method: request.method,
921
- path: request.path,
922
- error: `OpenCode unreachable: ${message}`,
923
- durationMs,
924
- requestId
925
- });
926
- displayStatus(state);
927
- telemetry.error(
928
- EventTypes.OPENCODE_UNREACHABLE,
929
- `Failed to connect to OpenCode: ${message}`,
930
915
  {
931
- port,
932
- path: request.path,
933
- error: message,
934
- requestId
916
+ name: "Continue without OpenCode",
917
+ value: "continue",
918
+ description: "Connect anyway (requests will fail until OpenCode is installed)"
935
919
  },
936
- state.sandboxId ?? void 0
937
- );
938
- return {
939
- status: 502,
940
- body: { error: "Failed to connect to OpenCode", message }
941
- };
942
- }
943
- }
944
- async function subscribeToOpenCodeEvents(port, subscriptionId, ws, state) {
945
- const url = `http://localhost:${port}/event`;
946
- logActivity(state, {
947
- type: "info",
948
- message: `Starting event subscription ${subscriptionId.slice(0, 8)}`
949
- });
950
- displayStatus(state);
951
- const abortController = new AbortController();
952
- state.activeEventSubscriptions.set(subscriptionId, abortController);
953
- try {
954
- const response = await fetch(url, {
955
- headers: { Accept: "text/event-stream" },
956
- signal: abortController.signal
957
- });
958
- if (!response.ok) {
959
- throw new Error(`Failed to connect to OpenCode events: ${response.status}`);
960
- }
961
- if (!response.body) {
962
- throw new Error("No response body");
963
- }
964
- const reader = response.body.getReader();
965
- const decoder = new TextDecoder();
966
- let buffer = "";
967
- while (true) {
968
- const { done, value } = await reader.read();
969
- if (done) {
970
- ws.send(JSON.stringify({ type: "event_end", id: subscriptionId }));
971
- break;
920
+ {
921
+ name: "Exit",
922
+ value: "exit",
923
+ description: "Exit and install OpenCode manually"
972
924
  }
973
- buffer += decoder.decode(value, { stream: true });
974
- const lines = buffer.split("\n");
975
- buffer = lines.pop() || "";
976
- for (const line of lines) {
977
- if (line.startsWith("data: ")) {
978
- try {
979
- const event = JSON.parse(line.slice(6));
980
- ws.send(JSON.stringify({ type: "event", id: subscriptionId, event }));
981
- } catch {
982
- }
925
+ ]
926
+ });
927
+ if (action === "instructions") {
928
+ blank();
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: [
942
+ {
943
+ name: "I installed it - continue",
944
+ value: "continue",
945
+ description: "Proceed with the run command"
946
+ },
947
+ {
948
+ name: "Exit",
949
+ value: "exit",
950
+ description: "Exit now and run the command again later"
983
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";
984
969
  }
985
970
  }
986
- } catch (error2) {
987
- if (abortController.signal.aborted) {
988
- return;
989
- }
990
- const message = error2 instanceof Error ? error2.message : "Unknown error";
991
- logActivity(state, {
992
- type: "error",
993
- error: `Event subscription failed: ${message}`
994
- });
995
- displayStatus(state);
996
- ws.send(JSON.stringify({ type: "event_error", id: subscriptionId, error: message }));
997
- } finally {
998
- state.activeEventSubscriptions.delete(subscriptionId);
971
+ return "exit";
999
972
  }
973
+ return action;
1000
974
  }
1001
- function cancelEventSubscription(subscriptionId, state) {
1002
- const controller = state.activeEventSubscriptions.get(subscriptionId);
1003
- if (controller) {
1004
- controller.abort();
1005
- state.activeEventSubscriptions.delete(subscriptionId);
1006
- }
975
+
976
+ // src/lib/opencode/session.ts
977
+ function opencodeBase(port) {
978
+ return `http://127.0.0.1:${port}`;
1007
979
  }
1008
- function sendResponse(ws, requestId, response) {
1009
- const bodyStr = JSON.stringify(response.body ?? null);
1010
- const bodyBytes = Buffer.from(bodyStr, "utf-8");
1011
- if (bodyBytes.length < CHUNK_THRESHOLD) {
1012
- ws.send(
1013
- JSON.stringify({
1014
- type: "response",
1015
- id: requestId,
1016
- payload: response
1017
- })
1018
- );
1019
- return;
1020
- }
1021
- sendResponseAsChunks(ws, requestId, response, bodyBytes);
1022
- }
1023
- function sendResponseAsChunks(ws, requestId, response, bodyBytes) {
1024
- const chunks = splitIntoChunks(bodyBytes, CHUNK_SIZE);
1025
- ws.send(
1026
- JSON.stringify({
1027
- type: "response_start",
1028
- id: requestId,
1029
- total_chunks: chunks.length,
1030
- total_size: bodyBytes.length,
1031
- payload: {
1032
- status: response.status,
1033
- headers: response.headers
1034
- }
1035
- })
1036
- );
1037
- for (let i = 0; i < chunks.length; i++) {
1038
- ws.send(
1039
- JSON.stringify({
1040
- type: "response_chunk",
1041
- id: requestId,
1042
- chunk_index: i,
1043
- data: chunks[i].toString("base64")
1044
- })
1045
- );
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;
1046
989
  }
1047
- ws.send(
1048
- JSON.stringify({
1049
- type: "response_end",
1050
- id: requestId
1051
- })
1052
- );
1053
990
  }
1054
- function splitIntoChunks(data, chunkSize) {
1055
- const chunks = [];
1056
- for (let i = 0; i < data.length; i += chunkSize) {
1057
- chunks.push(data.subarray(i, i + chunkSize));
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;
1058
1009
  }
1059
- return chunks;
1060
1010
  }
1061
- function getReconnectDelay(attempt) {
1062
- const exponentialDelay = BASE_RECONNECT_DELAY * Math.pow(2, attempt);
1063
- const jitter = Math.random() * 1e3;
1064
- return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
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;
1065
1016
  }
1066
- async function connect(token, sandboxId, port, state) {
1067
- const tunnelUrl = getTunnelUrlConfig();
1068
- const url = `${tunnelUrl}/tunnel/${sandboxId}/connect`;
1069
- logActivity(state, {
1070
- type: "info",
1071
- message: "Connecting to tunnel relay..."
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({})
1072
1026
  });
1073
- displayStatus(state);
1074
- telemetry.info(
1075
- EventTypes.TUNNEL_STARTING,
1076
- `Connecting to ${url}`,
1077
- {
1078
- sandboxId,
1079
- port,
1080
- tunnelUrl
1081
- },
1082
- sandboxId
1083
- );
1084
- return new Promise((resolve, reject) => {
1085
- const ws = new WebSocket(url, {
1086
- headers: {
1087
- Authorization: `Bearer ${token}`
1088
- }
1089
- });
1090
- ws.on("open", () => {
1091
- state.connected = true;
1092
- state.reconnectAttempt = 0;
1093
- logActivity(state, {
1094
- type: "info",
1095
- message: "WebSocket connection established"
1096
- });
1097
- displayStatus(state);
1098
- });
1099
- ws.on("message", async (data) => {
1100
- try {
1101
- const message = JSON.parse(data.toString());
1102
- switch (message.type) {
1103
- case "connected":
1104
- state.sandboxId = message.sandbox_id ?? sandboxId;
1105
- logActivity(state, {
1106
- type: "info",
1107
- message: `Tunnel connected (sandbox: ${state.sandboxId})`
1108
- });
1109
- telemetry.info(
1110
- EventTypes.TUNNEL_CONNECTED,
1111
- `Tunnel connected`,
1112
- {
1113
- sandboxId: message.sandbox_id
1114
- },
1115
- message.sandbox_id
1116
- );
1117
- displayStatus(state);
1118
- break;
1119
- case "error":
1120
- logActivity(state, {
1121
- type: "error",
1122
- error: message.message || "Unknown tunnel error"
1123
- });
1124
- telemetry.error(
1125
- EventTypes.TUNNEL_ERROR,
1126
- `Tunnel error: ${message.message}`,
1127
- {
1128
- code: message.code,
1129
- message: message.message
1130
- },
1131
- state.sandboxId ?? void 0
1132
- );
1133
- displayStatus(state);
1134
- if (message.code === "unauthorized") {
1135
- ws.close();
1136
- reject(new Error("Unauthorized"));
1137
- }
1138
- break;
1139
- case "ping":
1140
- ws.send(JSON.stringify({ type: "pong" }));
1141
- break;
1142
- case "request":
1143
- if (message.id && message.payload) {
1144
- telemetry.debug(
1145
- EventTypes.OPENCODE_REQUEST_RECEIVED,
1146
- `Request: ${message.payload.method} ${message.payload.path}`,
1147
- {
1148
- requestId: message.id,
1149
- method: message.payload.method,
1150
- path: message.payload.path
1151
- },
1152
- state.sandboxId ?? void 0
1153
- );
1154
- const response = await forwardToOpenCode(port, message.payload, message.id, state);
1155
- sendResponse(ws, message.id, response);
1156
- }
1157
- break;
1158
- case "subscribe_events":
1159
- if (message.id) {
1160
- void subscribeToOpenCodeEvents(port, message.id, ws, state);
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
+ }
1161
1067
  }
1162
- break;
1163
- case "unsubscribe_events":
1164
- if (message.id) {
1165
- cancelEventSubscription(message.id, state);
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
+ }
1166
1082
  }
1167
- break;
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
+ }
1168
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());
1169
1371
  } catch (error2) {
1170
1372
  const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
1171
- logActivity(state, {
1172
- type: "error",
1173
- error: `Failed to handle message: ${errorMessage}`
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)
1174
1482
  });
1175
- telemetry.error(
1176
- EventTypes.TUNNEL_ERROR,
1177
- `Failed to handle message: ${errorMessage}`,
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,
1178
1642
  {
1179
- error: errorMessage
1643
+ agent: message.opencode_agent ?? void 0,
1644
+ model: message.opencode_model ?? void 0
1180
1645
  },
1181
- state.sandboxId ?? void 0
1646
+ {
1647
+ onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
1648
+ onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
1649
+ }
1182
1650
  );
1183
- displayStatus(state);
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
+ });
1184
1680
  }
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(() => {
1185
1695
  });
1186
- ws.on("close", (code, reason) => {
1187
- state.connected = false;
1188
- const reasonStr = reason.toString() || "No reason provided";
1189
- logActivity(state, {
1190
- type: "info",
1191
- message: `Disconnected (code: ${code}, reason: ${reasonStr})`
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"
1192
1710
  });
1193
- telemetry.info(
1194
- EventTypes.TUNNEL_DISCONNECTED,
1195
- "Tunnel disconnected",
1196
- {
1197
- sandboxId: state.sandboxId,
1198
- code,
1199
- reason: reasonStr
1200
- },
1201
- state.sandboxId ?? void 0
1202
- );
1203
- displayStatus(state);
1204
- resolve();
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);
1205
1756
  });
1206
- ws.on("error", (error2) => {
1207
- state.connected = false;
1208
- logActivity(state, {
1209
- type: "error",
1210
- error: `Connection error: ${error2.message}`
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
1211
1817
  });
1212
- telemetry.error(
1213
- EventTypes.TUNNEL_ERROR,
1214
- `Connection error: ${error2.message}`,
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}`,
1215
1875
  {
1216
- error: error2.message
1217
- },
1218
- 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
+ )
1219
1924
  );
1220
- displayStatus(state);
1925
+ this.log({
1926
+ level: "info",
1927
+ message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
1928
+ conversation_id: conversationId
1929
+ });
1930
+ } catch (err) {
1931
+ if (err instanceof ChannelAuthError) throw err;
1932
+ this.log({
1933
+ level: "error",
1934
+ message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
1935
+ conversation_id: conversationId
1936
+ });
1937
+ }
1938
+ }
1939
+ // -------------------------------------------------------------------------
1940
+ // Retry wrapper
1941
+ // -------------------------------------------------------------------------
1942
+ /**
1943
+ * Invoke an Evident API call, retrying on transient failures (5xx / 429 /
1944
+ * network errors) with exponential backoff + jitter (capped). Auth failures
1945
+ * (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
1946
+ * terminal too. No on-disk persistence — a crash mid-retry drops the callback
1947
+ * (accepted by ADR-0039).
1948
+ */
1949
+ async callWithRetry(context, call) {
1950
+ let lastError;
1951
+ for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
1952
+ let res;
1953
+ try {
1954
+ res = await call();
1955
+ } catch (err) {
1956
+ lastError = err;
1957
+ if (attempt < this.retry.maxAttempts - 1) {
1958
+ await this.sleep(backoffDelay(attempt, this.retry));
1959
+ continue;
1960
+ }
1961
+ throw err;
1962
+ }
1963
+ if (res.status === 401 || res.status === 403) {
1964
+ throw new ChannelAuthError(
1965
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1966
+ );
1967
+ }
1968
+ if (res.ok) return;
1969
+ if (isRetryableStatus(res.status)) {
1970
+ lastError = new Error(`${context}: HTTP ${res.status}`);
1971
+ if (attempt < this.retry.maxAttempts - 1) {
1972
+ await this.sleep(backoffDelay(attempt, this.retry));
1973
+ continue;
1974
+ }
1975
+ }
1976
+ throw new Error(`${context}: HTTP ${res.status}`);
1977
+ }
1978
+ throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
1979
+ }
1980
+ assertAuth(res, context) {
1981
+ if (res.status === 401 || res.status === 403) {
1982
+ throw new ChannelAuthError(
1983
+ `Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
1984
+ );
1985
+ }
1986
+ }
1987
+ };
1988
+
1989
+ // src/commands/ensure-opencode.ts
1990
+ import chalk5 from "chalk";
1991
+ import ora2 from "ora";
1992
+ import { select as select2 } from "@inquirer/prompts";
1993
+ async function ensureOpenCodeRunning(ctx) {
1994
+ const healthCheck = await checkOpenCodeHealth(ctx.port);
1995
+ if (healthCheck.healthy) {
1996
+ return { port: ctx.port, process: null, version: healthCheck.version ?? null };
1997
+ }
1998
+ const runningInstances = await findHealthyOpenCodeInstances();
1999
+ if (runningInstances.length > 0) {
2000
+ if (!ctx.interactive) {
2001
+ throw new Error(
2002
+ `OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
2003
+ );
2004
+ }
2005
+ blank();
2006
+ console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
2007
+ for (const instance of runningInstances) {
2008
+ const ver = instance.version ? ` (v${instance.version})` : "";
2009
+ const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
2010
+ console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
2011
+ }
2012
+ blank();
2013
+ if (runningInstances.length === 1) {
2014
+ console.log(chalk5.yellow("Tip: Run with the correct port:"));
2015
+ console.log(
2016
+ chalk5.dim(
2017
+ ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
2018
+ )
2019
+ );
2020
+ }
2021
+ blank();
2022
+ throw new Error(`OpenCode not running on port ${ctx.port}`);
2023
+ }
2024
+ if (!isOpenCodeInstalled()) {
2025
+ if (!ctx.interactive) {
2026
+ throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
2027
+ }
2028
+ const result = await promptOpenCodeInstall(true);
2029
+ if (result === "exit") process.exit(0);
2030
+ if (result !== "installed" && !isOpenCodeInstalled()) {
2031
+ throw new Error("OpenCode is not installed");
2032
+ }
2033
+ }
2034
+ if (!ctx.interactive) {
2035
+ ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
2036
+ const proc = await startOpenCode(ctx.port);
2037
+ const health = await waitForOpenCodeHealth(ctx.port, 3e4);
2038
+ if (!health.healthy) {
2039
+ throw new Error(
2040
+ `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
2041
+ );
2042
+ }
2043
+ ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
2044
+ return { port: ctx.port, process: proc, version: health.version ?? null };
2045
+ }
2046
+ let port = ctx.port;
2047
+ if (isPortInUse(port)) {
2048
+ console.log(chalk5.yellow(`
2049
+ Port ${port} is already in use.`));
2050
+ const alternativePort = findAvailablePort(port + 1);
2051
+ if (alternativePort) {
2052
+ const useAlternative = await select2({
2053
+ message: `Use port ${alternativePort} instead?`,
2054
+ choices: [
2055
+ { name: `Yes, use port ${alternativePort}`, value: "yes" },
2056
+ { name: "No, I will free the port manually", value: "no" }
2057
+ ]
2058
+ });
2059
+ if (useAlternative === "yes") {
2060
+ port = alternativePort;
2061
+ } else {
2062
+ throw new Error(`Port ${ctx.port} is in use`);
2063
+ }
2064
+ }
2065
+ }
2066
+ const action = await select2({
2067
+ message: "OpenCode is not running. What would you like to do?",
2068
+ choices: [
2069
+ {
2070
+ name: "Start OpenCode for me",
2071
+ value: "start",
2072
+ description: `Run 'opencode serve --port ${port}'`
2073
+ },
2074
+ {
2075
+ name: "Show me the command",
2076
+ value: "manual",
2077
+ description: "Display the command to run manually"
2078
+ },
2079
+ {
2080
+ name: "Continue without OpenCode",
2081
+ value: "continue",
2082
+ description: "Requests will fail until OpenCode starts"
2083
+ }
2084
+ ]
2085
+ });
2086
+ if (action === "manual") {
2087
+ blank();
2088
+ console.log(chalk5.bold("Run this command in another terminal:"));
2089
+ blank();
2090
+ console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
2091
+ blank();
2092
+ throw new Error("Please start OpenCode manually");
2093
+ }
2094
+ if (action === "start") {
2095
+ const spinner = ora2("Starting OpenCode...").start();
2096
+ const proc = await startOpenCode(port);
2097
+ const health = await waitForOpenCodeHealth(port, 3e4);
2098
+ if (!health.healthy) {
2099
+ spinner.fail("Failed to start OpenCode");
2100
+ throw new Error("OpenCode failed to start");
2101
+ }
2102
+ spinner.stop();
2103
+ return { port, process: proc, version: health.version ?? null };
2104
+ }
2105
+ return { port, process: null, version: null };
2106
+ }
2107
+
2108
+ // src/commands/agent-lookup.ts
2109
+ async function readErrorMessage(response) {
2110
+ const text = await response.text().catch(() => "");
2111
+ if (!text) return response.statusText || void 0;
2112
+ try {
2113
+ const data = JSON.parse(text);
2114
+ const message = data.message ?? data.error;
2115
+ if (typeof message === "string" && message.trim()) {
2116
+ return message;
2117
+ }
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 }
1221
2131
  });
1222
- const cleanup = async () => {
1223
- process.removeAllListeners("SIGINT");
1224
- process.removeAllListeners("SIGTERM");
1225
- logActivity(state, {
1226
- type: "info",
1227
- message: "Shutting down..."
1228
- });
1229
- displayStatus(state);
1230
- telemetry.info(
1231
- EventTypes.TUNNEL_DISCONNECTED,
1232
- "Tunnel stopped by user",
1233
- {
1234
- sandboxId: state.sandboxId
1235
- },
1236
- state.sandboxId ?? void 0
1237
- );
1238
- await shutdownTelemetry();
1239
- ws.close();
1240
- process.exit(0);
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."
1241
2148
  };
1242
- process.removeAllListeners("SIGINT");
1243
- process.removeAllListeners("SIGTERM");
1244
- process.once("SIGINT", () => void cleanup());
1245
- process.once("SIGTERM", () => void cleanup());
1246
- });
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
+ }
1247
2229
  }
1248
- async function tunnel(options) {
1249
- const verbose = options.verbose ?? false;
1250
- const credentials = await getToken();
1251
- if (!credentials) {
1252
- const cliName = (await import("./config-J7LPYFVS.js")).getCliName();
1253
- telemetry.error(EventTypes.CLI_ERROR, "Not logged in", { command: "tunnel" });
1254
- printError(`Not logged in. Run \`${cliName} login\` first.`);
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.");
1255
2268
  process.exit(1);
1256
2269
  }
1257
- const port = options.port ?? 4096;
1258
- const sandboxId = options.sandbox;
1259
- if (!sandboxId) {
1260
- const cliName = (await import("./config-J7LPYFVS.js")).getCliName();
1261
- printError("--sandbox <id> is required");
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) {
1262
2283
  blank();
1263
- console.log(chalk4.dim("To find your sandbox ID:"));
1264
- console.log(chalk4.dim(" 1. Create a remote sandbox in the Evident web UI"));
1265
- console.log(chalk4.dim(" 2. Copy the sandbox ID from the URL or settings"));
1266
- console.log(chalk4.dim(` 3. Run: ${cliName} tunnel --sandbox <id>`));
2284
+ console.log(chalk6.red("Authentication expired"));
2285
+ console.log(chalk6.dim("Your authentication token is no longer valid."));
1267
2286
  blank();
1268
- telemetry.error(EventTypes.CLI_ERROR, "Missing sandbox ID", { command: "tunnel" });
1269
- process.exit(1);
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 };
1270
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" });
2371
+ displayStatus(state);
2372
+ } else {
2373
+ log(state, "Stopped OpenCode process");
2374
+ }
2375
+ state.opencodeProcess = null;
2376
+ }
2377
+ }
2378
+ async function run(options) {
2379
+ const interactive = isInteractive(options.json);
1271
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,
1272
2388
  connected: false,
1273
2389
  opencodeConnected: false,
1274
2390
  opencodeVersion: null,
1275
- sandboxId,
1276
- sandboxName: null,
1277
- reconnectAttempt: 0,
1278
- lastActivity: /* @__PURE__ */ new Date(),
2391
+ opencodeProcess: null,
2392
+ connection: null,
2393
+ running: true,
1279
2394
  activityLog: [],
1280
- pendingRequests: /* @__PURE__ */ new Map(),
1281
- verbose,
1282
- displayInitialized: false,
1283
- activeEventSubscriptions: /* @__PURE__ */ new Map()
2395
+ messageCount: 0,
2396
+ authHeader: ""
1284
2397
  };
1285
- telemetry.info(
1286
- EventTypes.CLI_COMMAND,
1287
- "Starting tunnel command",
1288
- {
1289
- command: "tunnel",
1290
- port,
1291
- sandboxId,
1292
- verbose
1293
- },
1294
- sandboxId
1295
- );
1296
- logActivity(state, {
1297
- type: "info",
1298
- message: `Starting tunnel (port: ${port}, verbose: ${verbose})`
1299
- });
1300
- logActivity(state, {
1301
- type: "info",
1302
- message: "Validating sandbox..."
1303
- });
1304
- const validateSpinner = ora2("Validating sandbox...").start();
1305
- const validation = await validateSandbox(credentials.token, sandboxId);
1306
- if (!validation.valid) {
1307
- validateSpinner.fail(`Sandbox validation failed: ${validation.error}`);
1308
- logActivity(state, {
1309
- type: "error",
1310
- error: `Sandbox validation failed: ${validation.error}`
1311
- });
1312
- telemetry.error(EventTypes.CLI_ERROR, `Sandbox validation failed: ${validation.error}`, {
1313
- command: "tunnel",
1314
- sandboxId
1315
- });
1316
- displayStatus(state);
1317
- process.exit(1);
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
+ );
1318
2404
  }
1319
- state.sandboxName = validation.name ?? null;
1320
- validateSpinner.succeed(`Sandbox: ${validation.name || sandboxId}`);
1321
- logActivity(state, {
1322
- type: "info",
1323
- message: `Sandbox validated: ${validation.name || sandboxId}`
1324
- });
1325
- logActivity(state, {
1326
- type: "info",
1327
- message: `Checking OpenCode on port ${port}...`
1328
- });
1329
- const opencodeSpinner = ora2("Checking OpenCode connection...").start();
1330
- const healthCheck = await checkOpenCodeHealth(port);
1331
- if (healthCheck.healthy) {
1332
- state.opencodeConnected = true;
1333
- state.opencodeVersion = healthCheck.version ?? null;
1334
- const version = healthCheck.version ? ` (v${healthCheck.version})` : "";
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
+ }
1335
2462
  telemetry.info(
1336
- EventTypes.OPENCODE_HEALTH_OK,
1337
- `OpenCode healthy on port ${port}`,
1338
- {
1339
- port,
1340
- version: healthCheck.version
1341
- },
1342
- sandboxId
1343
- );
1344
- opencodeSpinner.succeed(`OpenCode running on port ${port}${version}`);
1345
- logActivity(state, {
1346
- type: "info",
1347
- message: `OpenCode running on port ${port}${version}`
1348
- });
1349
- } else {
1350
- telemetry.warn(
1351
- EventTypes.OPENCODE_HEALTH_FAILED,
1352
- `Could not connect to OpenCode: ${healthCheck.error}`,
2463
+ EventTypes.CLI_COMMAND,
2464
+ "Starting run command",
1353
2465
  {
1354
- port,
1355
- error: healthCheck.error
2466
+ command: "run",
2467
+ agentId: state.agentId,
2468
+ port: state.port,
2469
+ conversationFilter: state.conversationFilter,
2470
+ interactive
1356
2471
  },
1357
- sandboxId
2472
+ state.agentId
1358
2473
  );
1359
- opencodeSpinner.warn(`Could not connect to OpenCode on port ${port}`);
1360
- logActivity(state, {
1361
- type: "error",
1362
- error: `OpenCode not reachable on port ${port}: ${healthCheck.error}`
1363
- });
1364
- const runningInstances = await findHealthyOpenCodeInstances();
1365
- if (runningInstances.length > 0) {
1366
- blank();
1367
- console.log(chalk4.yellow("Found OpenCode running on different port(s):"));
1368
- for (const instance of runningInstances) {
1369
- const ver = instance.version ? ` (v${instance.version})` : "";
1370
- const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
1371
- console.log(chalk4.dim(` \u2022 Port ${instance.port}${ver}${cwd}`));
1372
- }
2474
+ if (interactive && !state.json) {
1373
2475
  blank();
1374
- if (runningInstances.length === 1) {
1375
- console.log(chalk4.yellow("Tip: Run with the correct port:"));
1376
- console.log(
1377
- chalk4.dim(
1378
- ` npx @evident-ai/cli@latest tunnel --sandbox ${sandboxId} --port ${runningInstances[0].port}`
1379
- )
1380
- );
1381
- } else {
1382
- console.log(chalk4.yellow("Tip: Specify which port to use:"));
1383
- console.log(
1384
- chalk4.dim(` npx @evident-ai/cli@latest tunnel --sandbox ${sandboxId} --port <PORT>`)
1385
- );
1386
- }
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");
1387
2483
  blank();
1388
- } else {
2484
+ console.log(chalk6.yellow("Your authentication token is invalid or expired."));
1389
2485
  blank();
1390
- const action = await select({
1391
- message: "OpenCode is not running. What would you like to do?",
1392
- choices: [
1393
- {
1394
- name: "Start OpenCode for me",
1395
- value: "start",
1396
- description: `Run 'opencode serve --port ${port}' in a new process`
1397
- },
1398
- {
1399
- name: "Show me the command to run",
1400
- value: "manual",
1401
- description: "Display instructions for starting OpenCode manually"
1402
- },
1403
- {
1404
- name: "Continue without OpenCode",
1405
- value: "continue",
1406
- description: "Connect the tunnel anyway (requests will fail until OpenCode starts)"
1407
- }
1408
- ]
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)
1409
2507
  });
1410
- if (action === "start") {
1411
- let actualPort = port;
1412
- if (isPortInUse(port)) {
1413
- console.log(chalk4.yellow(`
1414
- Port ${port} is already in use by another process.`));
1415
- const alternativePort = findAvailablePort(port + 1);
1416
- if (alternativePort) {
1417
- const useAlternative = await select({
1418
- message: `Would you like to use port ${alternativePort} instead?`,
1419
- choices: [
1420
- { name: `Yes, use port ${alternativePort}`, value: "yes" },
1421
- { name: "No, I will free up the port manually", value: "no" }
1422
- ]
1423
- });
1424
- if (useAlternative === "yes") {
1425
- actualPort = alternativePort;
1426
- } else {
1427
- console.log(chalk4.dim(`
1428
- Free up port ${port} and run the tunnel command again.`));
1429
- blank();
1430
- process.exit(1);
1431
- }
1432
- } else {
1433
- console.log(
1434
- chalk4.red(
1435
- `Could not find an available port. Please free up port ${port} and try again.`
1436
- )
1437
- );
1438
- blank();
1439
- process.exit(1);
1440
- }
1441
- }
1442
- const opencodeStartSpinner = ora2(`Starting OpenCode on port ${actualPort}...`).start();
1443
- try {
1444
- let command = "opencode";
1445
- let args = ["serve", "--port", actualPort.toString()];
1446
- try {
1447
- execSync("which opencode", { stdio: "ignore" });
1448
- } catch {
1449
- command = "npx";
1450
- args = ["opencode", "serve", "--port", actualPort.toString()];
1451
- }
1452
- const child = spawn(command, args, {
1453
- detached: true,
1454
- stdio: "ignore",
1455
- cwd: process.cwd()
1456
- // Start in current working directory
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})`
1457
2543
  });
1458
- child.unref();
1459
- const maxRetries = 10;
1460
- const retryDelayMs = 1e3;
1461
- let healthy = false;
1462
- let version;
1463
- for (let i = 0; i < maxRetries; i++) {
1464
- await sleep(retryDelayMs);
1465
- const retryHealth = await checkOpenCodeHealth(actualPort);
1466
- if (retryHealth.healthy) {
1467
- healthy = true;
1468
- version = retryHealth.version;
1469
- break;
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);
1470
2555
  }
1471
- opencodeStartSpinner.text = `Starting OpenCode on port ${actualPort}... (${i + 1}/${maxRetries})`;
1472
- }
1473
- if (healthy) {
1474
- state.opencodeConnected = true;
1475
- state.opencodeVersion = version ?? null;
1476
- const versionStr = version ? ` (v${version})` : "";
1477
- opencodeStartSpinner.succeed(`OpenCode started on port ${actualPort}${versionStr}`);
2556
+ }).catch((error2) => {
2557
+ const message = error2 instanceof Error ? error2.message : String(error2);
1478
2558
  logActivity(state, {
1479
- type: "info",
1480
- message: `OpenCode started on port ${actualPort}${versionStr}`
2559
+ type: "error",
2560
+ error: `Failed to drain queued messages on connect: ${message}`
1481
2561
  });
1482
- } else {
1483
- opencodeStartSpinner.warn(
1484
- "OpenCode process started but not responding. Check if it started correctly."
1485
- );
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);
1486
2601
  logActivity(state, {
1487
- type: "info",
1488
- message: "OpenCode may still be starting..."
2602
+ type: "error",
2603
+ error: `Failed to drain queued messages on ping: ${message}`
1489
2604
  });
1490
- console.log(chalk4.dim("\nTip: Check for errors by running OpenCode manually:"));
1491
- console.log(chalk4.dim(` opencode serve --port ${actualPort}`));
1492
- blank();
1493
- }
1494
- } catch (error2) {
1495
- const msg = error2 instanceof Error ? error2.message : "Unknown error";
1496
- opencodeStartSpinner.fail(`Failed to start OpenCode: ${msg}`);
1497
- logActivity(state, {
1498
- type: "error",
1499
- error: `Failed to start OpenCode: ${msg}`
2605
+ if (state.interactive) displayStatus(state);
1500
2606
  });
1501
- console.log(chalk4.yellow("\nYou can try starting it manually:"));
1502
- console.log(chalk4.dim(` opencode serve --port ${actualPort}`));
1503
- blank();
1504
- }
1505
- } else if (action === "manual") {
1506
- blank();
1507
- console.log(chalk4.yellow("To start OpenCode, run one of these commands:"));
1508
- blank();
1509
- console.log(chalk4.dim(" # Start OpenCode in your project directory:"));
1510
- console.log(chalk4.dim(` opencode serve --port ${port}`));
1511
- blank();
1512
- console.log(chalk4.dim(" # Or if you have OpenCode installed globally:"));
1513
- console.log(chalk4.dim(` npx opencode serve --port ${port}`));
1514
- blank();
1515
- console.log(
1516
- chalk4.dim("The tunnel will automatically forward requests once OpenCode is running.")
1517
- );
1518
- blank();
2607
+ },
2608
+ onInfo: (message) => logActivity(state, { type: "info", message })
1519
2609
  }
1520
- }
1521
- }
1522
- while (true) {
2610
+ });
2611
+ state.connection = connection;
1523
2612
  try {
1524
- await connect(credentials.token, sandboxId, port, state);
1525
- state.reconnectAttempt++;
1526
- const delay = getReconnectDelay(state.reconnectAttempt);
1527
- logActivity(state, {
1528
- type: "info",
1529
- message: `Reconnecting in ${Math.round(delay / 1e3)}s (attempt ${state.reconnectAttempt})...`
1530
- });
1531
- telemetry.info(
1532
- EventTypes.TUNNEL_RECONNECTING,
1533
- `Reconnecting (attempt ${state.reconnectAttempt})`,
1534
- {
1535
- attempt: state.reconnectAttempt,
1536
- delayMs: delay
1537
- },
1538
- state.sandboxId ?? void 0
1539
- );
1540
- displayStatus(state);
1541
- await sleep(delay);
2613
+ await connection.connect();
1542
2614
  } catch (error2) {
1543
- const message = error2 instanceof Error ? error2.message : "Unknown error";
1544
- if (message === "Unauthorized") {
1545
- telemetry.error(
1546
- EventTypes.CLI_ERROR,
1547
- "Authentication failed",
1548
- {
1549
- command: "tunnel",
1550
- error: message
1551
- },
1552
- state.sandboxId ?? void 0
1553
- );
1554
- await shutdownTelemetry();
1555
- displayError(state, "Authentication failed", "Please run `evident login` again.");
1556
- process.exit(1);
1557
- }
1558
- logActivity(state, {
1559
- type: "error",
1560
- error: message
1561
- });
1562
- telemetry.error(
1563
- EventTypes.TUNNEL_ERROR,
1564
- `Tunnel error: ${message}`,
1565
- {
1566
- error: message,
1567
- attempt: state.reconnectAttempt
1568
- },
1569
- state.sandboxId ?? void 0
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
+ })
1570
2629
  );
1571
- state.reconnectAttempt++;
1572
- const delay = getReconnectDelay(state.reconnectAttempt);
1573
- logActivity(state, {
1574
- type: "info",
1575
- message: `Reconnecting in ${Math.round(delay / 1e3)}s (attempt ${state.reconnectAttempt})...`
1576
- });
1577
- displayStatus(state);
1578
- await sleep(delay);
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);
1579
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);
1580
2649
  }
1581
2650
  }
1582
2651
 
1583
2652
  // src/index.ts
1584
2653
  var program = new Command();
1585
- 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) => {
1586
- const env = thisCommand.opts().env;
1587
- if (env) {
1588
- 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);
1589
2664
  }
1590
2665
  });
1591
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);
1592
- 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 }));
1593
2668
  program.command("whoami").description("Show the currently logged in user").action(whoami);
1594
- 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) => {
1595
- tunnel({
1596
- sandbox: options.sandbox,
1597
- port: parseInt(options.port, 10),
1598
- verbose: options.verbose
1599
- });
1600
- });
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
+ );
1601
2681
  program.parse();
1602
2682
  //# sourceMappingURL=index.js.map