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