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