@evident-ai/cli 0.2.0 → 0.2.1-dev.0c08ffb
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/README.md +11 -4
- package/dist/index.js +1662 -907
- package/dist/index.js.map +1 -1
- package/package.json +11 -7
- package/dist/chunk-MWOWXSOP.js +0 -120
- package/dist/chunk-MWOWXSOP.js.map +0 -1
- package/dist/config-J7LPYFVS.js +0 -30
- package/dist/config-J7LPYFVS.js.map +0 -1
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,79 @@ 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 process.env.EVIDENT_API_URL ?? endpointOverride ?? defaults.apiUrl;
|
|
36
|
+
}
|
|
37
|
+
function getTunnelUrl() {
|
|
38
|
+
return process.env.EVIDENT_TUNNEL_URL ?? tunnelOverride ?? 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 getCredentials() {
|
|
58
|
+
return {
|
|
59
|
+
token: credentials.get("token"),
|
|
60
|
+
user: credentials.get("user"),
|
|
61
|
+
expiresAt: credentials.get("expiresAt")
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function setCredentials(creds) {
|
|
65
|
+
if (creds.token) credentials.set("token", creds.token);
|
|
66
|
+
if (creds.user) credentials.set("user", creds.user);
|
|
67
|
+
if (creds.expiresAt) credentials.set("expiresAt", creds.expiresAt);
|
|
68
|
+
}
|
|
69
|
+
function clearCredentials() {
|
|
70
|
+
credentials.clear();
|
|
71
|
+
}
|
|
72
|
+
function getCliName() {
|
|
73
|
+
const argv1 = process.argv[1] || "";
|
|
74
|
+
const isNpx = process.env.npm_execpath?.includes("npx") || process.env.npm_command === "exec" || argv1.includes("_npx") || argv1.includes(".npm/_cacache");
|
|
75
|
+
if (isNpx) {
|
|
76
|
+
return "npx @evident-ai/cli@latest";
|
|
77
|
+
}
|
|
78
|
+
if (argv1.includes("tsx") || argv1.includes("ts-node")) {
|
|
79
|
+
return "pnpm --filter @evident-ai/cli dev:run";
|
|
80
|
+
}
|
|
81
|
+
return "evident";
|
|
82
|
+
}
|
|
83
|
+
|
|
19
84
|
// src/lib/api.ts
|
|
20
85
|
var ApiClient = class {
|
|
21
86
|
baseUrl;
|
|
@@ -123,15 +188,15 @@ async function getKeytar() {
|
|
|
123
188
|
return null;
|
|
124
189
|
}
|
|
125
190
|
}
|
|
126
|
-
async function storeToken(
|
|
191
|
+
async function storeToken(credentials2) {
|
|
127
192
|
const keytar = await getKeytar();
|
|
128
193
|
if (keytar) {
|
|
129
|
-
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(
|
|
194
|
+
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(credentials2));
|
|
130
195
|
} else {
|
|
131
196
|
setCredentials({
|
|
132
|
-
token:
|
|
133
|
-
user:
|
|
134
|
-
expiresAt:
|
|
197
|
+
token: credentials2.token,
|
|
198
|
+
user: credentials2.user,
|
|
199
|
+
expiresAt: credentials2.expiresAt
|
|
135
200
|
});
|
|
136
201
|
}
|
|
137
202
|
}
|
|
@@ -332,8 +397,8 @@ async function login(options) {
|
|
|
332
397
|
|
|
333
398
|
// src/commands/logout.ts
|
|
334
399
|
async function logout() {
|
|
335
|
-
const
|
|
336
|
-
if (!
|
|
400
|
+
const credentials2 = await getToken();
|
|
401
|
+
if (!credentials2) {
|
|
337
402
|
printWarning("You are not logged in.");
|
|
338
403
|
return;
|
|
339
404
|
}
|
|
@@ -344,16 +409,16 @@ async function logout() {
|
|
|
344
409
|
// src/commands/whoami.ts
|
|
345
410
|
import chalk3 from "chalk";
|
|
346
411
|
async function whoami() {
|
|
347
|
-
const
|
|
348
|
-
if (!
|
|
412
|
+
const credentials2 = await getToken();
|
|
413
|
+
if (!credentials2) {
|
|
349
414
|
printError("Not logged in. Run the `login` command to authenticate.");
|
|
350
415
|
process.exit(1);
|
|
351
416
|
}
|
|
352
417
|
blank();
|
|
353
|
-
console.log(keyValue("User", chalk3.bold(
|
|
354
|
-
console.log(keyValue("User ID",
|
|
355
|
-
if (
|
|
356
|
-
const expiresAt = new Date(
|
|
418
|
+
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
419
|
+
console.log(keyValue("User ID", credentials2.user.id));
|
|
420
|
+
if (credentials2.expiresAt) {
|
|
421
|
+
const expiresAt = new Date(credentials2.expiresAt);
|
|
357
422
|
const now = /* @__PURE__ */ new Date();
|
|
358
423
|
if (expiresAt < now) {
|
|
359
424
|
console.log(keyValue("Status", chalk3.red("Token expired")));
|
|
@@ -367,12 +432,23 @@ async function whoami() {
|
|
|
367
432
|
blank();
|
|
368
433
|
}
|
|
369
434
|
|
|
370
|
-
// src/commands/
|
|
371
|
-
import
|
|
372
|
-
import
|
|
373
|
-
import
|
|
374
|
-
|
|
375
|
-
|
|
435
|
+
// src/commands/run.ts
|
|
436
|
+
import chalk6 from "chalk";
|
|
437
|
+
import ora3 from "ora";
|
|
438
|
+
import { select as select3 } from "@inquirer/prompts";
|
|
439
|
+
|
|
440
|
+
// ../../packages/types/src/telemetry/index.ts
|
|
441
|
+
var TelemetryEventTypes = {
|
|
442
|
+
// Agent activity events (shown in web UI activity log)
|
|
443
|
+
AGENT_CONNECTED: "agent.connected",
|
|
444
|
+
AGENT_DISCONNECTED: "agent.disconnected",
|
|
445
|
+
AGENT_MESSAGE_PROCESSING: "agent.message_processing",
|
|
446
|
+
AGENT_MESSAGE_DONE: "agent.message_done",
|
|
447
|
+
AGENT_MESSAGE_FAILED: "agent.message_failed"
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// ../../packages/types/src/tunnel/index.ts
|
|
451
|
+
var MAX_FRAME_BYTES = 256 * 1024;
|
|
376
452
|
|
|
377
453
|
// src/lib/telemetry.ts
|
|
378
454
|
var CLI_VERSION = process.env.npm_package_version || "unknown";
|
|
@@ -388,7 +464,7 @@ function logEvent(eventType, options = {}) {
|
|
|
388
464
|
severity: options.severity || "info",
|
|
389
465
|
message: options.message,
|
|
390
466
|
metadata: options.metadata,
|
|
391
|
-
|
|
467
|
+
agent_id: options.agentId,
|
|
392
468
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
393
469
|
};
|
|
394
470
|
eventBuffer.push(event);
|
|
@@ -402,10 +478,10 @@ function logEvent(eventType, options = {}) {
|
|
|
402
478
|
}
|
|
403
479
|
}
|
|
404
480
|
var telemetry = {
|
|
405
|
-
debug: (eventType, message, metadata,
|
|
406
|
-
info: (eventType, message, metadata,
|
|
407
|
-
warn: (eventType, message, metadata,
|
|
408
|
-
error: (eventType, message, metadata,
|
|
481
|
+
debug: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "debug", message, metadata, agentId }),
|
|
482
|
+
info: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "info", message, metadata, agentId }),
|
|
483
|
+
warn: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "warning", message, metadata, agentId }),
|
|
484
|
+
error: (eventType, message, metadata, agentId) => logEvent(eventType, { severity: "error", message, metadata, agentId })
|
|
409
485
|
};
|
|
410
486
|
async function flushEvents() {
|
|
411
487
|
if (eventBuffer.length === 0) return;
|
|
@@ -416,25 +492,26 @@ async function flushEvents() {
|
|
|
416
492
|
flushTimeout = null;
|
|
417
493
|
}
|
|
418
494
|
try {
|
|
419
|
-
const
|
|
420
|
-
if (!
|
|
495
|
+
const credentials2 = await getToken();
|
|
496
|
+
if (!credentials2) {
|
|
421
497
|
return;
|
|
422
498
|
}
|
|
423
499
|
const apiUrl = getApiUrlConfig();
|
|
424
500
|
const controller = new AbortController();
|
|
425
501
|
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
426
502
|
try {
|
|
503
|
+
const request = {
|
|
504
|
+
events,
|
|
505
|
+
client_type: "cli",
|
|
506
|
+
client_version: CLI_VERSION
|
|
507
|
+
};
|
|
427
508
|
const response = await fetch(`${apiUrl}/telemetry/events`, {
|
|
428
509
|
method: "POST",
|
|
429
510
|
headers: {
|
|
430
511
|
"Content-Type": "application/json",
|
|
431
|
-
Authorization: `Bearer ${
|
|
512
|
+
Authorization: `Bearer ${credentials2.token}`
|
|
432
513
|
},
|
|
433
|
-
body: JSON.stringify(
|
|
434
|
-
events,
|
|
435
|
-
client_type: "cli",
|
|
436
|
-
client_version: CLI_VERSION
|
|
437
|
-
}),
|
|
514
|
+
body: JSON.stringify(request),
|
|
438
515
|
signal: controller.signal
|
|
439
516
|
});
|
|
440
517
|
if (!response.ok) {
|
|
@@ -457,6 +534,32 @@ async function shutdownTelemetry() {
|
|
|
457
534
|
}
|
|
458
535
|
await flushEvents();
|
|
459
536
|
}
|
|
537
|
+
function emitEvent(event) {
|
|
538
|
+
logEvent(event.event_type, {
|
|
539
|
+
severity: event.severity,
|
|
540
|
+
message: event.message,
|
|
541
|
+
metadata: event.metadata,
|
|
542
|
+
agentId: event.agent_id
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
function emitAgentConnected(agentId, metadata) {
|
|
546
|
+
emitEvent({
|
|
547
|
+
event_type: TelemetryEventTypes.AGENT_CONNECTED,
|
|
548
|
+
severity: "info",
|
|
549
|
+
message: "Agent CLI connected",
|
|
550
|
+
metadata,
|
|
551
|
+
agent_id: agentId
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
function emitAgentDisconnected(agentId, metadata) {
|
|
555
|
+
emitEvent({
|
|
556
|
+
event_type: TelemetryEventTypes.AGENT_DISCONNECTED,
|
|
557
|
+
severity: "info",
|
|
558
|
+
message: `Agent CLI disconnected (code: ${metadata.code})`,
|
|
559
|
+
metadata,
|
|
560
|
+
agent_id: agentId
|
|
561
|
+
});
|
|
562
|
+
}
|
|
460
563
|
var EventTypes = {
|
|
461
564
|
// Tunnel lifecycle
|
|
462
565
|
TUNNEL_STARTING: "tunnel.starting",
|
|
@@ -484,12 +587,71 @@ var EventTypes = {
|
|
|
484
587
|
CLI_ERROR: "cli.error"
|
|
485
588
|
};
|
|
486
589
|
|
|
487
|
-
// src/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
590
|
+
// src/lib/auth.ts
|
|
591
|
+
async function getAuthCredentials() {
|
|
592
|
+
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
593
|
+
if (agentKey) {
|
|
594
|
+
return { token: agentKey, authType: "agent_key" };
|
|
595
|
+
}
|
|
596
|
+
const userToken = process.env.EVIDENT_TOKEN;
|
|
597
|
+
if (userToken) {
|
|
598
|
+
return { token: userToken, authType: "bearer" };
|
|
599
|
+
}
|
|
600
|
+
const keychainCreds = await getToken();
|
|
601
|
+
if (keychainCreds) {
|
|
602
|
+
return {
|
|
603
|
+
token: keychainCreds.token,
|
|
604
|
+
authType: "bearer",
|
|
605
|
+
user: keychainCreds.user
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
function getAuthHeader(credentials2) {
|
|
611
|
+
if (credentials2.authType === "agent_key") {
|
|
612
|
+
return `SandboxKey ${credentials2.token}`;
|
|
613
|
+
}
|
|
614
|
+
return `Bearer ${credentials2.token}`;
|
|
615
|
+
}
|
|
616
|
+
function isInteractive(jsonOutput) {
|
|
617
|
+
if (jsonOutput) return false;
|
|
618
|
+
if (process.env.CI) return false;
|
|
619
|
+
if (process.env.GITHUB_ACTIONS) return false;
|
|
620
|
+
if (!process.stdin.isTTY) return false;
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/lib/opencode/health.ts
|
|
625
|
+
async function checkOpenCodeHealth(port) {
|
|
626
|
+
try {
|
|
627
|
+
const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
|
|
628
|
+
signal: AbortSignal.timeout(2e3)
|
|
629
|
+
// 2 second timeout
|
|
630
|
+
});
|
|
631
|
+
if (!response.ok) {
|
|
632
|
+
return { healthy: false, error: `HTTP ${response.status}` };
|
|
633
|
+
}
|
|
634
|
+
const data = await response.json().catch(() => ({}));
|
|
635
|
+
return { healthy: true, version: data.version };
|
|
636
|
+
} catch (error2) {
|
|
637
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
638
|
+
return { healthy: false, error: message };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
642
|
+
const startTime = Date.now();
|
|
643
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
644
|
+
const health = await checkOpenCodeHealth(port);
|
|
645
|
+
if (health.healthy) {
|
|
646
|
+
return health;
|
|
647
|
+
}
|
|
648
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
649
|
+
}
|
|
650
|
+
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/lib/opencode/process.ts
|
|
654
|
+
import { execSync, spawn } from "child_process";
|
|
493
655
|
var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
|
|
494
656
|
function getProcessCwd(pid) {
|
|
495
657
|
const platform = process.platform;
|
|
@@ -624,22 +786,6 @@ async function scanPortsForOpenCode() {
|
|
|
624
786
|
}
|
|
625
787
|
return instances;
|
|
626
788
|
}
|
|
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
789
|
async function findHealthyOpenCodeInstances() {
|
|
644
790
|
const processes = findOpenCodeProcesses();
|
|
645
791
|
const healthy = [];
|
|
@@ -655,948 +801,1557 @@ async function findHealthyOpenCodeInstances() {
|
|
|
655
801
|
}
|
|
656
802
|
return healthy;
|
|
657
803
|
}
|
|
658
|
-
function
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
804
|
+
async function startOpenCode(port) {
|
|
805
|
+
let command = "opencode";
|
|
806
|
+
let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
807
|
+
try {
|
|
808
|
+
execSync("which opencode", { stdio: "ignore" });
|
|
809
|
+
} catch {
|
|
810
|
+
command = "npx";
|
|
811
|
+
args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
666
812
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
hour12: false,
|
|
672
|
-
hour: "2-digit",
|
|
673
|
-
minute: "2-digit",
|
|
674
|
-
second: "2-digit"
|
|
813
|
+
const child = spawn(command, args, {
|
|
814
|
+
detached: true,
|
|
815
|
+
stdio: "ignore",
|
|
816
|
+
cwd: process.cwd()
|
|
675
817
|
});
|
|
676
|
-
|
|
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
|
-
}
|
|
818
|
+
return child;
|
|
780
819
|
}
|
|
781
|
-
function
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
console.log(chalk4.red(` ${error2}`));
|
|
785
|
-
if (details) {
|
|
786
|
-
console.log(chalk4.dim(` ${details}`));
|
|
820
|
+
function stopOpenCode(opencodeProcess) {
|
|
821
|
+
if (!opencodeProcess || !opencodeProcess.pid) {
|
|
822
|
+
return;
|
|
787
823
|
}
|
|
788
|
-
blank();
|
|
789
|
-
}
|
|
790
|
-
async function validateSandbox(token, sandboxId) {
|
|
791
|
-
const apiUrl = getApiUrlConfig();
|
|
792
824
|
try {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
};
|
|
825
|
+
if (process.platform === "win32") {
|
|
826
|
+
opencodeProcess.kill("SIGTERM");
|
|
827
|
+
} else {
|
|
828
|
+
process.kill(-opencodeProcess.pid, "SIGTERM");
|
|
813
829
|
}
|
|
814
|
-
|
|
815
|
-
} catch (error2) {
|
|
816
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
817
|
-
return { valid: false, error: `Failed to validate sandbox: ${message}` };
|
|
830
|
+
} catch {
|
|
818
831
|
}
|
|
819
832
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
);
|
|
833
|
+
|
|
834
|
+
// src/lib/opencode/install.ts
|
|
835
|
+
import { execSync as execSync2 } from "child_process";
|
|
836
|
+
import chalk4 from "chalk";
|
|
837
|
+
import { select } from "@inquirer/prompts";
|
|
838
|
+
var OPENCODE_INSTALL_URL = "https://opencode.ai";
|
|
839
|
+
function isOpenCodeInstalled() {
|
|
849
840
|
try {
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
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;
|
|
841
|
+
const platform = process.platform;
|
|
842
|
+
if (platform === "win32") {
|
|
843
|
+
execSync2("where opencode", { stdio: "ignore" });
|
|
882
844
|
} else {
|
|
883
|
-
|
|
884
|
-
type: "response",
|
|
885
|
-
method: request.method,
|
|
886
|
-
path: request.path,
|
|
887
|
-
status: response.status,
|
|
888
|
-
durationMs,
|
|
889
|
-
requestId
|
|
890
|
-
});
|
|
845
|
+
execSync2("which opencode", { stdio: "ignore" });
|
|
891
846
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
847
|
+
return true;
|
|
848
|
+
} catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function promptOpenCodeInstall(interactive) {
|
|
853
|
+
if (!interactive) {
|
|
854
|
+
console.log(
|
|
855
|
+
JSON.stringify({
|
|
856
|
+
status: "error",
|
|
857
|
+
error: "OpenCode is not installed",
|
|
858
|
+
install_url: OPENCODE_INSTALL_URL,
|
|
859
|
+
install_commands: {
|
|
860
|
+
npm: "npm install -g opencode-ai",
|
|
861
|
+
curl: "curl -fsSL https://opencode.ai/install.sh | sh"
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
);
|
|
865
|
+
return "exit";
|
|
866
|
+
}
|
|
867
|
+
blank();
|
|
868
|
+
console.log(chalk4.yellow("OpenCode is not installed on your system."));
|
|
869
|
+
blank();
|
|
870
|
+
console.log(chalk4.dim("OpenCode is an AI coding agent that runs locally on your machine."));
|
|
871
|
+
console.log(chalk4.dim(`Learn more at: ${chalk4.cyan(OPENCODE_INSTALL_URL)}`));
|
|
872
|
+
blank();
|
|
873
|
+
const action = await select({
|
|
874
|
+
message: "How would you like to proceed?",
|
|
875
|
+
choices: [
|
|
901
876
|
{
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
requestId
|
|
877
|
+
name: "Show installation instructions",
|
|
878
|
+
value: "instructions",
|
|
879
|
+
description: "Display commands to install OpenCode"
|
|
906
880
|
},
|
|
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
881
|
{
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
requestId
|
|
882
|
+
name: "Continue without OpenCode",
|
|
883
|
+
value: "continue",
|
|
884
|
+
description: "Connect anyway (requests will fail until OpenCode is installed)"
|
|
935
885
|
},
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
886
|
+
{
|
|
887
|
+
name: "Exit",
|
|
888
|
+
value: "exit",
|
|
889
|
+
description: "Exit and install OpenCode manually"
|
|
890
|
+
}
|
|
891
|
+
]
|
|
892
|
+
});
|
|
893
|
+
if (action === "instructions") {
|
|
894
|
+
blank();
|
|
895
|
+
console.log(chalk4.bold("Install OpenCode using one of these methods:"));
|
|
896
|
+
blank();
|
|
897
|
+
console.log(chalk4.dim(" # Option 1: Install via npm (recommended)"));
|
|
898
|
+
console.log(` ${chalk4.cyan("npm install -g opencode-ai")}`);
|
|
899
|
+
blank();
|
|
900
|
+
console.log(chalk4.dim(" # Option 2: Install via curl"));
|
|
901
|
+
console.log(` ${chalk4.cyan("curl -fsSL https://opencode.ai/install.sh | sh")}`);
|
|
902
|
+
blank();
|
|
903
|
+
console.log(chalk4.dim(`For more options, visit: ${chalk4.cyan(OPENCODE_INSTALL_URL)}`));
|
|
904
|
+
blank();
|
|
905
|
+
const afterInstall = await select({
|
|
906
|
+
message: "After installing, what would you like to do?",
|
|
907
|
+
choices: [
|
|
908
|
+
{
|
|
909
|
+
name: "I installed it - continue",
|
|
910
|
+
value: "continue",
|
|
911
|
+
description: "Proceed with the run command"
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "Exit",
|
|
915
|
+
value: "exit",
|
|
916
|
+
description: "Exit now and run the command again later"
|
|
917
|
+
}
|
|
918
|
+
]
|
|
919
|
+
});
|
|
920
|
+
if (afterInstall === "continue") {
|
|
921
|
+
if (isOpenCodeInstalled()) {
|
|
922
|
+
console.log(chalk4.green("\n\u2713 OpenCode detected!"));
|
|
923
|
+
return "installed";
|
|
924
|
+
} else {
|
|
925
|
+
console.log(chalk4.yellow("\nOpenCode still not detected in PATH."));
|
|
926
|
+
console.log(chalk4.dim("You may need to restart your terminal or add it to your PATH."));
|
|
927
|
+
const proceed = await select({
|
|
928
|
+
message: "Continue anyway?",
|
|
929
|
+
choices: [
|
|
930
|
+
{ name: "Yes, continue", value: "continue" },
|
|
931
|
+
{ name: "No, exit", value: "exit" }
|
|
932
|
+
]
|
|
933
|
+
});
|
|
934
|
+
return proceed === "continue" ? "continue" : "exit";
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return "exit";
|
|
942
938
|
}
|
|
939
|
+
return action;
|
|
943
940
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
941
|
+
|
|
942
|
+
// src/lib/opencode/session.ts
|
|
943
|
+
async function createOpenCodeSession(port) {
|
|
944
|
+
const response = await fetch(`http://localhost:${port}/session`, {
|
|
945
|
+
method: "POST",
|
|
946
|
+
headers: { "Content-Type": "application/json" },
|
|
947
|
+
body: JSON.stringify({})
|
|
949
948
|
});
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
949
|
+
if (!response.ok) {
|
|
950
|
+
throw new Error(`Failed to create session: HTTP ${response.status}`);
|
|
951
|
+
}
|
|
952
|
+
const data = await response.json();
|
|
953
|
+
return data.id;
|
|
954
|
+
}
|
|
955
|
+
async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
|
|
956
|
+
const body = {
|
|
957
|
+
parts: [{ type: "text", text: content }]
|
|
958
|
+
};
|
|
959
|
+
if (options?.agent) {
|
|
960
|
+
body.agent = options.agent;
|
|
961
|
+
}
|
|
962
|
+
if (options?.model) {
|
|
963
|
+
const slashIndex = options.model.indexOf("/");
|
|
964
|
+
if (slashIndex !== -1) {
|
|
965
|
+
body.model = {
|
|
966
|
+
providerID: options.model.substring(0, slashIndex),
|
|
967
|
+
modelID: options.model.substring(slashIndex + 1)
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
let pollDone = false;
|
|
972
|
+
const reportedQuestions = /* @__PURE__ */ new Set();
|
|
973
|
+
const reportedPermissions = /* @__PURE__ */ new Set();
|
|
974
|
+
const pollInteractive = async () => {
|
|
975
|
+
while (!pollDone) {
|
|
976
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
977
|
+
if (pollDone) break;
|
|
978
|
+
if (hooks?.onQuestion) {
|
|
979
|
+
try {
|
|
980
|
+
const res = await fetch(`http://localhost:${port}/question`);
|
|
981
|
+
if (res.ok) {
|
|
982
|
+
const questions = await res.json();
|
|
983
|
+
for (const q of questions) {
|
|
984
|
+
if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
|
|
985
|
+
reportedQuestions.add(q.id);
|
|
986
|
+
await hooks.onQuestion(q);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
} catch {
|
|
991
|
+
}
|
|
972
992
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
993
|
+
if (hooks?.onPermission) {
|
|
994
|
+
try {
|
|
995
|
+
const res = await fetch(`http://localhost:${port}/permission`);
|
|
996
|
+
if (res.ok) {
|
|
997
|
+
const permissions = await res.json();
|
|
998
|
+
for (const p of permissions) {
|
|
999
|
+
if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
|
|
1000
|
+
reportedPermissions.add(p.id);
|
|
1001
|
+
await hooks.onPermission(p);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
982
1004
|
}
|
|
1005
|
+
} catch {
|
|
983
1006
|
}
|
|
984
1007
|
}
|
|
985
1008
|
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
|
|
1009
|
+
};
|
|
1010
|
+
const sendMessage = async () => {
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
const timer = setTimeout(() => controller.abort(), maxWaitMs);
|
|
1013
|
+
try {
|
|
1014
|
+
const res = await fetch(`http://localhost:${port}/session/${sessionId}/message`, {
|
|
1015
|
+
method: "POST",
|
|
1016
|
+
headers: { "Content-Type": "application/json" },
|
|
1017
|
+
body: JSON.stringify(body),
|
|
1018
|
+
signal: controller.signal
|
|
1019
|
+
});
|
|
1020
|
+
if (!res.ok) {
|
|
1021
|
+
const text = await res.text().catch(() => "");
|
|
1022
|
+
throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1023
|
+
}
|
|
1024
|
+
const sessionRes = await fetch(`http://localhost:${port}/session/${sessionId}`).catch(
|
|
1025
|
+
() => null
|
|
1026
|
+
);
|
|
1027
|
+
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
1028
|
+
return { title: session?.title };
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1031
|
+
throw new Error("Message processing timed out");
|
|
1032
|
+
}
|
|
1033
|
+
throw err;
|
|
1034
|
+
} finally {
|
|
1035
|
+
clearTimeout(timer);
|
|
1036
|
+
pollDone = true;
|
|
989
1037
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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);
|
|
999
|
-
}
|
|
1038
|
+
};
|
|
1039
|
+
const [result] = await Promise.all([sendMessage(), pollInteractive()]);
|
|
1040
|
+
return result;
|
|
1000
1041
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1042
|
+
|
|
1043
|
+
// src/lib/tunnel/connection.ts
|
|
1044
|
+
import WebSocket2 from "ws";
|
|
1045
|
+
|
|
1046
|
+
// src/lib/tunnel/forwarding.ts
|
|
1047
|
+
import WebSocket from "ws";
|
|
1048
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
1049
|
+
var STRIP_REQ = /* @__PURE__ */ new Set([
|
|
1050
|
+
"host",
|
|
1051
|
+
"connection",
|
|
1052
|
+
"keep-alive",
|
|
1053
|
+
"proxy-authorization",
|
|
1054
|
+
"transfer-encoding",
|
|
1055
|
+
"upgrade",
|
|
1056
|
+
"content-length"
|
|
1057
|
+
]);
|
|
1058
|
+
var STRIP_RES = /* @__PURE__ */ new Set([
|
|
1059
|
+
"connection",
|
|
1060
|
+
"keep-alive",
|
|
1061
|
+
"transfer-encoding",
|
|
1062
|
+
"content-encoding",
|
|
1063
|
+
"content-length"
|
|
1064
|
+
]);
|
|
1065
|
+
var StreamForwarder = class {
|
|
1066
|
+
constructor(ws, port, callbacks = {}) {
|
|
1067
|
+
this.ws = ws;
|
|
1068
|
+
this.port = port;
|
|
1069
|
+
this.callbacks = callbacks;
|
|
1006
1070
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1071
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1072
|
+
/**
|
|
1073
|
+
* Handle an edge→agent frame. Unknown frame types are ignored.
|
|
1074
|
+
*/
|
|
1075
|
+
handleFrame(frame) {
|
|
1076
|
+
switch (frame.type) {
|
|
1077
|
+
case "open":
|
|
1078
|
+
this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
|
|
1079
|
+
void this.handleOpen(frame);
|
|
1080
|
+
break;
|
|
1081
|
+
case "req_data":
|
|
1082
|
+
this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
|
|
1083
|
+
break;
|
|
1084
|
+
case "req_end":
|
|
1085
|
+
this.inflight.get(frame.sid)?.endBody?.();
|
|
1086
|
+
break;
|
|
1087
|
+
case "abort":
|
|
1088
|
+
this.inflight.get(frame.sid)?.abort?.();
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1020
1091
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
total_chunks: chunks.length,
|
|
1030
|
-
total_size: bodyBytes.length,
|
|
1031
|
-
payload: {
|
|
1032
|
-
status: response.status,
|
|
1033
|
-
headers: response.headers
|
|
1092
|
+
/**
|
|
1093
|
+
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1094
|
+
*/
|
|
1095
|
+
abortAll() {
|
|
1096
|
+
for (const stream of this.inflight.values()) {
|
|
1097
|
+
try {
|
|
1098
|
+
stream.abort();
|
|
1099
|
+
} catch {
|
|
1034
1100
|
}
|
|
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
|
-
);
|
|
1101
|
+
}
|
|
1102
|
+
this.inflight.clear();
|
|
1046
1103
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
})
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
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));
|
|
1104
|
+
send(frame) {
|
|
1105
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
1106
|
+
this.ws.send(JSON.stringify(frame));
|
|
1107
|
+
}
|
|
1058
1108
|
}
|
|
1059
|
-
|
|
1060
|
-
}
|
|
1109
|
+
async handleOpen(frame) {
|
|
1110
|
+
const { sid, method, path, headers, has_body } = frame;
|
|
1111
|
+
const ac = new AbortController();
|
|
1112
|
+
let bodyPromise;
|
|
1113
|
+
let pushBody;
|
|
1114
|
+
let endBody;
|
|
1115
|
+
if (has_body) {
|
|
1116
|
+
const chunks = [];
|
|
1117
|
+
bodyPromise = new Promise((resolve) => {
|
|
1118
|
+
pushBody = (buf) => {
|
|
1119
|
+
chunks.push(buf);
|
|
1120
|
+
};
|
|
1121
|
+
endBody = () => {
|
|
1122
|
+
resolve(Buffer.concat(chunks));
|
|
1123
|
+
};
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const fwdHeaders = {};
|
|
1127
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
1128
|
+
if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
|
|
1129
|
+
}
|
|
1130
|
+
this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
|
|
1131
|
+
const body = bodyPromise ? await bodyPromise : void 0;
|
|
1132
|
+
if (ac.signal.aborted) {
|
|
1133
|
+
this.inflight.delete(sid);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
let upstream;
|
|
1137
|
+
try {
|
|
1138
|
+
upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
|
|
1139
|
+
method,
|
|
1140
|
+
headers: fwdHeaders,
|
|
1141
|
+
body,
|
|
1142
|
+
redirect: "manual",
|
|
1143
|
+
signal: ac.signal
|
|
1144
|
+
});
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
this.inflight.delete(sid);
|
|
1147
|
+
if (!ac.signal.aborted) {
|
|
1148
|
+
this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const resHeaders = {};
|
|
1153
|
+
upstream.headers.forEach((value, key) => {
|
|
1154
|
+
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1155
|
+
});
|
|
1156
|
+
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1157
|
+
this.callbacks.onHead?.(sid, upstream.status);
|
|
1158
|
+
try {
|
|
1159
|
+
if (upstream.body) {
|
|
1160
|
+
const reader = upstream.body.getReader();
|
|
1161
|
+
while (true) {
|
|
1162
|
+
const { done, value } = await reader.read();
|
|
1163
|
+
if (done) break;
|
|
1164
|
+
const chunk = Buffer.from(value);
|
|
1165
|
+
for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
|
|
1166
|
+
const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
|
|
1167
|
+
this.send({ type: "res_data", sid, b64: slice.toString("base64") });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
this.send({ type: "res_end", sid });
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (!ac.signal.aborted) {
|
|
1174
|
+
this.send({ type: "res_err", sid, message: String(err) });
|
|
1175
|
+
}
|
|
1176
|
+
} finally {
|
|
1177
|
+
this.inflight.delete(sid);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
// src/lib/tunnel/connection.ts
|
|
1183
|
+
var MAX_RECONNECT_DELAY = 3e4;
|
|
1184
|
+
var BASE_RECONNECT_DELAY = 500;
|
|
1061
1185
|
function getReconnectDelay(attempt) {
|
|
1062
1186
|
const exponentialDelay = BASE_RECONNECT_DELAY * Math.pow(2, attempt);
|
|
1063
1187
|
const jitter = Math.random() * 1e3;
|
|
1064
1188
|
return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
|
|
1065
1189
|
}
|
|
1066
|
-
|
|
1190
|
+
function describeSocketError(error2, url) {
|
|
1191
|
+
const code = error2.code;
|
|
1192
|
+
switch (code) {
|
|
1193
|
+
case "ECONNREFUSED":
|
|
1194
|
+
return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
|
|
1195
|
+
case "ENOTFOUND":
|
|
1196
|
+
return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
|
|
1197
|
+
case "ETIMEDOUT":
|
|
1198
|
+
return `connection timed out to ${url} (ETIMEDOUT)`;
|
|
1199
|
+
case "ECONNRESET":
|
|
1200
|
+
return `connection reset by ${url} (ECONNRESET)`;
|
|
1201
|
+
default: {
|
|
1202
|
+
const base = error2.message?.trim();
|
|
1203
|
+
const suffix = code ? ` (${code})` : "";
|
|
1204
|
+
return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
|
|
1209
|
+
"open",
|
|
1210
|
+
"req_data",
|
|
1211
|
+
"req_end",
|
|
1212
|
+
"abort"
|
|
1213
|
+
]);
|
|
1214
|
+
function isStreamFrame(message) {
|
|
1215
|
+
return STREAM_FRAME_TYPES.has(message.type);
|
|
1216
|
+
}
|
|
1217
|
+
function connectTunnel(options) {
|
|
1218
|
+
const {
|
|
1219
|
+
agentId,
|
|
1220
|
+
authHeader,
|
|
1221
|
+
port,
|
|
1222
|
+
onConnected,
|
|
1223
|
+
onDisconnected,
|
|
1224
|
+
onError,
|
|
1225
|
+
onRequest,
|
|
1226
|
+
onResponse,
|
|
1227
|
+
onInfo
|
|
1228
|
+
} = options;
|
|
1067
1229
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1068
|
-
const url = `${tunnelUrl}/tunnel/${
|
|
1069
|
-
logActivity(state, {
|
|
1070
|
-
type: "info",
|
|
1071
|
-
message: "Connecting to tunnel relay..."
|
|
1072
|
-
});
|
|
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
|
-
);
|
|
1230
|
+
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1084
1231
|
return new Promise((resolve, reject) => {
|
|
1085
|
-
const ws = new
|
|
1232
|
+
const ws = new WebSocket2(url, {
|
|
1086
1233
|
headers: {
|
|
1087
|
-
Authorization:
|
|
1234
|
+
Authorization: authHeader
|
|
1088
1235
|
}
|
|
1089
1236
|
});
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1237
|
+
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1238
|
+
const forwarder = new StreamForwarder(ws, port, {
|
|
1239
|
+
onOpen: (sid, method, path) => {
|
|
1240
|
+
streamStartTimes.set(sid, Date.now());
|
|
1241
|
+
onRequest?.(method, path, sid);
|
|
1242
|
+
},
|
|
1243
|
+
onHead: (sid, status) => {
|
|
1244
|
+
const startedAt = streamStartTimes.get(sid);
|
|
1245
|
+
streamStartTimes.delete(sid);
|
|
1246
|
+
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
const connectionTimeout = setTimeout(() => {
|
|
1250
|
+
ws.close();
|
|
1251
|
+
reject(new Error("Connection timeout"));
|
|
1252
|
+
}, 3e4);
|
|
1253
|
+
let upgradeRejection = null;
|
|
1254
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
1255
|
+
clearTimeout(connectionTimeout);
|
|
1256
|
+
const chunks = [];
|
|
1257
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
1258
|
+
res.on("end", () => {
|
|
1259
|
+
const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
|
|
1260
|
+
let detail = bodyRaw;
|
|
1261
|
+
try {
|
|
1262
|
+
const parsed = JSON.parse(bodyRaw);
|
|
1263
|
+
detail = parsed.error ?? parsed.message ?? bodyRaw;
|
|
1264
|
+
if (parsed.details) detail += ` (${parsed.details})`;
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1267
|
+
const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
|
|
1268
|
+
upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
|
|
1269
|
+
onError?.(`Tunnel refused by relay (${upgradeRejection})`);
|
|
1270
|
+
reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
|
|
1096
1271
|
});
|
|
1097
|
-
displayStatus(state);
|
|
1098
1272
|
});
|
|
1099
|
-
ws.on("
|
|
1273
|
+
ws.on("open", () => {
|
|
1274
|
+
onInfo?.("WebSocket connection established");
|
|
1275
|
+
});
|
|
1276
|
+
ws.on("message", (data) => {
|
|
1277
|
+
let message;
|
|
1100
1278
|
try {
|
|
1101
|
-
|
|
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);
|
|
1161
|
-
}
|
|
1162
|
-
break;
|
|
1163
|
-
case "unsubscribe_events":
|
|
1164
|
-
if (message.id) {
|
|
1165
|
-
cancelEventSubscription(message.id, state);
|
|
1166
|
-
}
|
|
1167
|
-
break;
|
|
1168
|
-
}
|
|
1279
|
+
message = JSON.parse(data.toString());
|
|
1169
1280
|
} catch (error2) {
|
|
1170
1281
|
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1282
|
+
onError?.(`Failed to handle message: ${errorMessage}`);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (isStreamFrame(message)) {
|
|
1286
|
+
forwarder.handleFrame(message);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
switch (message.type) {
|
|
1290
|
+
case "connected": {
|
|
1291
|
+
clearTimeout(connectionTimeout);
|
|
1292
|
+
const connectedAgentId = message.agent_id ?? agentId;
|
|
1293
|
+
onConnected?.(connectedAgentId);
|
|
1294
|
+
resolve({
|
|
1295
|
+
ws,
|
|
1296
|
+
close: () => ws.close(1e3, "CLI shutdown")
|
|
1297
|
+
});
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
case "error":
|
|
1301
|
+
clearTimeout(connectionTimeout);
|
|
1302
|
+
onError?.(message.message || "Unknown tunnel error");
|
|
1303
|
+
if (message.code === "unauthorized") {
|
|
1304
|
+
ws.close();
|
|
1305
|
+
reject(new Error("Unauthorized"));
|
|
1306
|
+
}
|
|
1307
|
+
break;
|
|
1308
|
+
case "ping":
|
|
1309
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
ws.on("error", (error2) => {
|
|
1314
|
+
clearTimeout(connectionTimeout);
|
|
1315
|
+
const detail = upgradeRejection ?? describeSocketError(error2, url);
|
|
1316
|
+
onError?.(`Connection error: ${detail}`);
|
|
1317
|
+
reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
|
|
1318
|
+
});
|
|
1319
|
+
ws.on("close", (code, reason) => {
|
|
1320
|
+
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1321
|
+
forwarder.abortAll();
|
|
1322
|
+
streamStartTimes.clear();
|
|
1323
|
+
onDisconnected?.(code, reasonStr);
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/lib/tunnel/runner-connection.ts
|
|
1329
|
+
var RunnerConnection = class {
|
|
1330
|
+
opts;
|
|
1331
|
+
sleep;
|
|
1332
|
+
connection = null;
|
|
1333
|
+
resolvedAgentId;
|
|
1334
|
+
/** True while a (re)connect loop is in flight. */
|
|
1335
|
+
reconnecting = false;
|
|
1336
|
+
/** The in-flight reconnect promise, awaitable by the caller. */
|
|
1337
|
+
reconnectPromise = null;
|
|
1338
|
+
/** 1-based count of the current reconnect attempt streak. */
|
|
1339
|
+
reconnectAttempt = 0;
|
|
1340
|
+
constructor(opts) {
|
|
1341
|
+
this.opts = opts;
|
|
1342
|
+
this.resolvedAgentId = opts.agentId;
|
|
1343
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1344
|
+
}
|
|
1345
|
+
get agentId() {
|
|
1346
|
+
return this.resolvedAgentId;
|
|
1347
|
+
}
|
|
1348
|
+
/** Establish the initial tunnel connection (with retry/backoff). */
|
|
1349
|
+
async connect() {
|
|
1350
|
+
await this.connectWithRetry(false);
|
|
1351
|
+
}
|
|
1352
|
+
/** Close the active connection (idempotent). */
|
|
1353
|
+
close() {
|
|
1354
|
+
if (this.connection) {
|
|
1355
|
+
try {
|
|
1356
|
+
this.connection.close();
|
|
1357
|
+
} catch {
|
|
1358
|
+
}
|
|
1359
|
+
this.connection = null;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
async connectWithRetry(isReconnect) {
|
|
1363
|
+
if (isReconnect && this.reconnecting) return;
|
|
1364
|
+
this.reconnecting = true;
|
|
1365
|
+
this.close();
|
|
1366
|
+
const { events } = this.opts;
|
|
1367
|
+
while (this.opts.isRunning()) {
|
|
1368
|
+
try {
|
|
1369
|
+
this.connection = await connectTunnel({
|
|
1370
|
+
agentId: this.resolvedAgentId,
|
|
1371
|
+
authHeader: this.opts.getAuthHeader(),
|
|
1372
|
+
port: this.opts.port,
|
|
1373
|
+
onConnected: (agentId) => {
|
|
1374
|
+
this.reconnectAttempt = 0;
|
|
1375
|
+
this.reconnecting = false;
|
|
1376
|
+
this.resolvedAgentId = agentId;
|
|
1377
|
+
events.onConnected(agentId, isReconnect);
|
|
1378
|
+
},
|
|
1379
|
+
onDisconnected: (code, reason) => {
|
|
1380
|
+
events.onDisconnected(code, reason);
|
|
1381
|
+
if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
|
|
1382
|
+
this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
|
|
1383
|
+
events.onError?.(`Reconnection failed: ${err.message}`);
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
onError: (error2) => events.onError?.(error2),
|
|
1388
|
+
onResponse: () => events.onResponse?.(),
|
|
1389
|
+
onInfo: (message) => events.onInfo?.(message)
|
|
1390
|
+
});
|
|
1391
|
+
return;
|
|
1392
|
+
} catch (error2) {
|
|
1393
|
+
this.reconnectAttempt++;
|
|
1394
|
+
if (error2.message === "Unauthorized") {
|
|
1395
|
+
this.reconnecting = false;
|
|
1396
|
+
throw error2;
|
|
1397
|
+
}
|
|
1398
|
+
const delay = getReconnectDelay(this.reconnectAttempt);
|
|
1399
|
+
events.onReconnecting?.(this.reconnectAttempt);
|
|
1400
|
+
events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
|
|
1401
|
+
await this.sleep(delay);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
this.reconnecting = false;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
// src/lib/channels/driver.ts
|
|
1409
|
+
var DEFAULT_RETRY_POLICY = {
|
|
1410
|
+
maxAttempts: 6,
|
|
1411
|
+
baseDelayMs: 500,
|
|
1412
|
+
maxDelayMs: 3e4
|
|
1413
|
+
};
|
|
1414
|
+
var ChannelAuthError = class extends Error {
|
|
1415
|
+
constructor(message) {
|
|
1416
|
+
super(message);
|
|
1417
|
+
this.name = "ChannelAuthError";
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
function backoffDelay(attempt, policy) {
|
|
1421
|
+
const exp = policy.baseDelayMs * Math.pow(2, attempt);
|
|
1422
|
+
const capped = Math.min(policy.maxDelayMs, exp);
|
|
1423
|
+
return Math.floor(Math.random() * capped);
|
|
1424
|
+
}
|
|
1425
|
+
function isRetryableStatus(status) {
|
|
1426
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
1427
|
+
}
|
|
1428
|
+
var ChannelDriver = class {
|
|
1429
|
+
agentId;
|
|
1430
|
+
port;
|
|
1431
|
+
apiUrl;
|
|
1432
|
+
getAuthHeader;
|
|
1433
|
+
conversationFilter;
|
|
1434
|
+
retry;
|
|
1435
|
+
log;
|
|
1436
|
+
fetchImpl;
|
|
1437
|
+
sleep;
|
|
1438
|
+
/** Cache of conversationId → opencode sessionId. */
|
|
1439
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1440
|
+
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1441
|
+
draining = false;
|
|
1442
|
+
constructor(config2) {
|
|
1443
|
+
this.agentId = config2.agentId;
|
|
1444
|
+
this.port = config2.port;
|
|
1445
|
+
this.apiUrl = config2.apiUrl.replace(/\/$/, "");
|
|
1446
|
+
this.getAuthHeader = config2.getAuthHeader;
|
|
1447
|
+
this.conversationFilter = config2.conversationFilter ?? null;
|
|
1448
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
|
|
1449
|
+
this.log = config2.log ?? (() => {
|
|
1450
|
+
});
|
|
1451
|
+
this.fetchImpl = config2.fetchImpl ?? fetch;
|
|
1452
|
+
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1453
|
+
}
|
|
1454
|
+
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1455
|
+
get opencodeBase() {
|
|
1456
|
+
return `http://127.0.0.1:${this.port}`;
|
|
1457
|
+
}
|
|
1458
|
+
// -------------------------------------------------------------------------
|
|
1459
|
+
// Public API
|
|
1460
|
+
// -------------------------------------------------------------------------
|
|
1461
|
+
/**
|
|
1462
|
+
* Drain all pending channel conversations once: poll → process → callback.
|
|
1463
|
+
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
1464
|
+
* Re-entrant calls while a drain is in flight are skipped (return 0).
|
|
1465
|
+
*
|
|
1466
|
+
* @returns the number of messages processed.
|
|
1467
|
+
*/
|
|
1468
|
+
async drainPending() {
|
|
1469
|
+
if (this.draining) return 0;
|
|
1470
|
+
this.draining = true;
|
|
1471
|
+
let processed = 0;
|
|
1472
|
+
try {
|
|
1473
|
+
const conversations = await this.getPendingConversations();
|
|
1474
|
+
for (const conv of conversations) {
|
|
1475
|
+
processed += await this.processConversation(conv);
|
|
1476
|
+
}
|
|
1477
|
+
} finally {
|
|
1478
|
+
this.draining = false;
|
|
1479
|
+
}
|
|
1480
|
+
return processed;
|
|
1481
|
+
}
|
|
1482
|
+
// -------------------------------------------------------------------------
|
|
1483
|
+
// Conversation processing
|
|
1484
|
+
// -------------------------------------------------------------------------
|
|
1485
|
+
async processConversation(conv) {
|
|
1486
|
+
const sessionId = await this.ensureSession(conv);
|
|
1487
|
+
const messages = await this.getPendingMessages(conv.id);
|
|
1488
|
+
let processed = 0;
|
|
1489
|
+
for (const message of messages) {
|
|
1490
|
+
const claimed = await this.markProcessing(conv.id, message.id);
|
|
1491
|
+
if (!claimed) {
|
|
1492
|
+
this.log({
|
|
1493
|
+
level: "info",
|
|
1494
|
+
message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
|
|
1495
|
+
conversation_id: conv.id,
|
|
1496
|
+
message_id: message.id
|
|
1174
1497
|
});
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
try {
|
|
1501
|
+
await sendMessageToOpenCode(
|
|
1502
|
+
this.port,
|
|
1503
|
+
sessionId,
|
|
1504
|
+
message.content,
|
|
1178
1505
|
{
|
|
1179
|
-
|
|
1506
|
+
agent: message.opencode_agent ?? void 0,
|
|
1507
|
+
model: message.opencode_model ?? void 0
|
|
1180
1508
|
},
|
|
1181
|
-
|
|
1509
|
+
{
|
|
1510
|
+
onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
|
|
1511
|
+
onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
|
|
1512
|
+
}
|
|
1182
1513
|
);
|
|
1183
|
-
|
|
1514
|
+
await this.confirmCompletion(sessionId);
|
|
1515
|
+
await this.markDone(conv.id, message.id, sessionId);
|
|
1516
|
+
processed += 1;
|
|
1517
|
+
this.log({
|
|
1518
|
+
level: "info",
|
|
1519
|
+
message: `Message ${message.id.slice(0, 8)} processed`,
|
|
1520
|
+
conversation_id: conv.id,
|
|
1521
|
+
message_id: message.id
|
|
1522
|
+
});
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1525
|
+
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1526
|
+
});
|
|
1527
|
+
this.log({
|
|
1528
|
+
level: "error",
|
|
1529
|
+
message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1530
|
+
conversation_id: conv.id,
|
|
1531
|
+
message_id: message.id
|
|
1532
|
+
});
|
|
1184
1533
|
}
|
|
1534
|
+
}
|
|
1535
|
+
return processed;
|
|
1536
|
+
}
|
|
1537
|
+
async ensureSession(conv) {
|
|
1538
|
+
const cached = this.sessions.get(conv.id);
|
|
1539
|
+
if (cached) return cached;
|
|
1540
|
+
if (conv.opencode_session_id) {
|
|
1541
|
+
this.sessions.set(conv.id, conv.opencode_session_id);
|
|
1542
|
+
return conv.opencode_session_id;
|
|
1543
|
+
}
|
|
1544
|
+
const sessionId = await createOpenCodeSession(this.port);
|
|
1545
|
+
this.sessions.set(conv.id, sessionId);
|
|
1546
|
+
await this.persistSession(conv.id, sessionId).catch(() => {
|
|
1185
1547
|
});
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1548
|
+
return sessionId;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Local reconcile: re-query `GET /session/:id` and check `time.completed`.
|
|
1552
|
+
* Best-effort — if opencode is unreachable or the field is absent we proceed
|
|
1553
|
+
* to mark done anyway (the blocking call already returned).
|
|
1554
|
+
*/
|
|
1555
|
+
async confirmCompletion(sessionId) {
|
|
1556
|
+
try {
|
|
1557
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
1558
|
+
if (!res.ok) return;
|
|
1559
|
+
const session = await res.json();
|
|
1560
|
+
if (session.time && session.time.completed == null) {
|
|
1561
|
+
this.log({
|
|
1562
|
+
level: "info",
|
|
1563
|
+
message: `Session ${sessionId.slice(0, 8)} not marked completed on reconcile \u2014 delivering anyway`
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
// -------------------------------------------------------------------------
|
|
1570
|
+
// Evident API calls (combinedAuth thread routes)
|
|
1571
|
+
// -------------------------------------------------------------------------
|
|
1572
|
+
async getPendingConversations() {
|
|
1573
|
+
const res = await this.fetchImpl(
|
|
1574
|
+
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
1575
|
+
{
|
|
1576
|
+
headers: { Authorization: this.getAuthHeader() }
|
|
1577
|
+
}
|
|
1578
|
+
);
|
|
1579
|
+
this.assertAuth(res, "fetching pending conversations");
|
|
1580
|
+
if (!res.ok) {
|
|
1581
|
+
throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
|
|
1582
|
+
}
|
|
1583
|
+
const data = await res.json();
|
|
1584
|
+
let conversations = data.conversations;
|
|
1585
|
+
if (this.conversationFilter) {
|
|
1586
|
+
conversations = conversations.filter((c) => c.id === this.conversationFilter);
|
|
1587
|
+
}
|
|
1588
|
+
return conversations;
|
|
1589
|
+
}
|
|
1590
|
+
async getPendingMessages(conversationId) {
|
|
1591
|
+
const res = await this.fetchImpl(
|
|
1592
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
1593
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
1594
|
+
);
|
|
1595
|
+
this.assertAuth(res, "fetching pending messages");
|
|
1596
|
+
if (!res.ok) {
|
|
1597
|
+
throw new Error(`Failed to get messages: HTTP ${res.status}`);
|
|
1598
|
+
}
|
|
1599
|
+
return await res.json();
|
|
1600
|
+
}
|
|
1601
|
+
async markProcessing(conversationId, messageId) {
|
|
1602
|
+
const res = await this.fetchImpl(
|
|
1603
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1604
|
+
{
|
|
1605
|
+
method: "PATCH",
|
|
1606
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1607
|
+
body: JSON.stringify({ status: "processing" })
|
|
1608
|
+
}
|
|
1609
|
+
);
|
|
1610
|
+
this.assertAuth(res, "marking message as processing");
|
|
1611
|
+
return res.ok;
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
|
|
1615
|
+
* `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
1616
|
+
* `queued_conversation_messages.status`/`processed_at` gate makes a re-call
|
|
1617
|
+
* for an already-`done` message a no-op (no double Slack post).
|
|
1618
|
+
*/
|
|
1619
|
+
async markDone(conversationId, messageId, sessionId) {
|
|
1620
|
+
await this.callWithRetry(
|
|
1621
|
+
"marking message as done",
|
|
1622
|
+
() => this.fetchImpl(
|
|
1623
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1196
1624
|
{
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
type: "error",
|
|
1210
|
-
error: `Connection error: ${error2.message}`
|
|
1211
|
-
});
|
|
1212
|
-
telemetry.error(
|
|
1213
|
-
EventTypes.TUNNEL_ERROR,
|
|
1214
|
-
`Connection error: ${error2.message}`,
|
|
1625
|
+
method: "PATCH",
|
|
1626
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1627
|
+
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
1628
|
+
}
|
|
1629
|
+
)
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
async markFailed(conversationId, messageId) {
|
|
1633
|
+
await this.callWithRetry(
|
|
1634
|
+
"marking message as failed",
|
|
1635
|
+
() => this.fetchImpl(
|
|
1636
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1215
1637
|
{
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1638
|
+
method: "PATCH",
|
|
1639
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1640
|
+
body: JSON.stringify({ status: "failed" })
|
|
1641
|
+
}
|
|
1642
|
+
)
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
async persistSession(conversationId, sessionId) {
|
|
1646
|
+
const res = await this.fetchImpl(
|
|
1647
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
|
|
1648
|
+
{
|
|
1649
|
+
method: "PATCH",
|
|
1650
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1651
|
+
body: JSON.stringify({ opencode_session_id: sessionId })
|
|
1652
|
+
}
|
|
1653
|
+
);
|
|
1654
|
+
this.assertAuth(res, "persisting session id");
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
1658
|
+
* `POST .../interactive-event {type, data}`. The server persists the
|
|
1659
|
+
* interaction and posts a link to the proxied opencode-web conversation.
|
|
1660
|
+
*/
|
|
1661
|
+
async reportInteraction(conversationId, type, data) {
|
|
1662
|
+
try {
|
|
1663
|
+
await this.callWithRetry(
|
|
1664
|
+
"reporting interactive event",
|
|
1665
|
+
() => this.fetchImpl(
|
|
1666
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
1667
|
+
{
|
|
1668
|
+
method: "POST",
|
|
1669
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1670
|
+
body: JSON.stringify({ type, data })
|
|
1671
|
+
}
|
|
1672
|
+
)
|
|
1219
1673
|
);
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
process.removeAllListeners("SIGTERM");
|
|
1225
|
-
logActivity(state, {
|
|
1226
|
-
type: "info",
|
|
1227
|
-
message: "Shutting down..."
|
|
1674
|
+
this.log({
|
|
1675
|
+
level: "info",
|
|
1676
|
+
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
1677
|
+
conversation_id: conversationId
|
|
1228
1678
|
});
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
{
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1681
|
+
this.log({
|
|
1682
|
+
level: "error",
|
|
1683
|
+
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1684
|
+
conversation_id: conversationId
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
// -------------------------------------------------------------------------
|
|
1689
|
+
// Retry wrapper
|
|
1690
|
+
// -------------------------------------------------------------------------
|
|
1691
|
+
/**
|
|
1692
|
+
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
1693
|
+
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
1694
|
+
* (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
|
|
1695
|
+
* terminal too. No on-disk persistence — a crash mid-retry drops the callback
|
|
1696
|
+
* (accepted by ADR-0039).
|
|
1697
|
+
*/
|
|
1698
|
+
async callWithRetry(context, call) {
|
|
1699
|
+
let lastError;
|
|
1700
|
+
for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
|
|
1701
|
+
let res;
|
|
1702
|
+
try {
|
|
1703
|
+
res = await call();
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
lastError = err;
|
|
1706
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
1707
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
throw err;
|
|
1711
|
+
}
|
|
1712
|
+
if (res.status === 401 || res.status === 403) {
|
|
1713
|
+
throw new ChannelAuthError(
|
|
1714
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
if (res.ok) return;
|
|
1718
|
+
if (isRetryableStatus(res.status)) {
|
|
1719
|
+
lastError = new Error(`${context}: HTTP ${res.status}`);
|
|
1720
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
1721
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
throw new Error(`${context}: HTTP ${res.status}`);
|
|
1726
|
+
}
|
|
1727
|
+
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
1728
|
+
}
|
|
1729
|
+
assertAuth(res, context) {
|
|
1730
|
+
if (res.status === 401 || res.status === 403) {
|
|
1731
|
+
throw new ChannelAuthError(
|
|
1732
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
1237
1733
|
);
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
// src/commands/ensure-opencode.ts
|
|
1739
|
+
import chalk5 from "chalk";
|
|
1740
|
+
import ora2 from "ora";
|
|
1741
|
+
import { select as select2 } from "@inquirer/prompts";
|
|
1742
|
+
async function ensureOpenCodeRunning(ctx) {
|
|
1743
|
+
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
1744
|
+
if (healthCheck.healthy) {
|
|
1745
|
+
return { port: ctx.port, process: null, version: healthCheck.version ?? null };
|
|
1746
|
+
}
|
|
1747
|
+
const runningInstances = await findHealthyOpenCodeInstances();
|
|
1748
|
+
if (runningInstances.length > 0) {
|
|
1749
|
+
if (!ctx.interactive) {
|
|
1750
|
+
throw new Error(
|
|
1751
|
+
`OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
blank();
|
|
1755
|
+
console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
|
|
1756
|
+
for (const instance of runningInstances) {
|
|
1757
|
+
const ver = instance.version ? ` (v${instance.version})` : "";
|
|
1758
|
+
const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
|
|
1759
|
+
console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
|
|
1760
|
+
}
|
|
1761
|
+
blank();
|
|
1762
|
+
if (runningInstances.length === 1) {
|
|
1763
|
+
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
1764
|
+
console.log(
|
|
1765
|
+
chalk5.dim(
|
|
1766
|
+
` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
1767
|
+
)
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
blank();
|
|
1771
|
+
throw new Error(`OpenCode not running on port ${ctx.port}`);
|
|
1772
|
+
}
|
|
1773
|
+
if (!isOpenCodeInstalled()) {
|
|
1774
|
+
if (!ctx.interactive) {
|
|
1775
|
+
throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
|
|
1776
|
+
}
|
|
1777
|
+
const result = await promptOpenCodeInstall(true);
|
|
1778
|
+
if (result === "exit") process.exit(0);
|
|
1779
|
+
if (result !== "installed" && !isOpenCodeInstalled()) {
|
|
1780
|
+
throw new Error("OpenCode is not installed");
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (!ctx.interactive) {
|
|
1784
|
+
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
1785
|
+
const proc = await startOpenCode(ctx.port);
|
|
1786
|
+
const health = await waitForOpenCodeHealth(ctx.port, 3e4);
|
|
1787
|
+
if (!health.healthy) {
|
|
1788
|
+
throw new Error(
|
|
1789
|
+
`OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
|
|
1793
|
+
return { port: ctx.port, process: proc, version: health.version ?? null };
|
|
1794
|
+
}
|
|
1795
|
+
let port = ctx.port;
|
|
1796
|
+
if (isPortInUse(port)) {
|
|
1797
|
+
console.log(chalk5.yellow(`
|
|
1798
|
+
Port ${port} is already in use.`));
|
|
1799
|
+
const alternativePort = findAvailablePort(port + 1);
|
|
1800
|
+
if (alternativePort) {
|
|
1801
|
+
const useAlternative = await select2({
|
|
1802
|
+
message: `Use port ${alternativePort} instead?`,
|
|
1803
|
+
choices: [
|
|
1804
|
+
{ name: `Yes, use port ${alternativePort}`, value: "yes" },
|
|
1805
|
+
{ name: "No, I will free the port manually", value: "no" }
|
|
1806
|
+
]
|
|
1807
|
+
});
|
|
1808
|
+
if (useAlternative === "yes") {
|
|
1809
|
+
port = alternativePort;
|
|
1810
|
+
} else {
|
|
1811
|
+
throw new Error(`Port ${ctx.port} is in use`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const action = await select2({
|
|
1816
|
+
message: "OpenCode is not running. What would you like to do?",
|
|
1817
|
+
choices: [
|
|
1818
|
+
{
|
|
1819
|
+
name: "Start OpenCode for me",
|
|
1820
|
+
value: "start",
|
|
1821
|
+
description: `Run 'opencode serve --port ${port}'`
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
name: "Show me the command",
|
|
1825
|
+
value: "manual",
|
|
1826
|
+
description: "Display the command to run manually"
|
|
1827
|
+
},
|
|
1828
|
+
{
|
|
1829
|
+
name: "Continue without OpenCode",
|
|
1830
|
+
value: "continue",
|
|
1831
|
+
description: "Requests will fail until OpenCode starts"
|
|
1832
|
+
}
|
|
1833
|
+
]
|
|
1246
1834
|
});
|
|
1835
|
+
if (action === "manual") {
|
|
1836
|
+
blank();
|
|
1837
|
+
console.log(chalk5.bold("Run this command in another terminal:"));
|
|
1838
|
+
blank();
|
|
1839
|
+
console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
|
|
1840
|
+
blank();
|
|
1841
|
+
throw new Error("Please start OpenCode manually");
|
|
1842
|
+
}
|
|
1843
|
+
if (action === "start") {
|
|
1844
|
+
const spinner = ora2("Starting OpenCode...").start();
|
|
1845
|
+
const proc = await startOpenCode(port);
|
|
1846
|
+
const health = await waitForOpenCodeHealth(port, 3e4);
|
|
1847
|
+
if (!health.healthy) {
|
|
1848
|
+
spinner.fail("Failed to start OpenCode");
|
|
1849
|
+
throw new Error("OpenCode failed to start");
|
|
1850
|
+
}
|
|
1851
|
+
spinner.succeed(
|
|
1852
|
+
`OpenCode running on port ${port}${health.version ? ` (v${health.version})` : ""}`
|
|
1853
|
+
);
|
|
1854
|
+
return { port, process: proc, version: health.version ?? null };
|
|
1855
|
+
}
|
|
1856
|
+
return { port, process: null, version: null };
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/commands/agent-lookup.ts
|
|
1860
|
+
async function resolveAgentIdFromKey(authHeader) {
|
|
1861
|
+
const apiUrl = getApiUrlConfig();
|
|
1862
|
+
try {
|
|
1863
|
+
const response = await fetch(`${apiUrl}/me`, {
|
|
1864
|
+
headers: { Authorization: authHeader }
|
|
1865
|
+
});
|
|
1866
|
+
if (!response.ok) {
|
|
1867
|
+
return { error: `Failed to resolve agent from key: HTTP ${response.status}` };
|
|
1868
|
+
}
|
|
1869
|
+
const data = await response.json();
|
|
1870
|
+
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
1871
|
+
return { agent_id: data.agent_id };
|
|
1872
|
+
}
|
|
1873
|
+
return {
|
|
1874
|
+
error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
1875
|
+
};
|
|
1876
|
+
} catch (error2) {
|
|
1877
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1878
|
+
return { error: `Failed to resolve agent from key: ${message}` };
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
async function getAgentInfo(agentId, authHeader) {
|
|
1882
|
+
const apiUrl = getApiUrlConfig();
|
|
1883
|
+
try {
|
|
1884
|
+
const response = await fetch(`${apiUrl}/agents/${agentId}`, {
|
|
1885
|
+
headers: { Authorization: authHeader }
|
|
1886
|
+
});
|
|
1887
|
+
if (response.status === 404) {
|
|
1888
|
+
return { valid: false, error: "Agent not found" };
|
|
1889
|
+
}
|
|
1890
|
+
if (response.status === 401) {
|
|
1891
|
+
return { valid: false, error: "Authentication failed", authFailed: true };
|
|
1892
|
+
}
|
|
1893
|
+
if (!response.ok) {
|
|
1894
|
+
return { valid: false, error: `API error: ${response.status}` };
|
|
1895
|
+
}
|
|
1896
|
+
const agent = await response.json();
|
|
1897
|
+
if (agent.agent_type !== "local") {
|
|
1898
|
+
return {
|
|
1899
|
+
valid: false,
|
|
1900
|
+
error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
return { valid: true, agent };
|
|
1904
|
+
} catch (error2) {
|
|
1905
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1906
|
+
return { valid: false, error: `Failed to validate agent: ${message}` };
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/commands/run.ts
|
|
1911
|
+
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
1912
|
+
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
1913
|
+
function log(state, message, isError = false) {
|
|
1914
|
+
if (state.json) {
|
|
1915
|
+
console.log(
|
|
1916
|
+
JSON.stringify({
|
|
1917
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1918
|
+
level: isError ? "error" : "info",
|
|
1919
|
+
message
|
|
1920
|
+
})
|
|
1921
|
+
);
|
|
1922
|
+
} else if (!state.interactive) {
|
|
1923
|
+
const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
|
|
1924
|
+
console.log(`${prefix} ${message}`);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
function logActivity(state, entry) {
|
|
1928
|
+
const fullEntry = {
|
|
1929
|
+
...entry,
|
|
1930
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1931
|
+
};
|
|
1932
|
+
state.activityLog.push(fullEntry);
|
|
1933
|
+
if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
|
|
1934
|
+
state.activityLog.shift();
|
|
1935
|
+
}
|
|
1936
|
+
if (!state.interactive) {
|
|
1937
|
+
if (entry.type === "error") {
|
|
1938
|
+
log(state, entry.error ?? "Unknown error", true);
|
|
1939
|
+
} else if (entry.type === "info" && entry.message) {
|
|
1940
|
+
log(state, entry.message);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
function displayStatus(state) {
|
|
1945
|
+
if (!state.interactive) return;
|
|
1946
|
+
const attempt = state.connection?.reconnectAttempt ?? 0;
|
|
1947
|
+
const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
|
|
1948
|
+
const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
|
|
1949
|
+
const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
|
|
1950
|
+
const last = state.activityLog[state.activityLog.length - 1];
|
|
1951
|
+
const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
|
|
1952
|
+
const agent = state.agentName ?? state.agentId;
|
|
1953
|
+
console.log(
|
|
1954
|
+
`${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
|
|
1955
|
+
);
|
|
1247
1956
|
}
|
|
1248
|
-
async function
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1957
|
+
async function promptForLogin(promptMessage, successMessage) {
|
|
1958
|
+
const action = await select3({
|
|
1959
|
+
message: promptMessage,
|
|
1960
|
+
choices: [
|
|
1961
|
+
{
|
|
1962
|
+
name: "Yes, log me in",
|
|
1963
|
+
value: "login",
|
|
1964
|
+
description: "Opens a browser to authenticate with Evident"
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
name: "No, exit",
|
|
1968
|
+
value: "exit",
|
|
1969
|
+
description: "Exit without logging in"
|
|
1970
|
+
}
|
|
1971
|
+
]
|
|
1972
|
+
});
|
|
1973
|
+
if (action === "exit") {
|
|
1974
|
+
console.log(chalk6.dim(`
|
|
1975
|
+
You can log in later by running: ${getCliName()} login`));
|
|
1976
|
+
process.exit(0);
|
|
1977
|
+
}
|
|
1978
|
+
await login({ noBrowser: false });
|
|
1979
|
+
const credentials2 = await getToken();
|
|
1980
|
+
if (!credentials2) {
|
|
1981
|
+
printError("Login failed. Please try again.");
|
|
1255
1982
|
process.exit(1);
|
|
1256
1983
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1984
|
+
blank();
|
|
1985
|
+
console.log(chalk6.green(successMessage));
|
|
1986
|
+
blank();
|
|
1987
|
+
return { token: credentials2.token, authType: "bearer", user: credentials2.user };
|
|
1988
|
+
}
|
|
1989
|
+
var AUTH_EXPIRED_EXIT_CODE = 77;
|
|
1990
|
+
async function handleAuthError(state, error2) {
|
|
1991
|
+
logActivity(state, {
|
|
1992
|
+
type: "error",
|
|
1993
|
+
error: error2.message
|
|
1994
|
+
});
|
|
1995
|
+
if (state.interactive) displayStatus(state);
|
|
1996
|
+
if (!state.interactive) {
|
|
1262
1997
|
blank();
|
|
1263
|
-
console.log(
|
|
1264
|
-
console.log(
|
|
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>`));
|
|
1998
|
+
console.log(chalk6.red("Authentication expired"));
|
|
1999
|
+
console.log(chalk6.dim("Your authentication token is no longer valid."));
|
|
1267
2000
|
blank();
|
|
1268
|
-
|
|
1269
|
-
|
|
2001
|
+
console.log(chalk6.dim("To fix this:"));
|
|
2002
|
+
console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
|
|
2003
|
+
console.log(chalk6.dim(" 2. Restart this command"));
|
|
2004
|
+
blank();
|
|
2005
|
+
await cleanup(state);
|
|
2006
|
+
await shutdownTelemetry();
|
|
2007
|
+
process.exit(AUTH_EXPIRED_EXIT_CODE);
|
|
2008
|
+
return { success: false };
|
|
1270
2009
|
}
|
|
2010
|
+
blank();
|
|
2011
|
+
console.log(chalk6.yellow("Your authentication has expired."));
|
|
2012
|
+
blank();
|
|
2013
|
+
try {
|
|
2014
|
+
const credentials2 = await promptForLogin(
|
|
2015
|
+
"Would you like to log in again?",
|
|
2016
|
+
"Re-authenticated successfully! Resuming..."
|
|
2017
|
+
);
|
|
2018
|
+
const newAuthHeader = getAuthHeader(credentials2);
|
|
2019
|
+
return { success: true, newAuthHeader };
|
|
2020
|
+
} catch {
|
|
2021
|
+
return { success: false };
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function driveChannels(state, driver) {
|
|
2025
|
+
let idlePolls = 0;
|
|
2026
|
+
while (state.running) {
|
|
2027
|
+
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
2028
|
+
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
2029
|
+
if (state.interactive) displayStatus(state);
|
|
2030
|
+
await state.connection.reconnectPromise;
|
|
2031
|
+
}
|
|
2032
|
+
try {
|
|
2033
|
+
const processed = await driver.drainPending();
|
|
2034
|
+
state.messageCount += processed;
|
|
2035
|
+
if (processed > 0) {
|
|
2036
|
+
idlePolls = 0;
|
|
2037
|
+
if (state.interactive) displayStatus(state);
|
|
2038
|
+
} else if (state.idleTimeout !== null) {
|
|
2039
|
+
idlePolls++;
|
|
2040
|
+
if (idlePolls === 1) {
|
|
2041
|
+
logActivity(state, {
|
|
2042
|
+
type: "info",
|
|
2043
|
+
message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
|
|
2044
|
+
});
|
|
2045
|
+
if (state.interactive) displayStatus(state);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
} catch (error2) {
|
|
2049
|
+
if (error2 instanceof ChannelAuthError) {
|
|
2050
|
+
const result = await handleAuthError(state, error2);
|
|
2051
|
+
if (result.success && result.newAuthHeader) {
|
|
2052
|
+
state.authHeader = result.newAuthHeader;
|
|
2053
|
+
logActivity(state, { type: "info", message: "Continuing with new credentials..." });
|
|
2054
|
+
if (state.interactive) displayStatus(state);
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
state.running = false;
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
2061
|
+
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
2062
|
+
if (state.interactive) displayStatus(state);
|
|
2063
|
+
}
|
|
2064
|
+
await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
|
|
2065
|
+
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
2066
|
+
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
2067
|
+
if (idleMs > state.idleTimeout * 1e3) {
|
|
2068
|
+
logActivity(state, { type: "info", message: "Idle timeout reached" });
|
|
2069
|
+
if (state.interactive) displayStatus(state);
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
async function cleanup(state) {
|
|
2076
|
+
state.running = false;
|
|
2077
|
+
if (state.connection) {
|
|
2078
|
+
state.connection.close();
|
|
2079
|
+
state.connection = null;
|
|
2080
|
+
}
|
|
2081
|
+
if (state.opencodeProcess) {
|
|
2082
|
+
stopOpenCode(state.opencodeProcess);
|
|
2083
|
+
if (state.interactive) {
|
|
2084
|
+
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
2085
|
+
displayStatus(state);
|
|
2086
|
+
} else {
|
|
2087
|
+
log(state, "Stopped OpenCode process");
|
|
2088
|
+
}
|
|
2089
|
+
state.opencodeProcess = null;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
async function run(options) {
|
|
2093
|
+
const interactive = isInteractive(options.json);
|
|
1271
2094
|
const state = {
|
|
2095
|
+
agentId: options.agent || "",
|
|
2096
|
+
agentName: null,
|
|
2097
|
+
port: options.port ?? 4096,
|
|
2098
|
+
conversationFilter: options.conversation ?? null,
|
|
2099
|
+
idleTimeout: options.idleTimeout ?? null,
|
|
2100
|
+
json: options.json ?? false,
|
|
2101
|
+
interactive,
|
|
1272
2102
|
connected: false,
|
|
1273
2103
|
opencodeConnected: false,
|
|
1274
2104
|
opencodeVersion: null,
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
lastActivity: /* @__PURE__ */ new Date(),
|
|
2105
|
+
opencodeProcess: null,
|
|
2106
|
+
connection: null,
|
|
2107
|
+
running: true,
|
|
1279
2108
|
activityLog: [],
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
displayInitialized: false,
|
|
1283
|
-
activeEventSubscriptions: /* @__PURE__ */ new Map()
|
|
2109
|
+
messageCount: 0,
|
|
2110
|
+
authHeader: ""
|
|
1284
2111
|
};
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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);
|
|
1318
|
-
}
|
|
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})` : "";
|
|
1335
|
-
telemetry.info(
|
|
1336
|
-
EventTypes.OPENCODE_HEALTH_OK,
|
|
1337
|
-
`OpenCode healthy on port ${port}`,
|
|
1338
|
-
{
|
|
1339
|
-
port,
|
|
1340
|
-
version: healthCheck.version
|
|
1341
|
-
},
|
|
1342
|
-
sandboxId
|
|
2112
|
+
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
2113
|
+
log(
|
|
2114
|
+
state,
|
|
2115
|
+
"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.",
|
|
2116
|
+
false
|
|
1343
2117
|
);
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
}
|
|
1373
|
-
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
|
-
);
|
|
2118
|
+
}
|
|
2119
|
+
const handleSignal = async () => {
|
|
2120
|
+
if (state.interactive) {
|
|
2121
|
+
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
2122
|
+
displayStatus(state);
|
|
2123
|
+
} else {
|
|
2124
|
+
log(state, "Shutting down...");
|
|
2125
|
+
}
|
|
2126
|
+
await cleanup(state);
|
|
2127
|
+
await shutdownTelemetry();
|
|
2128
|
+
process.exit(0);
|
|
2129
|
+
};
|
|
2130
|
+
process.on("SIGINT", handleSignal);
|
|
2131
|
+
process.on("SIGTERM", handleSignal);
|
|
2132
|
+
try {
|
|
2133
|
+
let credentials2 = await getAuthCredentials();
|
|
2134
|
+
if (!credentials2) {
|
|
2135
|
+
if (!interactive) {
|
|
2136
|
+
printError("Authentication required");
|
|
2137
|
+
blank();
|
|
2138
|
+
console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
|
|
2139
|
+
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
2140
|
+
blank();
|
|
2141
|
+
process.exit(1);
|
|
1386
2142
|
}
|
|
1387
2143
|
blank();
|
|
1388
|
-
|
|
2144
|
+
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
1389
2145
|
blank();
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
{
|
|
1404
|
-
name: "Continue without OpenCode",
|
|
1405
|
-
value: "continue",
|
|
1406
|
-
description: "Connect the tunnel anyway (requests will fail until OpenCode starts)"
|
|
1407
|
-
}
|
|
1408
|
-
]
|
|
1409
|
-
});
|
|
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
|
|
1457
|
-
});
|
|
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;
|
|
1470
|
-
}
|
|
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}`);
|
|
1478
|
-
logActivity(state, {
|
|
1479
|
-
type: "info",
|
|
1480
|
-
message: `OpenCode started on port ${actualPort}${versionStr}`
|
|
1481
|
-
});
|
|
1482
|
-
} else {
|
|
1483
|
-
opencodeStartSpinner.warn(
|
|
1484
|
-
"OpenCode process started but not responding. Check if it started correctly."
|
|
1485
|
-
);
|
|
2146
|
+
credentials2 = await promptForLogin(
|
|
2147
|
+
"Would you like to log in now?",
|
|
2148
|
+
"Login successful! Continuing..."
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
state.authHeader = getAuthHeader(credentials2);
|
|
2152
|
+
if (!state.agentId) {
|
|
2153
|
+
if (credentials2.authType === "agent_key") {
|
|
2154
|
+
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
2155
|
+
if (resolved.agent_id) {
|
|
2156
|
+
state.agentId = resolved.agent_id;
|
|
2157
|
+
log(state, `Resolved agent ID from key: ${state.agentId}`);
|
|
2158
|
+
if (state.interactive && !state.json) {
|
|
1486
2159
|
logActivity(state, {
|
|
1487
2160
|
type: "info",
|
|
1488
|
-
message:
|
|
2161
|
+
message: `Agent ID resolved from key: ${state.agentId}`
|
|
1489
2162
|
});
|
|
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
2163
|
}
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
logActivity(state, {
|
|
1498
|
-
type: "error",
|
|
1499
|
-
error: `Failed to start OpenCode: ${msg}`
|
|
1500
|
-
});
|
|
1501
|
-
console.log(chalk4.yellow("\nYou can try starting it manually:"));
|
|
1502
|
-
console.log(chalk4.dim(` opencode serve --port ${actualPort}`));
|
|
1503
|
-
blank();
|
|
2164
|
+
} else {
|
|
2165
|
+
printError(resolved.error || "Failed to resolve agent ID from key");
|
|
2166
|
+
process.exit(1);
|
|
1504
2167
|
}
|
|
1505
|
-
} else
|
|
1506
|
-
|
|
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}`));
|
|
2168
|
+
} else {
|
|
2169
|
+
printError("--agent is required when not using EVIDENT_AGENT_KEY");
|
|
1514
2170
|
blank();
|
|
1515
|
-
console.log(
|
|
1516
|
-
chalk4.dim("The tunnel will automatically forward requests once OpenCode is running.")
|
|
1517
|
-
);
|
|
2171
|
+
console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
|
|
1518
2172
|
blank();
|
|
2173
|
+
process.exit(1);
|
|
1519
2174
|
}
|
|
1520
2175
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
2176
|
+
telemetry.info(
|
|
2177
|
+
EventTypes.CLI_COMMAND,
|
|
2178
|
+
"Starting run command",
|
|
2179
|
+
{
|
|
2180
|
+
command: "run",
|
|
2181
|
+
agentId: state.agentId,
|
|
2182
|
+
port: state.port,
|
|
2183
|
+
conversationFilter: state.conversationFilter,
|
|
2184
|
+
interactive
|
|
2185
|
+
},
|
|
2186
|
+
state.agentId
|
|
2187
|
+
);
|
|
2188
|
+
if (interactive && !state.json) {
|
|
2189
|
+
blank();
|
|
2190
|
+
console.log(chalk6.bold("Evident Run"));
|
|
2191
|
+
console.log(chalk6.dim("-".repeat(40)));
|
|
2192
|
+
}
|
|
2193
|
+
const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
|
|
2194
|
+
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
2195
|
+
if (!validation.valid && validation.authFailed && interactive) {
|
|
2196
|
+
spinner?.fail("Authentication failed");
|
|
2197
|
+
blank();
|
|
2198
|
+
console.log(chalk6.yellow("Your authentication token is invalid or expired."));
|
|
2199
|
+
blank();
|
|
2200
|
+
credentials2 = await promptForLogin(
|
|
2201
|
+
"Would you like to log in again?",
|
|
2202
|
+
"Login successful! Retrying..."
|
|
2203
|
+
);
|
|
2204
|
+
state.authHeader = getAuthHeader(credentials2);
|
|
2205
|
+
spinner?.start("Validating agent...");
|
|
2206
|
+
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
2207
|
+
}
|
|
2208
|
+
if (!validation.valid) {
|
|
2209
|
+
spinner?.fail(`Agent validation failed: ${validation.error}`);
|
|
2210
|
+
throw new Error(validation.error);
|
|
2211
|
+
}
|
|
2212
|
+
spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
|
|
2213
|
+
state.agentName = validation.agent.name;
|
|
2214
|
+
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
1523
2215
|
try {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
message: `Reconnecting in ${Math.round(delay / 1e3)}s (attempt ${state.reconnectAttempt})...`
|
|
2216
|
+
const oc = await ensureOpenCodeRunning({
|
|
2217
|
+
port: state.port,
|
|
2218
|
+
interactive: state.interactive,
|
|
2219
|
+
agentId: state.agentId,
|
|
2220
|
+
log: (message) => log(state, message)
|
|
1530
2221
|
});
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
},
|
|
1538
|
-
state.sandboxId ?? void 0
|
|
1539
|
-
);
|
|
1540
|
-
displayStatus(state);
|
|
1541
|
-
await sleep(delay);
|
|
2222
|
+
state.port = oc.port;
|
|
2223
|
+
state.opencodeProcess = oc.process;
|
|
2224
|
+
state.opencodeVersion = oc.version;
|
|
2225
|
+
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2226
|
+
const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2227
|
+
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
|
|
1542
2228
|
} catch (error2) {
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
{
|
|
1566
|
-
|
|
1567
|
-
|
|
2229
|
+
ocSpinner?.fail(error2.message);
|
|
2230
|
+
throw error2;
|
|
2231
|
+
}
|
|
2232
|
+
const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
|
|
2233
|
+
const channelDriver = new ChannelDriver({
|
|
2234
|
+
agentId: state.agentId,
|
|
2235
|
+
port: state.port,
|
|
2236
|
+
apiUrl: getApiUrlConfig(),
|
|
2237
|
+
getAuthHeader: () => state.authHeader,
|
|
2238
|
+
conversationFilter: state.conversationFilter,
|
|
2239
|
+
log: (entry) => logActivity(state, {
|
|
2240
|
+
type: entry.level === "error" ? "error" : "info",
|
|
2241
|
+
message: entry.message,
|
|
2242
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
2243
|
+
})
|
|
2244
|
+
});
|
|
2245
|
+
const connection = new RunnerConnection({
|
|
2246
|
+
agentId: state.agentId,
|
|
2247
|
+
getAuthHeader: () => state.authHeader,
|
|
2248
|
+
port: state.port,
|
|
2249
|
+
isRunning: () => state.running,
|
|
2250
|
+
events: {
|
|
2251
|
+
onConnected: (agentId, isReconnect) => {
|
|
2252
|
+
state.connected = true;
|
|
2253
|
+
state.agentId = agentId;
|
|
2254
|
+
logActivity(state, {
|
|
2255
|
+
type: "info",
|
|
2256
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
2257
|
+
});
|
|
2258
|
+
emitAgentConnected(state.agentId, { port: state.port });
|
|
2259
|
+
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
2260
|
+
if (state.interactive) displayStatus(state);
|
|
2261
|
+
channelDriver.drainPending().catch(() => {
|
|
2262
|
+
});
|
|
1568
2263
|
},
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2264
|
+
onDisconnected: (code, reason) => {
|
|
2265
|
+
state.connected = false;
|
|
2266
|
+
logActivity(state, {
|
|
2267
|
+
type: "info",
|
|
2268
|
+
message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
|
|
2269
|
+
});
|
|
2270
|
+
emitAgentDisconnected(state.agentId, { code, reason });
|
|
2271
|
+
if (state.interactive) displayStatus(state);
|
|
2272
|
+
},
|
|
2273
|
+
onError: (error2) => {
|
|
2274
|
+
logActivity(state, { type: "error", error: error2 });
|
|
2275
|
+
if (state.interactive) displayStatus(state);
|
|
2276
|
+
},
|
|
2277
|
+
// Web traffic is proxied transparently; only note opencode is live.
|
|
2278
|
+
onResponse: () => {
|
|
2279
|
+
state.opencodeConnected = true;
|
|
2280
|
+
},
|
|
2281
|
+
onInfo: (message) => logActivity(state, { type: "info", message })
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
state.connection = connection;
|
|
2285
|
+
try {
|
|
2286
|
+
await connection.connect();
|
|
2287
|
+
} catch (error2) {
|
|
2288
|
+
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
2289
|
+
throw error2;
|
|
2290
|
+
}
|
|
2291
|
+
if (interactive && !state.json) {
|
|
1577
2292
|
displayStatus(state);
|
|
1578
|
-
|
|
2293
|
+
} else {
|
|
2294
|
+
log(state, "Driving channel messages...");
|
|
2295
|
+
}
|
|
2296
|
+
await driveChannels(state, channelDriver);
|
|
2297
|
+
await cleanup(state);
|
|
2298
|
+
if (state.json) {
|
|
2299
|
+
console.log(
|
|
2300
|
+
JSON.stringify({
|
|
2301
|
+
status: "success",
|
|
2302
|
+
messages_processed: state.messageCount
|
|
2303
|
+
})
|
|
2304
|
+
);
|
|
2305
|
+
} else if (!interactive) {
|
|
2306
|
+
log(state, `Completed. Processed ${state.messageCount} message(s).`);
|
|
2307
|
+
}
|
|
2308
|
+
await shutdownTelemetry();
|
|
2309
|
+
process.exit(0);
|
|
2310
|
+
} catch (error2) {
|
|
2311
|
+
await cleanup(state);
|
|
2312
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2313
|
+
if (state.json) {
|
|
2314
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
2315
|
+
} else {
|
|
2316
|
+
printError(message);
|
|
1579
2317
|
}
|
|
2318
|
+
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
2319
|
+
command: "run",
|
|
2320
|
+
agentId: options.agent
|
|
2321
|
+
});
|
|
2322
|
+
await shutdownTelemetry();
|
|
2323
|
+
process.exit(1);
|
|
1580
2324
|
}
|
|
1581
2325
|
}
|
|
1582
2326
|
|
|
1583
2327
|
// src/index.ts
|
|
1584
2328
|
var program = new Command();
|
|
1585
|
-
program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
2329
|
+
program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
|
|
2330
|
+
"--endpoint <url>",
|
|
2331
|
+
"Evident API base URL (default: production; e.g. http://localhost:3001)"
|
|
2332
|
+
).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
|
|
2333
|
+
const { endpoint, tunnel } = thisCommand.opts();
|
|
2334
|
+
if (endpoint) {
|
|
2335
|
+
setEndpoint(endpoint);
|
|
2336
|
+
}
|
|
2337
|
+
if (tunnel) {
|
|
2338
|
+
setTunnelUrl(tunnel);
|
|
1589
2339
|
}
|
|
1590
2340
|
});
|
|
1591
2341
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
1592
2342
|
program.command("logout").description("Remove stored credentials").action(logout);
|
|
1593
2343
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
1594
|
-
program.command("
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2344
|
+
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
|
|
2345
|
+
(options) => {
|
|
2346
|
+
run({
|
|
2347
|
+
agent: options.agent,
|
|
2348
|
+
port: parseInt(options.port, 10),
|
|
2349
|
+
verbose: options.verbose,
|
|
2350
|
+
conversation: options.conversation,
|
|
2351
|
+
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
2352
|
+
json: options.json
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
);
|
|
1601
2356
|
program.parse();
|
|
1602
2357
|
//# sourceMappingURL=index.js.map
|