@evident-ai/cli 0.2.1-dev.fab83f9 → 3.0.1-dev.476bdd6
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 +10 -3
- package/dist/index.js +1288 -1161
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -12,41 +12,30 @@ import chalk2 from "chalk";
|
|
|
12
12
|
import Conf from "conf";
|
|
13
13
|
import { homedir } from "os";
|
|
14
14
|
import { join } from "path";
|
|
15
|
-
var
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
dev: {
|
|
21
|
-
apiUrl: "https://api.dev.evident.run/v1",
|
|
22
|
-
tunnelUrl: "wss://tunnel.dev.evident.run"
|
|
23
|
-
},
|
|
24
|
-
production: {
|
|
25
|
-
// Production URLs also have aliases: api.evident.run, tunnel.evident.run
|
|
26
|
-
apiUrl: "https://api.production.evident.run/v1",
|
|
27
|
-
tunnelUrl: "wss://tunnel.production.evident.run"
|
|
28
|
-
}
|
|
15
|
+
var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
|
|
16
|
+
var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
|
|
17
|
+
var defaults = {
|
|
18
|
+
apiUrl: PRODUCTION_API_URL,
|
|
19
|
+
tunnelUrl: PRODUCTION_TUNNEL_URL
|
|
29
20
|
};
|
|
30
|
-
var
|
|
31
|
-
var
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const envVar = process.env.EVIDENT_ENV;
|
|
37
|
-
if (envVar && environmentPresets[envVar]) {
|
|
38
|
-
return envVar;
|
|
21
|
+
var endpointOverride;
|
|
22
|
+
var tunnelOverride;
|
|
23
|
+
function setEndpoint(url) {
|
|
24
|
+
if (!url) {
|
|
25
|
+
endpointOverride = void 0;
|
|
26
|
+
return;
|
|
39
27
|
}
|
|
40
|
-
|
|
28
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
29
|
+
endpointOverride = /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
|
41
30
|
}
|
|
42
|
-
function
|
|
43
|
-
|
|
31
|
+
function setTunnelUrl(url) {
|
|
32
|
+
tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
|
|
44
33
|
}
|
|
45
34
|
function getApiUrl() {
|
|
46
|
-
return process.env.EVIDENT_API_URL ??
|
|
35
|
+
return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
|
|
47
36
|
}
|
|
48
37
|
function getTunnelUrl() {
|
|
49
|
-
return process.env.EVIDENT_TUNNEL_URL ??
|
|
38
|
+
return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
|
|
50
39
|
}
|
|
51
40
|
var config = new Conf({
|
|
52
41
|
projectName: "evident",
|
|
@@ -65,19 +54,28 @@ function getApiUrlConfig() {
|
|
|
65
54
|
function getTunnelUrlConfig() {
|
|
66
55
|
return getTunnelUrl();
|
|
67
56
|
}
|
|
57
|
+
function credentialsKey() {
|
|
58
|
+
return getApiUrl();
|
|
59
|
+
}
|
|
68
60
|
function getCredentials() {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
user: credentials.get("user"),
|
|
72
|
-
expiresAt: credentials.get("expiresAt")
|
|
73
|
-
};
|
|
61
|
+
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
62
|
+
return byEndpoint[credentialsKey()] ?? {};
|
|
74
63
|
}
|
|
75
64
|
function setCredentials(creds) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
66
|
+
byEndpoint[credentialsKey()] = {
|
|
67
|
+
token: creds.token,
|
|
68
|
+
user: creds.user,
|
|
69
|
+
expiresAt: creds.expiresAt
|
|
70
|
+
};
|
|
71
|
+
credentials.set("byEndpoint", byEndpoint);
|
|
79
72
|
}
|
|
80
73
|
function clearCredentials() {
|
|
74
|
+
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
75
|
+
delete byEndpoint[credentialsKey()];
|
|
76
|
+
credentials.set("byEndpoint", byEndpoint);
|
|
77
|
+
}
|
|
78
|
+
function clearAllCredentials() {
|
|
81
79
|
credentials.clear();
|
|
82
80
|
}
|
|
83
81
|
function getCliName() {
|
|
@@ -187,7 +185,6 @@ var api = {
|
|
|
187
185
|
|
|
188
186
|
// src/lib/keychain.ts
|
|
189
187
|
var SERVICE_NAME = "evident-cli";
|
|
190
|
-
var ACCOUNT_NAME = "default";
|
|
191
188
|
async function getKeytar() {
|
|
192
189
|
try {
|
|
193
190
|
const keytar = await import("keytar");
|
|
@@ -199,10 +196,13 @@ async function getKeytar() {
|
|
|
199
196
|
return null;
|
|
200
197
|
}
|
|
201
198
|
}
|
|
199
|
+
function keychainAccount() {
|
|
200
|
+
return getApiUrlConfig();
|
|
201
|
+
}
|
|
202
202
|
async function storeToken(credentials2) {
|
|
203
203
|
const keytar = await getKeytar();
|
|
204
204
|
if (keytar) {
|
|
205
|
-
await keytar.setPassword(SERVICE_NAME,
|
|
205
|
+
await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
|
|
206
206
|
} else {
|
|
207
207
|
setCredentials({
|
|
208
208
|
token: credentials2.token,
|
|
@@ -214,12 +214,13 @@ async function storeToken(credentials2) {
|
|
|
214
214
|
async function getToken() {
|
|
215
215
|
const keytar = await getKeytar();
|
|
216
216
|
if (keytar) {
|
|
217
|
-
const
|
|
217
|
+
const account = keychainAccount();
|
|
218
|
+
const stored = await keytar.getPassword(SERVICE_NAME, account);
|
|
218
219
|
if (stored) {
|
|
219
220
|
try {
|
|
220
221
|
return JSON.parse(stored);
|
|
221
222
|
} catch {
|
|
222
|
-
await keytar.deletePassword(SERVICE_NAME,
|
|
223
|
+
await keytar.deletePassword(SERVICE_NAME, account);
|
|
223
224
|
return null;
|
|
224
225
|
}
|
|
225
226
|
}
|
|
@@ -234,12 +235,26 @@ async function getToken() {
|
|
|
234
235
|
}
|
|
235
236
|
return null;
|
|
236
237
|
}
|
|
237
|
-
async function deleteToken() {
|
|
238
|
+
async function deleteToken(options = {}) {
|
|
238
239
|
const keytar = await getKeytar();
|
|
239
240
|
if (keytar) {
|
|
240
|
-
|
|
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();
|
|
241
257
|
}
|
|
242
|
-
clearCredentials();
|
|
243
258
|
}
|
|
244
259
|
|
|
245
260
|
// src/utils/ui.ts
|
|
@@ -407,25 +422,32 @@ async function login(options) {
|
|
|
407
422
|
}
|
|
408
423
|
|
|
409
424
|
// src/commands/logout.ts
|
|
410
|
-
async function logout() {
|
|
425
|
+
async function logout(options = {}) {
|
|
426
|
+
if (options.all) {
|
|
427
|
+
await deleteToken({ all: true });
|
|
428
|
+
printSuccess("Logged out of all endpoints.");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
411
431
|
const credentials2 = await getToken();
|
|
412
432
|
if (!credentials2) {
|
|
413
|
-
printWarning(
|
|
433
|
+
printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
|
|
414
434
|
return;
|
|
415
435
|
}
|
|
416
436
|
await deleteToken();
|
|
417
|
-
printSuccess(
|
|
437
|
+
printSuccess(`Logged out of ${getApiUrlConfig()}.`);
|
|
418
438
|
}
|
|
419
439
|
|
|
420
440
|
// src/commands/whoami.ts
|
|
421
441
|
import chalk3 from "chalk";
|
|
422
442
|
async function whoami() {
|
|
443
|
+
const apiUrl = getApiUrlConfig();
|
|
423
444
|
const credentials2 = await getToken();
|
|
424
445
|
if (!credentials2) {
|
|
425
|
-
printError(
|
|
446
|
+
printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
|
|
426
447
|
process.exit(1);
|
|
427
448
|
}
|
|
428
449
|
blank();
|
|
450
|
+
console.log(keyValue("Endpoint", apiUrl));
|
|
429
451
|
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
430
452
|
console.log(keyValue("User ID", credentials2.user.id));
|
|
431
453
|
if (credentials2.expiresAt) {
|
|
@@ -444,9 +466,9 @@ async function whoami() {
|
|
|
444
466
|
}
|
|
445
467
|
|
|
446
468
|
// src/commands/run.ts
|
|
447
|
-
import
|
|
448
|
-
import
|
|
449
|
-
import { select as
|
|
469
|
+
import chalk6 from "chalk";
|
|
470
|
+
import ora3 from "ora";
|
|
471
|
+
import { select as select3 } from "@inquirer/prompts";
|
|
450
472
|
|
|
451
473
|
// ../../packages/types/src/telemetry/index.ts
|
|
452
474
|
var TelemetryEventTypes = {
|
|
@@ -459,10 +481,8 @@ var TelemetryEventTypes = {
|
|
|
459
481
|
};
|
|
460
482
|
|
|
461
483
|
// ../../packages/types/src/tunnel/index.ts
|
|
462
|
-
var
|
|
463
|
-
var
|
|
464
|
-
var TUNNEL_MAX_RESPONSE_SIZE = 50 * 1024 * 1024;
|
|
465
|
-
var TUNNEL_CHUNK_TIMEOUT_MS = 30 * 1e3;
|
|
484
|
+
var MAX_FRAME_BYTES = 256 * 1024;
|
|
485
|
+
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
466
486
|
|
|
467
487
|
// src/lib/telemetry.ts
|
|
468
488
|
var CLI_VERSION = process.env.npm_package_version || "unknown";
|
|
@@ -574,33 +594,6 @@ function emitAgentDisconnected(agentId, metadata) {
|
|
|
574
594
|
agent_id: agentId
|
|
575
595
|
});
|
|
576
596
|
}
|
|
577
|
-
function emitAgentMessageProcessing(agentId, metadata) {
|
|
578
|
-
emitEvent({
|
|
579
|
-
event_type: TelemetryEventTypes.AGENT_MESSAGE_PROCESSING,
|
|
580
|
-
severity: "info",
|
|
581
|
-
message: `Processing message ${metadata.message_id.slice(0, 8)}...`,
|
|
582
|
-
metadata,
|
|
583
|
-
agent_id: agentId
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
function emitAgentMessageDone(agentId, metadata) {
|
|
587
|
-
emitEvent({
|
|
588
|
-
event_type: TelemetryEventTypes.AGENT_MESSAGE_DONE,
|
|
589
|
-
severity: "info",
|
|
590
|
-
message: `Message ${metadata.message_id.slice(0, 8)} processed`,
|
|
591
|
-
metadata,
|
|
592
|
-
agent_id: agentId
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
function emitAgentMessageFailed(agentId, metadata) {
|
|
596
|
-
emitEvent({
|
|
597
|
-
event_type: TelemetryEventTypes.AGENT_MESSAGE_FAILED,
|
|
598
|
-
severity: "error",
|
|
599
|
-
message: metadata.error ? `Message ${metadata.message_id.slice(0, 8)} failed: ${metadata.error}` : `Message ${metadata.message_id.slice(0, 8)} ${metadata.reason || "failed"}`,
|
|
600
|
-
metadata,
|
|
601
|
-
agent_id: agentId
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
597
|
var EventTypes = {
|
|
605
598
|
// Tunnel lifecycle
|
|
606
599
|
TUNNEL_STARTING: "tunnel.starting",
|
|
@@ -665,7 +658,7 @@ function isInteractive(jsonOutput) {
|
|
|
665
658
|
// src/lib/opencode/health.ts
|
|
666
659
|
async function checkOpenCodeHealth(port) {
|
|
667
660
|
try {
|
|
668
|
-
const response = await fetch(`http://
|
|
661
|
+
const response = await fetch(`http://127.0.0.1:${port}/global/health`, {
|
|
669
662
|
signal: AbortSignal.timeout(2e3)
|
|
670
663
|
// 2 second timeout
|
|
671
664
|
});
|
|
@@ -844,12 +837,12 @@ async function findHealthyOpenCodeInstances() {
|
|
|
844
837
|
}
|
|
845
838
|
async function startOpenCode(port) {
|
|
846
839
|
let command = "opencode";
|
|
847
|
-
let args = ["serve", "--port", port.toString()];
|
|
840
|
+
let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
848
841
|
try {
|
|
849
842
|
execSync("which opencode", { stdio: "ignore" });
|
|
850
843
|
} catch {
|
|
851
844
|
command = "npx";
|
|
852
|
-
args = ["opencode", "serve", "--port", port.toString()];
|
|
845
|
+
args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
853
846
|
}
|
|
854
847
|
const child = spawn(command, args, {
|
|
855
848
|
detached: true,
|
|
@@ -981,14 +974,59 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
981
974
|
}
|
|
982
975
|
|
|
983
976
|
// src/lib/opencode/session.ts
|
|
984
|
-
|
|
985
|
-
|
|
977
|
+
function opencodeBase(port) {
|
|
978
|
+
return `http://127.0.0.1:${port}`;
|
|
979
|
+
}
|
|
980
|
+
async function getOpenCodeDirectory(port) {
|
|
981
|
+
try {
|
|
982
|
+
const res = await fetch(`${opencodeBase(port)}/path`);
|
|
983
|
+
if (!res.ok) return null;
|
|
984
|
+
const body = await res.json();
|
|
985
|
+
const dir = typeof body.directory === "string" && body.directory || typeof body.worktree === "string" && body.worktree || typeof body.path?.cwd === "string" && body.path.cwd || typeof body.path?.directory === "string" && body.path.directory || null;
|
|
986
|
+
return dir && dir.trim() ? dir.trim() : null;
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function roleOf(m) {
|
|
992
|
+
if (!m || typeof m !== "object") return void 0;
|
|
993
|
+
if (typeof m.role === "string") return m.role;
|
|
994
|
+
const infoRole = m.info?.role;
|
|
995
|
+
return typeof infoRole === "string" ? infoRole : void 0;
|
|
996
|
+
}
|
|
997
|
+
function completedOf(m) {
|
|
998
|
+
if (!m || typeof m !== "object") return void 0;
|
|
999
|
+
return m.info?.time?.completed;
|
|
1000
|
+
}
|
|
1001
|
+
async function getSessionMessages(port, sessionId) {
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
|
|
1004
|
+
if (!res.ok) return null;
|
|
1005
|
+
const body = await res.json();
|
|
1006
|
+
return Array.isArray(body) ? body : null;
|
|
1007
|
+
} catch {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function isTurnComplete(messages) {
|
|
1012
|
+
if (!messages || messages.length === 0) return false;
|
|
1013
|
+
const last = messages[messages.length - 1];
|
|
1014
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1015
|
+
return completedOf(last) != null;
|
|
1016
|
+
}
|
|
1017
|
+
async function createOpenCodeSession(port, directory) {
|
|
1018
|
+
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1019
|
+
if (directory && directory.trim()) {
|
|
1020
|
+
url.searchParams.set("directory", directory.trim());
|
|
1021
|
+
}
|
|
1022
|
+
const response = await fetch(url, {
|
|
986
1023
|
method: "POST",
|
|
987
1024
|
headers: { "Content-Type": "application/json" },
|
|
988
1025
|
body: JSON.stringify({})
|
|
989
1026
|
});
|
|
990
1027
|
if (!response.ok) {
|
|
991
|
-
|
|
1028
|
+
const text = await response.text().catch(() => "");
|
|
1029
|
+
throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
|
|
992
1030
|
}
|
|
993
1031
|
const data = await response.json();
|
|
994
1032
|
return data.id;
|
|
@@ -1018,7 +1056,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1018
1056
|
if (pollDone) break;
|
|
1019
1057
|
if (hooks?.onQuestion) {
|
|
1020
1058
|
try {
|
|
1021
|
-
const res = await fetch(
|
|
1059
|
+
const res = await fetch(`${opencodeBase(port)}/question`);
|
|
1022
1060
|
if (res.ok) {
|
|
1023
1061
|
const questions = await res.json();
|
|
1024
1062
|
for (const q of questions) {
|
|
@@ -1033,7 +1071,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1033
1071
|
}
|
|
1034
1072
|
if (hooks?.onPermission) {
|
|
1035
1073
|
try {
|
|
1036
|
-
const res = await fetch(
|
|
1074
|
+
const res = await fetch(`${opencodeBase(port)}/permission`);
|
|
1037
1075
|
if (res.ok) {
|
|
1038
1076
|
const permissions = await res.json();
|
|
1039
1077
|
for (const p of permissions) {
|
|
@@ -1052,7 +1090,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1052
1090
|
const controller = new AbortController();
|
|
1053
1091
|
const timer = setTimeout(() => controller.abort(), maxWaitMs);
|
|
1054
1092
|
try {
|
|
1055
|
-
const res = await fetch(
|
|
1093
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
|
|
1056
1094
|
method: "POST",
|
|
1057
1095
|
headers: { "Content-Type": "application/json" },
|
|
1058
1096
|
body: JSON.stringify(body),
|
|
@@ -1062,11 +1100,14 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1062
1100
|
const text = await res.text().catch(() => "");
|
|
1063
1101
|
throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1064
1102
|
}
|
|
1065
|
-
const sessionRes = await fetch(
|
|
1103
|
+
const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
|
|
1066
1104
|
() => null
|
|
1067
1105
|
);
|
|
1068
1106
|
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
1069
|
-
|
|
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 };
|
|
1070
1111
|
} catch (err) {
|
|
1071
1112
|
if (err instanceof Error && err.name === "AbortError") {
|
|
1072
1113
|
throw new Error("Message processing timed out");
|
|
@@ -1082,147 +1123,149 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1082
1123
|
}
|
|
1083
1124
|
|
|
1084
1125
|
// src/lib/tunnel/connection.ts
|
|
1085
|
-
import
|
|
1126
|
+
import WebSocket2 from "ws";
|
|
1086
1127
|
|
|
1087
1128
|
// src/lib/tunnel/forwarding.ts
|
|
1088
|
-
|
|
1089
|
-
var
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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()) {
|
|
1107
1179
|
try {
|
|
1108
|
-
|
|
1180
|
+
stream.abort();
|
|
1109
1181
|
} catch {
|
|
1110
|
-
body = text;
|
|
1111
1182
|
}
|
|
1112
|
-
} else {
|
|
1113
|
-
body = text;
|
|
1114
1183
|
}
|
|
1115
|
-
|
|
1116
|
-
status: response.status,
|
|
1117
|
-
body
|
|
1118
|
-
};
|
|
1119
|
-
} catch (error2) {
|
|
1120
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1121
|
-
return {
|
|
1122
|
-
status: 502,
|
|
1123
|
-
body: { error: "Failed to connect to OpenCode", message }
|
|
1124
|
-
};
|
|
1184
|
+
this.inflight.clear();
|
|
1125
1185
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
if (bodyBytes.length < CHUNK_THRESHOLD) {
|
|
1131
|
-
ws.send(
|
|
1132
|
-
JSON.stringify({
|
|
1133
|
-
type: "response",
|
|
1134
|
-
id: requestId,
|
|
1135
|
-
payload: response
|
|
1136
|
-
})
|
|
1137
|
-
);
|
|
1138
|
-
return;
|
|
1186
|
+
send(frame) {
|
|
1187
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
1188
|
+
this.ws.send(JSON.stringify(frame));
|
|
1189
|
+
}
|
|
1139
1190
|
}
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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)}` });
|
|
1153
1237
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
type: "response_chunk",
|
|
1160
|
-
id: requestId,
|
|
1161
|
-
chunk_index: i,
|
|
1162
|
-
data: chunks[i].toString("base64")
|
|
1163
|
-
})
|
|
1164
|
-
);
|
|
1165
|
-
}
|
|
1166
|
-
ws.send(
|
|
1167
|
-
JSON.stringify({
|
|
1168
|
-
type: "response_end",
|
|
1169
|
-
id: requestId
|
|
1170
|
-
})
|
|
1171
|
-
);
|
|
1172
|
-
}
|
|
1173
|
-
function splitIntoChunks(data, chunkSize) {
|
|
1174
|
-
const chunks = [];
|
|
1175
|
-
for (let i = 0; i < data.length; i += chunkSize) {
|
|
1176
|
-
chunks.push(data.subarray(i, i + chunkSize));
|
|
1177
|
-
}
|
|
1178
|
-
return chunks;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// src/lib/tunnel/events.ts
|
|
1182
|
-
async function subscribeToOpenCodeEvents(port, subscriptionId, ws, abortController) {
|
|
1183
|
-
const url = `http://localhost:${port}/event`;
|
|
1184
|
-
try {
|
|
1185
|
-
const response = await fetch(url, {
|
|
1186
|
-
headers: { Accept: "text/event-stream" },
|
|
1187
|
-
signal: abortController.signal
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const resHeaders = {};
|
|
1241
|
+
upstream.headers.forEach((value, key) => {
|
|
1242
|
+
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1188
1243
|
});
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
ws.send(JSON.stringify({ type: "event_end", id: subscriptionId }));
|
|
1202
|
-
break;
|
|
1203
|
-
}
|
|
1204
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1205
|
-
const lines = buffer.split("\n");
|
|
1206
|
-
buffer = lines.pop() || "";
|
|
1207
|
-
for (const line of lines) {
|
|
1208
|
-
if (line.startsWith("data: ")) {
|
|
1209
|
-
try {
|
|
1210
|
-
const event = JSON.parse(line.slice(6));
|
|
1211
|
-
ws.send(JSON.stringify({ type: "event", id: subscriptionId, event }));
|
|
1212
|
-
} catch {
|
|
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") });
|
|
1213
1256
|
}
|
|
1214
1257
|
}
|
|
1215
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);
|
|
1216
1266
|
}
|
|
1217
|
-
} catch (error2) {
|
|
1218
|
-
if (abortController.signal.aborted) {
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1222
|
-
ws.send(JSON.stringify({ type: "event_error", id: subscriptionId, error: message }));
|
|
1223
|
-
throw error2;
|
|
1224
1267
|
}
|
|
1225
|
-
}
|
|
1268
|
+
};
|
|
1226
1269
|
|
|
1227
1270
|
// src/lib/tunnel/connection.ts
|
|
1228
1271
|
var MAX_RECONNECT_DELAY = 3e4;
|
|
@@ -1232,6 +1275,33 @@ function getReconnectDelay(attempt) {
|
|
|
1232
1275
|
const jitter = Math.random() * 1e3;
|
|
1233
1276
|
return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
|
|
1234
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
|
+
}
|
|
1235
1305
|
function connectTunnel(options) {
|
|
1236
1306
|
const {
|
|
1237
1307
|
agentId,
|
|
@@ -1242,330 +1312,890 @@ function connectTunnel(options) {
|
|
|
1242
1312
|
onError,
|
|
1243
1313
|
onRequest,
|
|
1244
1314
|
onResponse,
|
|
1245
|
-
onInfo
|
|
1315
|
+
onInfo,
|
|
1316
|
+
onDrainPing
|
|
1246
1317
|
} = options;
|
|
1247
1318
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1248
1319
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1249
|
-
const activeEventSubscriptions = /* @__PURE__ */ new Map();
|
|
1250
1320
|
return new Promise((resolve, reject) => {
|
|
1251
|
-
const ws = new
|
|
1321
|
+
const ws = new WebSocket2(url, {
|
|
1252
1322
|
headers: {
|
|
1253
1323
|
Authorization: authHeader
|
|
1254
1324
|
}
|
|
1255
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
|
+
});
|
|
1256
1340
|
const connectionTimeout = setTimeout(() => {
|
|
1257
1341
|
ws.close();
|
|
1258
1342
|
reject(new Error("Connection timeout"));
|
|
1259
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
|
+
});
|
|
1260
1364
|
ws.on("open", () => {
|
|
1261
1365
|
onInfo?.("WebSocket connection established");
|
|
1262
1366
|
});
|
|
1263
|
-
ws.on("message",
|
|
1367
|
+
ws.on("message", (data) => {
|
|
1368
|
+
let message;
|
|
1264
1369
|
try {
|
|
1265
|
-
|
|
1266
|
-
switch (message.type) {
|
|
1267
|
-
case "connected": {
|
|
1268
|
-
clearTimeout(connectionTimeout);
|
|
1269
|
-
const connectedAgentId = message.agent_id ?? agentId;
|
|
1270
|
-
onConnected?.(connectedAgentId);
|
|
1271
|
-
resolve({
|
|
1272
|
-
ws,
|
|
1273
|
-
close: () => ws.close(1e3, "CLI shutdown"),
|
|
1274
|
-
activeEventSubscriptions
|
|
1275
|
-
});
|
|
1276
|
-
break;
|
|
1277
|
-
}
|
|
1278
|
-
case "error":
|
|
1279
|
-
clearTimeout(connectionTimeout);
|
|
1280
|
-
onError?.(message.message || "Unknown tunnel error");
|
|
1281
|
-
if (message.code === "unauthorized") {
|
|
1282
|
-
ws.close();
|
|
1283
|
-
reject(new Error("Unauthorized"));
|
|
1284
|
-
}
|
|
1285
|
-
break;
|
|
1286
|
-
case "ping":
|
|
1287
|
-
ws.send(JSON.stringify({ type: "pong" }));
|
|
1288
|
-
break;
|
|
1289
|
-
case "request":
|
|
1290
|
-
if (message.id && message.payload) {
|
|
1291
|
-
const startTime = Date.now();
|
|
1292
|
-
onRequest?.(message.payload.method, message.payload.path, message.id);
|
|
1293
|
-
const response = await forwardToOpenCode(port, message.payload);
|
|
1294
|
-
const durationMs = Date.now() - startTime;
|
|
1295
|
-
onResponse?.(response.status, durationMs, message.id);
|
|
1296
|
-
sendResponse(ws, message.id, response);
|
|
1297
|
-
}
|
|
1298
|
-
break;
|
|
1299
|
-
case "subscribe_events":
|
|
1300
|
-
if (message.id) {
|
|
1301
|
-
const abortController = new AbortController();
|
|
1302
|
-
activeEventSubscriptions.set(message.id, abortController);
|
|
1303
|
-
onInfo?.(`Starting event subscription ${message.id.slice(0, 8)}`);
|
|
1304
|
-
subscribeToOpenCodeEvents(port, message.id, ws, abortController).catch((error2) => {
|
|
1305
|
-
if (!abortController.signal.aborted) {
|
|
1306
|
-
onError?.(`Event subscription failed: ${error2.message}`);
|
|
1307
|
-
}
|
|
1308
|
-
}).finally(() => {
|
|
1309
|
-
activeEventSubscriptions.delete(message.id);
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
1312
|
-
break;
|
|
1313
|
-
case "unsubscribe_events":
|
|
1314
|
-
if (message.id) {
|
|
1315
|
-
const controller = activeEventSubscriptions.get(message.id);
|
|
1316
|
-
if (controller) {
|
|
1317
|
-
controller.abort();
|
|
1318
|
-
activeEventSubscriptions.delete(message.id);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
break;
|
|
1322
|
-
}
|
|
1370
|
+
message = JSON.parse(data.toString());
|
|
1323
1371
|
} catch (error2) {
|
|
1324
1372
|
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1325
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;
|
|
1326
1402
|
}
|
|
1327
1403
|
});
|
|
1328
1404
|
ws.on("error", (error2) => {
|
|
1329
1405
|
clearTimeout(connectionTimeout);
|
|
1330
|
-
|
|
1331
|
-
|
|
1406
|
+
const detail = upgradeRejection ?? describeSocketError(error2, url);
|
|
1407
|
+
onError?.(`Connection error: ${detail}`);
|
|
1408
|
+
reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
|
|
1332
1409
|
});
|
|
1333
1410
|
ws.on("close", (code, reason) => {
|
|
1334
|
-
const reasonStr = reason.toString() || "No reason provided";
|
|
1411
|
+
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1412
|
+
forwarder.abortAll();
|
|
1413
|
+
streamStartTimes.clear();
|
|
1335
1414
|
onDisconnected?.(code, reasonStr);
|
|
1336
|
-
for (const [, controller] of activeEventSubscriptions) {
|
|
1337
|
-
controller.abort();
|
|
1338
|
-
}
|
|
1339
|
-
activeEventSubscriptions.clear();
|
|
1340
1415
|
});
|
|
1341
1416
|
});
|
|
1342
1417
|
}
|
|
1343
1418
|
|
|
1344
|
-
// src/
|
|
1345
|
-
var
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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;
|
|
1361
1451
|
}
|
|
1362
|
-
return {
|
|
1363
|
-
error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
1364
|
-
};
|
|
1365
|
-
} catch (error2) {
|
|
1366
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1367
|
-
return { error: `Failed to resolve agent from key: ${message}` };
|
|
1368
1452
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1453
|
+
async connectWithRetry(isReconnect) {
|
|
1454
|
+
if (isReconnect && this.reconnecting) return;
|
|
1455
|
+
this.reconnecting = true;
|
|
1456
|
+
this.close();
|
|
1457
|
+
const { events } = this.opts;
|
|
1458
|
+
while (this.opts.isRunning()) {
|
|
1459
|
+
try {
|
|
1460
|
+
this.connection = await connectTunnel({
|
|
1461
|
+
agentId: this.resolvedAgentId,
|
|
1462
|
+
authHeader: this.opts.getAuthHeader(),
|
|
1463
|
+
port: this.opts.port,
|
|
1464
|
+
onConnected: (agentId) => {
|
|
1465
|
+
this.reconnectAttempt = 0;
|
|
1466
|
+
this.reconnecting = false;
|
|
1467
|
+
this.resolvedAgentId = agentId;
|
|
1468
|
+
events.onConnected(agentId, isReconnect);
|
|
1469
|
+
},
|
|
1470
|
+
onDisconnected: (code, reason) => {
|
|
1471
|
+
events.onDisconnected(code, reason);
|
|
1472
|
+
if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
|
|
1473
|
+
this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
|
|
1474
|
+
events.onError?.(`Reconnection failed: ${err.message}`);
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
},
|
|
1478
|
+
onError: (error2) => events.onError?.(error2),
|
|
1479
|
+
onResponse: () => events.onResponse?.(),
|
|
1480
|
+
onDrainPing: () => events.onDrainPing?.(),
|
|
1481
|
+
onInfo: (message) => events.onInfo?.(message)
|
|
1482
|
+
});
|
|
1483
|
+
return;
|
|
1484
|
+
} catch (error2) {
|
|
1485
|
+
this.reconnectAttempt++;
|
|
1486
|
+
if (error2.message === "Unauthorized") {
|
|
1487
|
+
this.reconnecting = false;
|
|
1488
|
+
throw error2;
|
|
1489
|
+
}
|
|
1490
|
+
const delay = getReconnectDelay(this.reconnectAttempt);
|
|
1491
|
+
events.onReconnecting?.(this.reconnectAttempt);
|
|
1492
|
+
events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
|
|
1493
|
+
await this.sleep(delay);
|
|
1494
|
+
}
|
|
1391
1495
|
}
|
|
1392
|
-
|
|
1393
|
-
} catch (error2) {
|
|
1394
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1395
|
-
return { valid: false, error: `Failed to validate agent: ${message}` };
|
|
1496
|
+
this.reconnecting = false;
|
|
1396
1497
|
}
|
|
1397
|
-
}
|
|
1398
|
-
|
|
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 {
|
|
1399
1509
|
constructor(message) {
|
|
1400
1510
|
super(message);
|
|
1401
|
-
this.name = "
|
|
1511
|
+
this.name = "ChannelAuthError";
|
|
1402
1512
|
}
|
|
1403
1513
|
};
|
|
1404
|
-
function
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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;
|
|
1424
1600
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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,
|
|
1642
|
+
{
|
|
1643
|
+
agent: message.opencode_agent ?? void 0,
|
|
1644
|
+
model: message.opencode_model ?? void 0
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
|
|
1648
|
+
onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
|
|
1649
|
+
}
|
|
1650
|
+
);
|
|
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
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return processed;
|
|
1436
1683
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
{
|
|
1444
|
-
method: "PATCH",
|
|
1445
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
1446
|
-
body: JSON.stringify({ status: "processing" })
|
|
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;
|
|
1447
1690
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1691
|
+
const directory = await this.resolveOpenCodeDirectory();
|
|
1692
|
+
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1693
|
+
this.sessions.set(conv.id, sessionId);
|
|
1694
|
+
await this.persistSession(conv.id, sessionId).catch(() => {
|
|
1695
|
+
});
|
|
1696
|
+
return sessionId;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Lazily resolve (and cache) opencode's root directory via `GET /path`.
|
|
1700
|
+
* Resolved once per driver: `undefined` until first lookup, then the directory
|
|
1701
|
+
* string or `null` if unavailable (we don't keep retrying a missing `/path`).
|
|
1702
|
+
*/
|
|
1703
|
+
async resolveOpenCodeDirectory() {
|
|
1704
|
+
if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
|
|
1705
|
+
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
1706
|
+
if (!this.opencodeDirectory) {
|
|
1707
|
+
this.log({
|
|
1708
|
+
level: "info",
|
|
1709
|
+
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
1710
|
+
});
|
|
1460
1711
|
}
|
|
1461
|
-
|
|
1462
|
-
checkAuthResponse(response, "reporting interactive event");
|
|
1463
|
-
if (!response.ok) {
|
|
1464
|
-
throw new Error(`Failed to report interactive event: HTTP ${response.status}`);
|
|
1712
|
+
return this.opencodeDirectory;
|
|
1465
1713
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1714
|
+
/**
|
|
1715
|
+
* Local reconcile: re-query `GET /session/:id/message` and check whether the
|
|
1716
|
+
* last assistant message is message-level complete (`isTurnComplete`).
|
|
1717
|
+
*
|
|
1718
|
+
* This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
|
|
1719
|
+
* blocking POST already returned, and `markDone` causes the server to re-fetch
|
|
1720
|
+
* the messages itself (via `extractTextFromMessages`) when delivering the
|
|
1721
|
+
* reply — so this round-trip never gates delivery. We keep it only to surface a
|
|
1722
|
+
* truthful diagnostic when opencode hasn't yet recorded a completed assistant
|
|
1723
|
+
* turn at reconcile time, then proceed to `markDone` regardless. Uses the
|
|
1724
|
+
* injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
|
|
1725
|
+
*/
|
|
1726
|
+
async confirmCompletion(sessionId) {
|
|
1727
|
+
try {
|
|
1728
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1729
|
+
if (!res.ok) return;
|
|
1730
|
+
const body = await res.json();
|
|
1731
|
+
const messages = Array.isArray(body) ? body : null;
|
|
1732
|
+
if (!isTurnComplete(messages)) {
|
|
1733
|
+
this.log({
|
|
1734
|
+
level: "info",
|
|
1735
|
+
message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
} catch {
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
// -------------------------------------------------------------------------
|
|
1742
|
+
// Paused-session watcher (WI-2-CLI)
|
|
1743
|
+
// -------------------------------------------------------------------------
|
|
1744
|
+
/**
|
|
1745
|
+
* Start (but do NOT await) a watcher that resumes a paused turn to completion.
|
|
1746
|
+
*
|
|
1747
|
+
* Single-flight per message: if a watcher is already live for this message we
|
|
1748
|
+
* skip. The returned watcher promise is tracked in `this.watchers` and removed
|
|
1749
|
+
* when it settles; it never rejects (the body is fully guarded), so a failed
|
|
1750
|
+
* poll/markDone can never crash the run loop — the cron stays as the safety net.
|
|
1751
|
+
*/
|
|
1752
|
+
startPausedWatcher(conv, message, sessionId) {
|
|
1753
|
+
if (this.watchers.has(message.id)) return;
|
|
1754
|
+
const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
|
|
1755
|
+
this.watchers.delete(message.id);
|
|
1756
|
+
});
|
|
1757
|
+
this.watchers.set(message.id, watcher);
|
|
1472
1758
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1759
|
+
/**
|
|
1760
|
+
* Poll `GET /session/:id/message` until the SAME paused turn is message-level
|
|
1761
|
+
* complete — the last message is an ASSISTANT message whose
|
|
1762
|
+
* `info.time.completed` is set (the user answered the question/permission in
|
|
1763
|
+
* opencode web and opencode finished the turn) — then complete it via the
|
|
1764
|
+
* EXISTING `markDone` PATCH — NO re-send of the original message. The server
|
|
1765
|
+
* re-fetches the assistant messages on completion, so the watcher does not
|
|
1766
|
+
* pass any reply text.
|
|
1767
|
+
*
|
|
1768
|
+
* Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
|
|
1769
|
+
* tests' injection) and reuses only the pure `isTurnComplete` predicate from
|
|
1770
|
+
* `session.ts` — we do NOT call `getSessionMessages` (which uses the global
|
|
1771
|
+
* `fetch`) here.
|
|
1772
|
+
*
|
|
1773
|
+
* Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
|
|
1774
|
+
* reset): on timeout we STOP and leave the message `processing` so the cron
|
|
1775
|
+
* remains the last-resort safety net. The whole body is wrapped so any
|
|
1776
|
+
* poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
|
|
1777
|
+
* out of the run loop.
|
|
1778
|
+
*/
|
|
1779
|
+
async watchPausedSession(conv, message, sessionId) {
|
|
1780
|
+
const deadline = Date.now() + this.pausedMaxWaitMs;
|
|
1781
|
+
try {
|
|
1782
|
+
while (Date.now() < deadline) {
|
|
1783
|
+
await this.sleep(this.pausedPollIntervalMs);
|
|
1784
|
+
let completed = false;
|
|
1785
|
+
try {
|
|
1786
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1787
|
+
if (res.ok) {
|
|
1788
|
+
const body = await res.json();
|
|
1789
|
+
const messages = Array.isArray(body) ? body : null;
|
|
1790
|
+
completed = isTurnComplete(messages);
|
|
1791
|
+
}
|
|
1792
|
+
} catch {
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
if (!completed) continue;
|
|
1796
|
+
this.log({
|
|
1797
|
+
level: "info",
|
|
1798
|
+
message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
|
|
1799
|
+
conversation_id: conv.id,
|
|
1800
|
+
message_id: message.id
|
|
1801
|
+
});
|
|
1802
|
+
await this.markDone(conv.id, message.id, sessionId);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
this.log({
|
|
1806
|
+
level: "info",
|
|
1807
|
+
message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
|
|
1808
|
+
conversation_id: conv.id,
|
|
1809
|
+
message_id: message.id
|
|
1810
|
+
});
|
|
1811
|
+
} catch (err) {
|
|
1812
|
+
this.log({
|
|
1813
|
+
level: "error",
|
|
1814
|
+
message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1815
|
+
conversation_id: conv.id,
|
|
1816
|
+
message_id: message.id
|
|
1817
|
+
});
|
|
1479
1818
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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}`);
|
|
1491
1833
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
const apiUrl = getApiUrlConfig();
|
|
1497
|
-
try {
|
|
1498
|
-
const response = await fetch(`${apiUrl}/agents/${agentId}/threads/${conversationId}/lock`, {
|
|
1499
|
-
method: "POST",
|
|
1500
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
1501
|
-
body: JSON.stringify({ correlation_id: correlationId })
|
|
1502
|
-
});
|
|
1503
|
-
checkAuthResponse(response, "acquiring conversation lock");
|
|
1504
|
-
if (response.status === 409) {
|
|
1505
|
-
return { acquired: false, error: "Conversation already locked by another runner" };
|
|
1834
|
+
const data = await res.json();
|
|
1835
|
+
let conversations = data.conversations;
|
|
1836
|
+
if (this.conversationFilter) {
|
|
1837
|
+
conversations = conversations.filter((c) => c.id === this.conversationFilter);
|
|
1506
1838
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
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}`);
|
|
1509
1849
|
}
|
|
1510
|
-
return
|
|
1511
|
-
} catch (error2) {
|
|
1512
|
-
if (error2 instanceof AuthenticationError) throw error2;
|
|
1513
|
-
return { acquired: false, error: String(error2) };
|
|
1850
|
+
return await res.json();
|
|
1514
1851
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
try {
|
|
1519
|
-
const response = await fetch(
|
|
1520
|
-
`${apiUrl}/agents/${agentId}/threads/${conversationId}/lock/extend`,
|
|
1852
|
+
async markProcessing(conversationId, messageId) {
|
|
1853
|
+
const res = await this.fetchImpl(
|
|
1854
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1521
1855
|
{
|
|
1522
|
-
method: "
|
|
1523
|
-
headers: { Authorization:
|
|
1524
|
-
body: JSON.stringify({
|
|
1856
|
+
method: "PATCH",
|
|
1857
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1858
|
+
body: JSON.stringify({ status: "processing" })
|
|
1525
1859
|
}
|
|
1526
1860
|
);
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
return false;
|
|
1861
|
+
this.assertAuth(res, "marking message as processing");
|
|
1862
|
+
return res.ok;
|
|
1530
1863
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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}`,
|
|
1875
|
+
{
|
|
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}`,
|
|
1537
1899
|
{
|
|
1538
|
-
method: "
|
|
1539
|
-
headers: { Authorization:
|
|
1900
|
+
method: "PATCH",
|
|
1901
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1902
|
+
body: JSON.stringify({ opencode_session_id: sessionId })
|
|
1540
1903
|
}
|
|
1541
1904
|
);
|
|
1905
|
+
this.assertAuth(res, "persisting session id");
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
1909
|
+
* `POST .../interactive-event {type, data}`. The server persists the
|
|
1910
|
+
* interaction and posts a link to the proxied opencode-web conversation.
|
|
1911
|
+
*/
|
|
1912
|
+
async reportInteraction(conversationId, type, data) {
|
|
1913
|
+
try {
|
|
1914
|
+
await this.callWithRetry(
|
|
1915
|
+
"reporting interactive event",
|
|
1916
|
+
() => this.fetchImpl(
|
|
1917
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
1918
|
+
{
|
|
1919
|
+
method: "POST",
|
|
1920
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1921
|
+
body: JSON.stringify({ type, data })
|
|
1922
|
+
}
|
|
1923
|
+
)
|
|
1924
|
+
);
|
|
1925
|
+
this.log({
|
|
1926
|
+
level: "info",
|
|
1927
|
+
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
1928
|
+
conversation_id: conversationId
|
|
1929
|
+
});
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1932
|
+
this.log({
|
|
1933
|
+
level: "error",
|
|
1934
|
+
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1935
|
+
conversation_id: conversationId
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
// -------------------------------------------------------------------------
|
|
1940
|
+
// Retry wrapper
|
|
1941
|
+
// -------------------------------------------------------------------------
|
|
1942
|
+
/**
|
|
1943
|
+
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
1944
|
+
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
1945
|
+
* (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
|
|
1946
|
+
* terminal too. No on-disk persistence — a crash mid-retry drops the callback
|
|
1947
|
+
* (accepted by ADR-0039).
|
|
1948
|
+
*/
|
|
1949
|
+
async callWithRetry(context, call) {
|
|
1950
|
+
let lastError;
|
|
1951
|
+
for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
|
|
1952
|
+
let res;
|
|
1953
|
+
try {
|
|
1954
|
+
res = await call();
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
lastError = err;
|
|
1957
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
1958
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
throw err;
|
|
1962
|
+
}
|
|
1963
|
+
if (res.status === 401 || res.status === 403) {
|
|
1964
|
+
throw new ChannelAuthError(
|
|
1965
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
if (res.ok) return;
|
|
1969
|
+
if (isRetryableStatus(res.status)) {
|
|
1970
|
+
lastError = new Error(`${context}: HTTP ${res.status}`);
|
|
1971
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
1972
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
throw new Error(`${context}: HTTP ${res.status}`);
|
|
1977
|
+
}
|
|
1978
|
+
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
1979
|
+
}
|
|
1980
|
+
assertAuth(res, context) {
|
|
1981
|
+
if (res.status === 401 || res.status === 403) {
|
|
1982
|
+
throw new ChannelAuthError(
|
|
1983
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
// src/commands/ensure-opencode.ts
|
|
1990
|
+
import chalk5 from "chalk";
|
|
1991
|
+
import ora2 from "ora";
|
|
1992
|
+
import { select as select2 } from "@inquirer/prompts";
|
|
1993
|
+
async function ensureOpenCodeRunning(ctx) {
|
|
1994
|
+
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
1995
|
+
if (healthCheck.healthy) {
|
|
1996
|
+
return { port: ctx.port, process: null, version: healthCheck.version ?? null };
|
|
1997
|
+
}
|
|
1998
|
+
const runningInstances = await findHealthyOpenCodeInstances();
|
|
1999
|
+
if (runningInstances.length > 0) {
|
|
2000
|
+
if (!ctx.interactive) {
|
|
2001
|
+
throw new Error(
|
|
2002
|
+
`OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
blank();
|
|
2006
|
+
console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
|
|
2007
|
+
for (const instance of runningInstances) {
|
|
2008
|
+
const ver = instance.version ? ` (v${instance.version})` : "";
|
|
2009
|
+
const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
|
|
2010
|
+
console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
|
|
2011
|
+
}
|
|
2012
|
+
blank();
|
|
2013
|
+
if (runningInstances.length === 1) {
|
|
2014
|
+
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
2015
|
+
console.log(
|
|
2016
|
+
chalk5.dim(
|
|
2017
|
+
` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
2018
|
+
)
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
blank();
|
|
2022
|
+
throw new Error(`OpenCode not running on port ${ctx.port}`);
|
|
2023
|
+
}
|
|
2024
|
+
if (!isOpenCodeInstalled()) {
|
|
2025
|
+
if (!ctx.interactive) {
|
|
2026
|
+
throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
|
|
2027
|
+
}
|
|
2028
|
+
const result = await promptOpenCodeInstall(true);
|
|
2029
|
+
if (result === "exit") process.exit(0);
|
|
2030
|
+
if (result !== "installed" && !isOpenCodeInstalled()) {
|
|
2031
|
+
throw new Error("OpenCode is not installed");
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
if (!ctx.interactive) {
|
|
2035
|
+
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
2036
|
+
const proc = await startOpenCode(ctx.port);
|
|
2037
|
+
const health = await waitForOpenCodeHealth(ctx.port, 3e4);
|
|
2038
|
+
if (!health.healthy) {
|
|
2039
|
+
throw new Error(
|
|
2040
|
+
`OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
|
|
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
|
+
}
|
|
1542
2118
|
} catch {
|
|
1543
2119
|
}
|
|
2120
|
+
return text.trim() || response.statusText || void 0;
|
|
1544
2121
|
}
|
|
1545
|
-
|
|
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) {
|
|
1546
2127
|
const apiUrl = getApiUrlConfig();
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
2128
|
+
try {
|
|
2129
|
+
const response = await fetch(`${apiUrl}/me`, {
|
|
2130
|
+
headers: { Authorization: authHeader }
|
|
2131
|
+
});
|
|
2132
|
+
if (response.status === 401) {
|
|
2133
|
+
const serverMessage = await readErrorMessage(response);
|
|
2134
|
+
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
2135
|
+
}
|
|
2136
|
+
if (!response.ok) {
|
|
2137
|
+
const serverMessage = await readErrorMessage(response);
|
|
2138
|
+
return {
|
|
2139
|
+
error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
const data = await response.json();
|
|
2143
|
+
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
2144
|
+
return { agent_id: data.agent_id };
|
|
2145
|
+
}
|
|
2146
|
+
return {
|
|
2147
|
+
error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
2148
|
+
};
|
|
2149
|
+
} catch (error2) {
|
|
2150
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
2151
|
+
return { error: `Failed to resolve agent from key: ${message}` };
|
|
1558
2152
|
}
|
|
1559
2153
|
}
|
|
1560
|
-
async function
|
|
2154
|
+
async function getAgentInfo(agentId, authHeader) {
|
|
1561
2155
|
const apiUrl = getApiUrlConfig();
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
+
}
|
|
1568
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;
|
|
1569
2199
|
function log(state, message, isError = false) {
|
|
1570
2200
|
if (state.json) {
|
|
1571
2201
|
console.log(
|
|
@@ -1576,7 +2206,7 @@ function log(state, message, isError = false) {
|
|
|
1576
2206
|
})
|
|
1577
2207
|
);
|
|
1578
2208
|
} else if (!state.interactive) {
|
|
1579
|
-
const prefix = isError ?
|
|
2209
|
+
const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
|
|
1580
2210
|
console.log(`${prefix} ${message}`);
|
|
1581
2211
|
}
|
|
1582
2212
|
}
|
|
@@ -1589,7 +2219,6 @@ function logActivity(state, entry) {
|
|
|
1589
2219
|
if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
|
|
1590
2220
|
state.activityLog.shift();
|
|
1591
2221
|
}
|
|
1592
|
-
state.lastActivity = fullEntry.timestamp;
|
|
1593
2222
|
if (!state.interactive) {
|
|
1594
2223
|
if (entry.type === "error") {
|
|
1595
2224
|
log(state, entry.error ?? "Unknown error", true);
|
|
@@ -1598,130 +2227,21 @@ function logActivity(state, entry) {
|
|
|
1598
2227
|
}
|
|
1599
2228
|
}
|
|
1600
2229
|
}
|
|
1601
|
-
var ANSI = {
|
|
1602
|
-
moveUp: (n) => `\x1B[${n}A`
|
|
1603
|
-
};
|
|
1604
|
-
var STATUS_DISPLAY_HEIGHT = 22;
|
|
1605
|
-
function colorizeStatus(status) {
|
|
1606
|
-
if (status >= 200 && status < 300) {
|
|
1607
|
-
return chalk5.green(status.toString());
|
|
1608
|
-
} else if (status >= 300 && status < 400) {
|
|
1609
|
-
return chalk5.yellow(status.toString());
|
|
1610
|
-
} else if (status >= 400 && status < 500) {
|
|
1611
|
-
return chalk5.red(status.toString());
|
|
1612
|
-
} else if (status >= 500) {
|
|
1613
|
-
return chalk5.bgRed.white(` ${status} `);
|
|
1614
|
-
}
|
|
1615
|
-
return status.toString();
|
|
1616
|
-
}
|
|
1617
|
-
function formatActivityEntry(entry) {
|
|
1618
|
-
const time = entry.timestamp.toLocaleTimeString("en-US", {
|
|
1619
|
-
hour12: false,
|
|
1620
|
-
hour: "2-digit",
|
|
1621
|
-
minute: "2-digit",
|
|
1622
|
-
second: "2-digit"
|
|
1623
|
-
});
|
|
1624
|
-
switch (entry.type) {
|
|
1625
|
-
case "request": {
|
|
1626
|
-
const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
|
|
1627
|
-
const status = entry.status ? ` -> ${colorizeStatus(entry.status)}` : " ...";
|
|
1628
|
-
return ` ${chalk5.dim(`[${time}]`)} ${chalk5.cyan("<-")} ${entry.method} ${entry.path}${status}${duration}`;
|
|
1629
|
-
}
|
|
1630
|
-
case "response": {
|
|
1631
|
-
const duration = entry.durationMs ? ` (${entry.durationMs}ms)` : "";
|
|
1632
|
-
return ` ${chalk5.dim(`[${time}]`)} ${chalk5.green("->")} ${entry.method} ${entry.path} ${colorizeStatus(entry.status)}${duration}`;
|
|
1633
|
-
}
|
|
1634
|
-
case "error": {
|
|
1635
|
-
const errorMsg = entry.error || "Unknown error";
|
|
1636
|
-
const path = entry.path ? ` ${entry.method} ${entry.path}` : "";
|
|
1637
|
-
return ` ${chalk5.dim(`[${time}]`)} ${chalk5.red("x")}${path} - ${chalk5.red(errorMsg)}`;
|
|
1638
|
-
}
|
|
1639
|
-
case "info": {
|
|
1640
|
-
return ` ${chalk5.dim(`[${time}]`)} ${chalk5.blue("*")} ${entry.message}`;
|
|
1641
|
-
}
|
|
1642
|
-
default:
|
|
1643
|
-
return ` ${chalk5.dim(`[${time}]`)} ${entry.message || "Unknown"}`;
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
2230
|
function displayStatus(state) {
|
|
1647
2231
|
if (!state.interactive) return;
|
|
1648
|
-
const
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
lines.push(` Filter: conversation ${state.conversationFilter.slice(0, 8)}...`);
|
|
1658
|
-
}
|
|
1659
|
-
lines.push("");
|
|
1660
|
-
if (state.connected) {
|
|
1661
|
-
lines.push(` ${chalk5.green("*")} Tunnel: ${chalk5.green("Connected to Evident")}`);
|
|
1662
|
-
} else {
|
|
1663
|
-
if (state.reconnectAttempt > 0) {
|
|
1664
|
-
lines.push(
|
|
1665
|
-
` ${chalk5.yellow("o")} Tunnel: ${chalk5.yellow(`Reconnecting... (attempt ${state.reconnectAttempt})`)}`
|
|
1666
|
-
);
|
|
1667
|
-
} else {
|
|
1668
|
-
lines.push(` ${chalk5.yellow("o")} Tunnel: ${chalk5.yellow("Connecting...")}`);
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
if (state.opencodeConnected) {
|
|
1672
|
-
const version = state.opencodeVersion ? `, v${state.opencodeVersion}` : "";
|
|
1673
|
-
lines.push(
|
|
1674
|
-
` ${chalk5.green("*")} OpenCode: ${chalk5.green(`Running on port ${state.port}${version}`)}`
|
|
1675
|
-
);
|
|
1676
|
-
} else {
|
|
1677
|
-
lines.push(` ${chalk5.red("o")} OpenCode: ${chalk5.red(`Not connected (port ${state.port})`)}`);
|
|
1678
|
-
}
|
|
1679
|
-
lines.push("");
|
|
1680
|
-
if (state.messageCount > 0) {
|
|
1681
|
-
lines.push(` Messages: ${state.messageCount} processed`);
|
|
1682
|
-
lines.push("");
|
|
1683
|
-
}
|
|
1684
|
-
if (state.activityLog.length > 0) {
|
|
1685
|
-
lines.push(chalk5.bold(" Activity:"));
|
|
1686
|
-
for (const entry of state.activityLog) {
|
|
1687
|
-
lines.push(formatActivityEntry(entry));
|
|
1688
|
-
}
|
|
1689
|
-
} else {
|
|
1690
|
-
lines.push(chalk5.dim(" No activity yet. Waiting for requests..."));
|
|
1691
|
-
}
|
|
1692
|
-
lines.push("");
|
|
1693
|
-
lines.push(chalk5.dim("-".repeat(60)));
|
|
1694
|
-
if (state.verbose) {
|
|
1695
|
-
lines.push(chalk5.dim(" Verbose mode: ON"));
|
|
1696
|
-
}
|
|
1697
|
-
lines.push("");
|
|
1698
|
-
lines.push(
|
|
1699
|
-
chalk5.dim(` Tip: Run \`opencode attach http://localhost:${state.port}\` to see live activity`)
|
|
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}`
|
|
1700
2241
|
);
|
|
1701
|
-
lines.push(chalk5.dim(" Press Ctrl+C to disconnect"));
|
|
1702
|
-
while (lines.length < STATUS_DISPLAY_HEIGHT) {
|
|
1703
|
-
lines.push("");
|
|
1704
|
-
}
|
|
1705
|
-
if (!state.displayInitialized) {
|
|
1706
|
-
console.log("");
|
|
1707
|
-
console.log(chalk5.dim("=".repeat(60)));
|
|
1708
|
-
console.log("");
|
|
1709
|
-
for (const line of lines) {
|
|
1710
|
-
console.log(line);
|
|
1711
|
-
}
|
|
1712
|
-
state.displayInitialized = true;
|
|
1713
|
-
} else {
|
|
1714
|
-
process.stdout.write(ANSI.moveUp(STATUS_DISPLAY_HEIGHT + 3));
|
|
1715
|
-
console.log(chalk5.dim("=".repeat(60)));
|
|
1716
|
-
console.log("");
|
|
1717
|
-
for (const line of lines) {
|
|
1718
|
-
process.stdout.write("\x1B[2K");
|
|
1719
|
-
console.log(line);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
2242
|
}
|
|
1723
2243
|
async function promptForLogin(promptMessage, successMessage) {
|
|
1724
|
-
const action = await
|
|
2244
|
+
const action = await select3({
|
|
1725
2245
|
message: promptMessage,
|
|
1726
2246
|
choices: [
|
|
1727
2247
|
{
|
|
@@ -1737,7 +2257,7 @@ async function promptForLogin(promptMessage, successMessage) {
|
|
|
1737
2257
|
]
|
|
1738
2258
|
});
|
|
1739
2259
|
if (action === "exit") {
|
|
1740
|
-
console.log(
|
|
2260
|
+
console.log(chalk6.dim(`
|
|
1741
2261
|
You can log in later by running: ${getCliName()} login`));
|
|
1742
2262
|
process.exit(0);
|
|
1743
2263
|
}
|
|
@@ -1748,137 +2268,10 @@ You can log in later by running: ${getCliName()} login`));
|
|
|
1748
2268
|
process.exit(1);
|
|
1749
2269
|
}
|
|
1750
2270
|
blank();
|
|
1751
|
-
console.log(
|
|
2271
|
+
console.log(chalk6.green(successMessage));
|
|
1752
2272
|
blank();
|
|
1753
2273
|
return { token: credentials2.token, authType: "bearer", user: credentials2.user };
|
|
1754
2274
|
}
|
|
1755
|
-
async function ensureOpenCodeRunning(state) {
|
|
1756
|
-
const healthCheck = await checkOpenCodeHealth(state.port);
|
|
1757
|
-
if (healthCheck.healthy) {
|
|
1758
|
-
state.opencodeConnected = true;
|
|
1759
|
-
state.opencodeVersion = healthCheck.version ?? null;
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
const runningInstances = await findHealthyOpenCodeInstances();
|
|
1763
|
-
if (runningInstances.length > 0) {
|
|
1764
|
-
if (!state.interactive) {
|
|
1765
|
-
throw new Error(
|
|
1766
|
-
`OpenCode not found on port ${state.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
blank();
|
|
1770
|
-
console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
|
|
1771
|
-
for (const instance of runningInstances) {
|
|
1772
|
-
const ver = instance.version ? ` (v${instance.version})` : "";
|
|
1773
|
-
const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
|
|
1774
|
-
console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
|
|
1775
|
-
}
|
|
1776
|
-
blank();
|
|
1777
|
-
if (runningInstances.length === 1) {
|
|
1778
|
-
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
1779
|
-
console.log(
|
|
1780
|
-
chalk5.dim(
|
|
1781
|
-
` ${getCliName()} run --agent ${state.agentId} --port ${runningInstances[0].port}`
|
|
1782
|
-
)
|
|
1783
|
-
);
|
|
1784
|
-
}
|
|
1785
|
-
blank();
|
|
1786
|
-
throw new Error(`OpenCode not running on port ${state.port}`);
|
|
1787
|
-
}
|
|
1788
|
-
if (!isOpenCodeInstalled()) {
|
|
1789
|
-
if (!state.interactive) {
|
|
1790
|
-
throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
|
|
1791
|
-
}
|
|
1792
|
-
const result = await promptOpenCodeInstall(true);
|
|
1793
|
-
if (result === "exit") {
|
|
1794
|
-
process.exit(0);
|
|
1795
|
-
}
|
|
1796
|
-
if (result === "installed" || isOpenCodeInstalled()) {
|
|
1797
|
-
} else {
|
|
1798
|
-
throw new Error("OpenCode is not installed");
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if (state.interactive) {
|
|
1802
|
-
let actualPort = state.port;
|
|
1803
|
-
if (isPortInUse(state.port)) {
|
|
1804
|
-
console.log(chalk5.yellow(`
|
|
1805
|
-
Port ${state.port} is already in use.`));
|
|
1806
|
-
const alternativePort = findAvailablePort(state.port + 1);
|
|
1807
|
-
if (alternativePort) {
|
|
1808
|
-
const useAlternative = await select2({
|
|
1809
|
-
message: `Use port ${alternativePort} instead?`,
|
|
1810
|
-
choices: [
|
|
1811
|
-
{ name: `Yes, use port ${alternativePort}`, value: "yes" },
|
|
1812
|
-
{ name: "No, I will free the port manually", value: "no" }
|
|
1813
|
-
]
|
|
1814
|
-
});
|
|
1815
|
-
if (useAlternative === "yes") {
|
|
1816
|
-
actualPort = alternativePort;
|
|
1817
|
-
state.port = actualPort;
|
|
1818
|
-
} else {
|
|
1819
|
-
throw new Error(`Port ${state.port} is in use`);
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
const action = await select2({
|
|
1824
|
-
message: "OpenCode is not running. What would you like to do?",
|
|
1825
|
-
choices: [
|
|
1826
|
-
{
|
|
1827
|
-
name: "Start OpenCode for me",
|
|
1828
|
-
value: "start",
|
|
1829
|
-
description: `Run 'opencode serve --port ${actualPort}'`
|
|
1830
|
-
},
|
|
1831
|
-
{
|
|
1832
|
-
name: "Show me the command",
|
|
1833
|
-
value: "manual",
|
|
1834
|
-
description: "Display the command to run manually"
|
|
1835
|
-
},
|
|
1836
|
-
{
|
|
1837
|
-
name: "Continue without OpenCode",
|
|
1838
|
-
value: "continue",
|
|
1839
|
-
description: "Requests will fail until OpenCode starts"
|
|
1840
|
-
}
|
|
1841
|
-
]
|
|
1842
|
-
});
|
|
1843
|
-
if (action === "manual") {
|
|
1844
|
-
blank();
|
|
1845
|
-
console.log(chalk5.bold("Run this command in another terminal:"));
|
|
1846
|
-
blank();
|
|
1847
|
-
console.log(` ${chalk5.cyan(`opencode serve --port ${actualPort}`)}`);
|
|
1848
|
-
blank();
|
|
1849
|
-
throw new Error("Please start OpenCode manually");
|
|
1850
|
-
}
|
|
1851
|
-
if (action === "start") {
|
|
1852
|
-
const spinner = ora2("Starting OpenCode...").start();
|
|
1853
|
-
state.opencodeProcess = await startOpenCode(actualPort);
|
|
1854
|
-
const health = await waitForOpenCodeHealth(actualPort, 3e4);
|
|
1855
|
-
if (!health.healthy) {
|
|
1856
|
-
spinner.fail("Failed to start OpenCode");
|
|
1857
|
-
throw new Error("OpenCode failed to start");
|
|
1858
|
-
}
|
|
1859
|
-
spinner.succeed(
|
|
1860
|
-
`OpenCode running on port ${actualPort}${health.version ? ` (v${health.version})` : ""}`
|
|
1861
|
-
);
|
|
1862
|
-
state.opencodeConnected = true;
|
|
1863
|
-
state.opencodeVersion = health.version ?? null;
|
|
1864
|
-
}
|
|
1865
|
-
} else {
|
|
1866
|
-
log(state, `OpenCode is not running on port ${state.port}. Starting it automatically...`);
|
|
1867
|
-
state.opencodeProcess = await startOpenCode(state.port);
|
|
1868
|
-
const health = await waitForOpenCodeHealth(state.port, 3e4);
|
|
1869
|
-
if (!health.healthy) {
|
|
1870
|
-
throw new Error(
|
|
1871
|
-
`OpenCode failed to start on port ${state.port}. Install with: npm install -g opencode-ai`
|
|
1872
|
-
);
|
|
1873
|
-
}
|
|
1874
|
-
log(
|
|
1875
|
-
state,
|
|
1876
|
-
`OpenCode started on port ${state.port}${health.version ? ` (v${health.version})` : ""}`
|
|
1877
|
-
);
|
|
1878
|
-
state.opencodeConnected = true;
|
|
1879
|
-
state.opencodeVersion = health.version ?? null;
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
2275
|
var AUTH_EXPIRED_EXIT_CODE = 77;
|
|
1883
2276
|
async function handleAuthError(state, error2) {
|
|
1884
2277
|
logActivity(state, {
|
|
@@ -1888,12 +2281,12 @@ async function handleAuthError(state, error2) {
|
|
|
1888
2281
|
if (state.interactive) displayStatus(state);
|
|
1889
2282
|
if (!state.interactive) {
|
|
1890
2283
|
blank();
|
|
1891
|
-
console.log(
|
|
1892
|
-
console.log(
|
|
2284
|
+
console.log(chalk6.red("Authentication expired"));
|
|
2285
|
+
console.log(chalk6.dim("Your authentication token is no longer valid."));
|
|
1893
2286
|
blank();
|
|
1894
|
-
console.log(
|
|
1895
|
-
console.log(
|
|
1896
|
-
console.log(
|
|
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"));
|
|
1897
2290
|
blank();
|
|
1898
2291
|
await cleanup(state);
|
|
1899
2292
|
await shutdownTelemetry();
|
|
@@ -1901,7 +2294,7 @@ async function handleAuthError(state, error2) {
|
|
|
1901
2294
|
return { success: false };
|
|
1902
2295
|
}
|
|
1903
2296
|
blank();
|
|
1904
|
-
console.log(
|
|
2297
|
+
console.log(chalk6.yellow("Your authentication has expired."));
|
|
1905
2298
|
blank();
|
|
1906
2299
|
try {
|
|
1907
2300
|
const credentials2 = await promptForLogin(
|
|
@@ -1914,285 +2307,62 @@ async function handleAuthError(state, error2) {
|
|
|
1914
2307
|
return { success: false };
|
|
1915
2308
|
}
|
|
1916
2309
|
}
|
|
1917
|
-
function
|
|
1918
|
-
if (error2 instanceof Error) {
|
|
1919
|
-
const message = error2.message.toLowerCase();
|
|
1920
|
-
return message.includes("fetch failed") || message.includes("network") || message.includes("econnrefused") || message.includes("econnreset") || message.includes("etimedout") || message.includes("socket hang up");
|
|
1921
|
-
}
|
|
1922
|
-
return false;
|
|
1923
|
-
}
|
|
1924
|
-
async function processQueue(state, authHeader, triggerReconnect) {
|
|
2310
|
+
async function driveChannels(state, driver) {
|
|
1925
2311
|
let idlePolls = 0;
|
|
1926
|
-
let currentAuthHeader = authHeader;
|
|
1927
2312
|
while (state.running) {
|
|
1928
|
-
if (state.reconnecting && state.reconnectPromise) {
|
|
1929
|
-
logActivity(state, {
|
|
1930
|
-
type: "info",
|
|
1931
|
-
message: "Waiting for tunnel reconnection..."
|
|
1932
|
-
});
|
|
2313
|
+
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
2314
|
+
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
1933
2315
|
if (state.interactive) displayStatus(state);
|
|
1934
|
-
await state.reconnectPromise;
|
|
2316
|
+
await state.connection.reconnectPromise;
|
|
1935
2317
|
}
|
|
1936
2318
|
try {
|
|
1937
|
-
const
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
state.conversationFilter ?? void 0
|
|
1941
|
-
);
|
|
1942
|
-
state.consecutiveFetchFailures = 0;
|
|
1943
|
-
if (conversations.length > 0) {
|
|
2319
|
+
const processed = await driver.drainPending();
|
|
2320
|
+
state.messageCount += processed;
|
|
2321
|
+
if (processed > 0) {
|
|
1944
2322
|
idlePolls = 0;
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
state.agentId,
|
|
1950
|
-
conv.id,
|
|
1951
|
-
state.lockCorrelationId,
|
|
1952
|
-
currentAuthHeader
|
|
1953
|
-
);
|
|
1954
|
-
if (!lockResult.acquired) {
|
|
1955
|
-
logActivity(state, {
|
|
1956
|
-
type: "info",
|
|
1957
|
-
message: `Conversation ${conv.id.slice(0, 8)} locked by another runner \u2014 skipping`
|
|
1958
|
-
});
|
|
1959
|
-
if (state.interactive) displayStatus(state);
|
|
1960
|
-
continue;
|
|
1961
|
-
}
|
|
1962
|
-
state.lockedConversations.add(conv.id);
|
|
1963
|
-
logActivity(state, {
|
|
1964
|
-
type: "info",
|
|
1965
|
-
message: `Lock acquired on conversation ${conv.id.slice(0, 8)}`
|
|
1966
|
-
});
|
|
1967
|
-
}
|
|
1968
|
-
logActivity(state, {
|
|
1969
|
-
type: "info",
|
|
1970
|
-
message: `Processing conversation ${conv.id.slice(0, 8)}... (${conv.pending_message_count} pending)`
|
|
1971
|
-
});
|
|
1972
|
-
if (state.interactive) displayStatus(state);
|
|
1973
|
-
let sessionId = state.sessions.get(conv.id);
|
|
1974
|
-
if (!sessionId) {
|
|
1975
|
-
if (conv.opencode_session_id) {
|
|
1976
|
-
sessionId = conv.opencode_session_id;
|
|
1977
|
-
} else {
|
|
1978
|
-
sessionId = await createOpenCodeSession(state.port);
|
|
1979
|
-
await updateConversationSession(state.agentId, conv.id, sessionId, currentAuthHeader);
|
|
1980
|
-
logActivity(state, {
|
|
1981
|
-
type: "info",
|
|
1982
|
-
message: `Created session ${sessionId.slice(0, 8)}`
|
|
1983
|
-
});
|
|
1984
|
-
}
|
|
1985
|
-
state.sessions.set(conv.id, sessionId);
|
|
1986
|
-
}
|
|
1987
|
-
const messages = await getPendingMessages(state.agentId, conv.id, currentAuthHeader);
|
|
1988
|
-
for (const message of messages) {
|
|
1989
|
-
if (!state.running) break;
|
|
1990
|
-
logActivity(state, {
|
|
1991
|
-
type: "info",
|
|
1992
|
-
message: `Processing message ${message.id.slice(0, 8)}...`
|
|
1993
|
-
});
|
|
1994
|
-
if (state.interactive) displayStatus(state);
|
|
1995
|
-
const claimed = await markMessageProcessing(
|
|
1996
|
-
state.agentId,
|
|
1997
|
-
conv.id,
|
|
1998
|
-
message.id,
|
|
1999
|
-
currentAuthHeader
|
|
2000
|
-
);
|
|
2001
|
-
if (!claimed) {
|
|
2002
|
-
logActivity(state, {
|
|
2003
|
-
type: "info",
|
|
2004
|
-
message: `Message ${message.id.slice(0, 8)} already claimed`
|
|
2005
|
-
});
|
|
2006
|
-
continue;
|
|
2007
|
-
}
|
|
2008
|
-
emitAgentMessageProcessing(state.agentId, {
|
|
2009
|
-
message_id: message.id,
|
|
2010
|
-
conversation_id: conv.id
|
|
2011
|
-
});
|
|
2012
|
-
try {
|
|
2013
|
-
const result = await sendMessageToOpenCode(
|
|
2014
|
-
state.port,
|
|
2015
|
-
sessionId,
|
|
2016
|
-
message.content,
|
|
2017
|
-
{
|
|
2018
|
-
agent: message.opencode_agent ?? void 0,
|
|
2019
|
-
model: message.opencode_model ?? void 0
|
|
2020
|
-
},
|
|
2021
|
-
{
|
|
2022
|
-
onQuestion: async (question) => {
|
|
2023
|
-
try {
|
|
2024
|
-
await reportInteractiveEvent(
|
|
2025
|
-
state.agentId,
|
|
2026
|
-
conv.id,
|
|
2027
|
-
"question",
|
|
2028
|
-
question,
|
|
2029
|
-
currentAuthHeader
|
|
2030
|
-
);
|
|
2031
|
-
logActivity(state, {
|
|
2032
|
-
type: "info",
|
|
2033
|
-
message: `Question surfaced to user (id: ${question.id.slice(0, 8)})`
|
|
2034
|
-
});
|
|
2035
|
-
} catch (err) {
|
|
2036
|
-
logActivity(state, {
|
|
2037
|
-
type: "error",
|
|
2038
|
-
error: `Failed to surface question: ${err}`
|
|
2039
|
-
});
|
|
2040
|
-
}
|
|
2041
|
-
},
|
|
2042
|
-
onPermission: async (permission) => {
|
|
2043
|
-
try {
|
|
2044
|
-
await reportInteractiveEvent(
|
|
2045
|
-
state.agentId,
|
|
2046
|
-
conv.id,
|
|
2047
|
-
"permission",
|
|
2048
|
-
permission,
|
|
2049
|
-
currentAuthHeader
|
|
2050
|
-
);
|
|
2051
|
-
logActivity(state, {
|
|
2052
|
-
type: "info",
|
|
2053
|
-
message: `Permission request surfaced to user (id: ${permission.id.slice(0, 8)})`
|
|
2054
|
-
});
|
|
2055
|
-
} catch (err) {
|
|
2056
|
-
logActivity(state, {
|
|
2057
|
-
type: "error",
|
|
2058
|
-
error: `Failed to surface permission: ${err}`
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
);
|
|
2064
|
-
if (result.title) {
|
|
2065
|
-
try {
|
|
2066
|
-
await updateConversationTitle(
|
|
2067
|
-
state.agentId,
|
|
2068
|
-
conv.id,
|
|
2069
|
-
result.title,
|
|
2070
|
-
currentAuthHeader
|
|
2071
|
-
);
|
|
2072
|
-
} catch {
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
await markMessageDone(
|
|
2076
|
-
state.agentId,
|
|
2077
|
-
conv.id,
|
|
2078
|
-
message.id,
|
|
2079
|
-
currentAuthHeader,
|
|
2080
|
-
sessionId
|
|
2081
|
-
);
|
|
2082
|
-
state.messageCount++;
|
|
2083
|
-
logActivity(state, {
|
|
2084
|
-
type: "info",
|
|
2085
|
-
message: `Message ${message.id.slice(0, 8)} processed`
|
|
2086
|
-
});
|
|
2087
|
-
emitAgentMessageDone(state.agentId, {
|
|
2088
|
-
message_id: message.id,
|
|
2089
|
-
conversation_id: conv.id
|
|
2090
|
-
});
|
|
2091
|
-
} catch (error2) {
|
|
2092
|
-
if (error2 instanceof AuthenticationError) {
|
|
2093
|
-
throw error2;
|
|
2094
|
-
}
|
|
2095
|
-
await markMessageFailed(state.agentId, conv.id, message.id, currentAuthHeader);
|
|
2096
|
-
logActivity(state, {
|
|
2097
|
-
type: "error",
|
|
2098
|
-
error: `Message ${message.id.slice(0, 8)} failed: ${error2}`
|
|
2099
|
-
});
|
|
2100
|
-
emitAgentMessageFailed(state.agentId, {
|
|
2101
|
-
message_id: message.id,
|
|
2102
|
-
conversation_id: conv.id,
|
|
2103
|
-
error: String(error2)
|
|
2104
|
-
});
|
|
2105
|
-
}
|
|
2106
|
-
if (state.interactive) displayStatus(state);
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
|
-
} else {
|
|
2110
|
-
if (state.idleTimeout !== null) {
|
|
2111
|
-
idlePolls++;
|
|
2112
|
-
if (idlePolls === 1) {
|
|
2113
|
-
logActivity(state, {
|
|
2114
|
-
type: "info",
|
|
2115
|
-
message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
|
|
2116
|
-
});
|
|
2117
|
-
if (state.interactive) displayStatus(state);
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
|
|
2122
|
-
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
2123
|
-
const idleMs = idlePolls * MESSAGE_POLL_INTERVAL_MS;
|
|
2124
|
-
if (idleMs > state.idleTimeout * 1e3) {
|
|
2323
|
+
if (state.interactive) displayStatus(state);
|
|
2324
|
+
} else if (state.idleTimeout !== null) {
|
|
2325
|
+
idlePolls++;
|
|
2326
|
+
if (idlePolls === 1) {
|
|
2125
2327
|
logActivity(state, {
|
|
2126
2328
|
type: "info",
|
|
2127
|
-
message:
|
|
2329
|
+
message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
|
|
2128
2330
|
});
|
|
2129
2331
|
if (state.interactive) displayStatus(state);
|
|
2130
|
-
break;
|
|
2131
2332
|
}
|
|
2132
2333
|
}
|
|
2133
2334
|
} catch (error2) {
|
|
2134
|
-
if (error2 instanceof
|
|
2335
|
+
if (error2 instanceof ChannelAuthError) {
|
|
2135
2336
|
const result = await handleAuthError(state, error2);
|
|
2136
2337
|
if (result.success && result.newAuthHeader) {
|
|
2137
|
-
currentAuthHeader = result.newAuthHeader;
|
|
2138
2338
|
state.authHeader = result.newAuthHeader;
|
|
2139
|
-
logActivity(state, {
|
|
2140
|
-
type: "info",
|
|
2141
|
-
message: "Continuing with new credentials..."
|
|
2142
|
-
});
|
|
2339
|
+
logActivity(state, { type: "info", message: "Continuing with new credentials..." });
|
|
2143
2340
|
if (state.interactive) displayStatus(state);
|
|
2144
2341
|
continue;
|
|
2145
|
-
} else {
|
|
2146
|
-
state.running = false;
|
|
2147
|
-
break;
|
|
2148
2342
|
}
|
|
2343
|
+
state.running = false;
|
|
2344
|
+
break;
|
|
2149
2345
|
}
|
|
2150
2346
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
2151
|
-
logActivity(state, {
|
|
2152
|
-
type: "error",
|
|
2153
|
-
error: `Queue processing error: ${errorMessage}`
|
|
2154
|
-
});
|
|
2347
|
+
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
2155
2348
|
if (state.interactive) displayStatus(state);
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
await triggerReconnect();
|
|
2165
|
-
state.consecutiveFetchFailures = 0;
|
|
2166
|
-
}
|
|
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;
|
|
2167
2357
|
}
|
|
2168
|
-
await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
|
|
2169
2358
|
}
|
|
2170
2359
|
}
|
|
2171
2360
|
}
|
|
2172
|
-
async function cleanup(state
|
|
2361
|
+
async function cleanup(state) {
|
|
2173
2362
|
state.running = false;
|
|
2174
|
-
if (state.
|
|
2175
|
-
|
|
2176
|
-
state.
|
|
2177
|
-
}
|
|
2178
|
-
if (authHeader && state.lockedConversations.size > 0) {
|
|
2179
|
-
for (const convId of state.lockedConversations) {
|
|
2180
|
-
await releaseConversationLock(state.agentId, convId, state.lockCorrelationId, authHeader);
|
|
2181
|
-
}
|
|
2182
|
-
if (state.interactive) {
|
|
2183
|
-
logActivity(state, {
|
|
2184
|
-
type: "info",
|
|
2185
|
-
message: `Released ${state.lockedConversations.size} lock(s)`
|
|
2186
|
-
});
|
|
2187
|
-
displayStatus(state);
|
|
2188
|
-
} else {
|
|
2189
|
-
log(state, `Released ${state.lockedConversations.size} lock(s)`);
|
|
2190
|
-
}
|
|
2191
|
-
state.lockedConversations.clear();
|
|
2192
|
-
}
|
|
2193
|
-
if (state.tunnelConnection) {
|
|
2194
|
-
state.tunnelConnection.close();
|
|
2195
|
-
state.tunnelConnection = null;
|
|
2363
|
+
if (state.connection) {
|
|
2364
|
+
state.connection.close();
|
|
2365
|
+
state.connection = null;
|
|
2196
2366
|
}
|
|
2197
2367
|
if (state.opencodeProcess) {
|
|
2198
2368
|
stopOpenCode(state.opencodeProcess);
|
|
@@ -2211,7 +2381,6 @@ async function run(options) {
|
|
|
2211
2381
|
agentId: options.agent || "",
|
|
2212
2382
|
agentName: null,
|
|
2213
2383
|
port: options.port ?? 4096,
|
|
2214
|
-
verbose: options.verbose ?? false,
|
|
2215
2384
|
conversationFilter: options.conversation ?? null,
|
|
2216
2385
|
idleTimeout: options.idleTimeout ?? null,
|
|
2217
2386
|
json: options.json ?? false,
|
|
@@ -2219,22 +2388,11 @@ async function run(options) {
|
|
|
2219
2388
|
connected: false,
|
|
2220
2389
|
opencodeConnected: false,
|
|
2221
2390
|
opencodeVersion: null,
|
|
2222
|
-
reconnectAttempt: 0,
|
|
2223
2391
|
opencodeProcess: null,
|
|
2224
|
-
|
|
2392
|
+
connection: null,
|
|
2225
2393
|
running: true,
|
|
2226
2394
|
activityLog: [],
|
|
2227
|
-
displayInitialized: false,
|
|
2228
|
-
lastActivity: /* @__PURE__ */ new Date(),
|
|
2229
|
-
pendingRequests: /* @__PURE__ */ new Map(),
|
|
2230
|
-
sessions: /* @__PURE__ */ new Map(),
|
|
2231
2395
|
messageCount: 0,
|
|
2232
|
-
lockCorrelationId: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
2233
|
-
lockedConversations: /* @__PURE__ */ new Set(),
|
|
2234
|
-
lockHeartbeatTimer: null,
|
|
2235
|
-
consecutiveFetchFailures: 0,
|
|
2236
|
-
reconnecting: false,
|
|
2237
|
-
reconnectPromise: null,
|
|
2238
2396
|
authHeader: ""
|
|
2239
2397
|
};
|
|
2240
2398
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
@@ -2251,7 +2409,7 @@ async function run(options) {
|
|
|
2251
2409
|
} else {
|
|
2252
2410
|
log(state, "Shutting down...");
|
|
2253
2411
|
}
|
|
2254
|
-
await cleanup(state
|
|
2412
|
+
await cleanup(state);
|
|
2255
2413
|
await shutdownTelemetry();
|
|
2256
2414
|
process.exit(0);
|
|
2257
2415
|
};
|
|
@@ -2263,13 +2421,13 @@ async function run(options) {
|
|
|
2263
2421
|
if (!interactive) {
|
|
2264
2422
|
printError("Authentication required");
|
|
2265
2423
|
blank();
|
|
2266
|
-
console.log(
|
|
2267
|
-
console.log(
|
|
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"));
|
|
2268
2426
|
blank();
|
|
2269
2427
|
process.exit(1);
|
|
2270
2428
|
}
|
|
2271
2429
|
blank();
|
|
2272
|
-
console.log(
|
|
2430
|
+
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
2273
2431
|
blank();
|
|
2274
2432
|
credentials2 = await promptForLogin(
|
|
2275
2433
|
"Would you like to log in now?",
|
|
@@ -2283,6 +2441,12 @@ async function run(options) {
|
|
|
2283
2441
|
if (resolved.agent_id) {
|
|
2284
2442
|
state.agentId = resolved.agent_id;
|
|
2285
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
|
+
}
|
|
2286
2450
|
} else {
|
|
2287
2451
|
printError(resolved.error || "Failed to resolve agent ID from key");
|
|
2288
2452
|
process.exit(1);
|
|
@@ -2290,7 +2454,7 @@ async function run(options) {
|
|
|
2290
2454
|
} else {
|
|
2291
2455
|
printError("--agent is required when not using EVIDENT_AGENT_KEY");
|
|
2292
2456
|
blank();
|
|
2293
|
-
console.log(
|
|
2457
|
+
console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
|
|
2294
2458
|
blank();
|
|
2295
2459
|
process.exit(1);
|
|
2296
2460
|
}
|
|
@@ -2309,15 +2473,15 @@ async function run(options) {
|
|
|
2309
2473
|
);
|
|
2310
2474
|
if (interactive && !state.json) {
|
|
2311
2475
|
blank();
|
|
2312
|
-
console.log(
|
|
2313
|
-
console.log(
|
|
2476
|
+
console.log(chalk6.bold("Evident Run"));
|
|
2477
|
+
console.log(chalk6.dim("-".repeat(40)));
|
|
2314
2478
|
}
|
|
2315
|
-
const spinner = interactive && !state.json ?
|
|
2479
|
+
const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
|
|
2316
2480
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
2317
2481
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
2318
2482
|
spinner?.fail("Authentication failed");
|
|
2319
2483
|
blank();
|
|
2320
|
-
console.log(
|
|
2484
|
+
console.log(chalk6.yellow("Your authentication token is invalid or expired."));
|
|
2321
2485
|
blank();
|
|
2322
2486
|
credentials2 = await promptForLogin(
|
|
2323
2487
|
"Would you like to log in again?",
|
|
@@ -2333,172 +2497,129 @@ async function run(options) {
|
|
|
2333
2497
|
}
|
|
2334
2498
|
spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
|
|
2335
2499
|
state.agentName = validation.agent.name;
|
|
2336
|
-
const ocSpinner = interactive && !state.json ?
|
|
2500
|
+
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
2337
2501
|
try {
|
|
2338
|
-
await ensureOpenCodeRunning(
|
|
2502
|
+
const oc = await ensureOpenCodeRunning({
|
|
2503
|
+
port: state.port,
|
|
2504
|
+
interactive: state.interactive,
|
|
2505
|
+
agentId: state.agentId,
|
|
2506
|
+
log: (message) => log(state, message)
|
|
2507
|
+
});
|
|
2508
|
+
state.port = oc.port;
|
|
2509
|
+
state.opencodeProcess = oc.process;
|
|
2510
|
+
state.opencodeVersion = oc.version;
|
|
2511
|
+
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2339
2512
|
const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2340
2513
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
|
|
2341
2514
|
} catch (error2) {
|
|
2342
2515
|
ocSpinner?.fail(error2.message);
|
|
2343
2516
|
throw error2;
|
|
2344
2517
|
}
|
|
2345
|
-
const tunnelSpinner = interactive && !state.json ?
|
|
2346
|
-
const
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
state.
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
onDisconnected: (code, reason) => {
|
|
2378
|
-
state.connected = false;
|
|
2518
|
+
const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
|
|
2519
|
+
const channelDriver = new ChannelDriver({
|
|
2520
|
+
agentId: state.agentId,
|
|
2521
|
+
port: state.port,
|
|
2522
|
+
apiUrl: getApiUrlConfig(),
|
|
2523
|
+
getAuthHeader: () => state.authHeader,
|
|
2524
|
+
conversationFilter: state.conversationFilter,
|
|
2525
|
+
log: (entry) => logActivity(state, {
|
|
2526
|
+
type: entry.level === "error" ? "error" : "info",
|
|
2527
|
+
message: entry.message,
|
|
2528
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
2529
|
+
})
|
|
2530
|
+
});
|
|
2531
|
+
const connection = new RunnerConnection({
|
|
2532
|
+
agentId: state.agentId,
|
|
2533
|
+
getAuthHeader: () => state.authHeader,
|
|
2534
|
+
port: state.port,
|
|
2535
|
+
isRunning: () => state.running,
|
|
2536
|
+
events: {
|
|
2537
|
+
onConnected: (agentId, isReconnect) => {
|
|
2538
|
+
state.connected = true;
|
|
2539
|
+
state.agentId = agentId;
|
|
2540
|
+
logActivity(state, {
|
|
2541
|
+
type: "info",
|
|
2542
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
2543
|
+
});
|
|
2544
|
+
emitAgentConnected(state.agentId, { port: state.port });
|
|
2545
|
+
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
2546
|
+
if (state.interactive) displayStatus(state);
|
|
2547
|
+
channelDriver.drainPending().then((processed) => {
|
|
2548
|
+
if (processed > 0) {
|
|
2549
|
+
state.messageCount += processed;
|
|
2379
2550
|
logActivity(state, {
|
|
2380
2551
|
type: "info",
|
|
2381
|
-
message: `
|
|
2552
|
+
message: `Drained ${processed} queued message(s) on connect`
|
|
2382
2553
|
});
|
|
2383
|
-
emitAgentDisconnected(state.agentId, { code, reason });
|
|
2384
|
-
if (state.interactive) displayStatus(state);
|
|
2385
|
-
if (state.running && code !== 1e3 && !state.reconnecting) {
|
|
2386
|
-
logActivity(state, {
|
|
2387
|
-
type: "info",
|
|
2388
|
-
message: "Attempting automatic reconnection..."
|
|
2389
|
-
});
|
|
2390
|
-
if (state.interactive) displayStatus(state);
|
|
2391
|
-
state.reconnectPromise = connectWithRetry(true).catch((err) => {
|
|
2392
|
-
logActivity(state, {
|
|
2393
|
-
type: "error",
|
|
2394
|
-
error: `Reconnection failed: ${err.message}`
|
|
2395
|
-
});
|
|
2396
|
-
if (state.interactive) displayStatus(state);
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
},
|
|
2400
|
-
onError: (error2) => {
|
|
2401
|
-
logActivity(state, { type: "error", error: error2 });
|
|
2402
|
-
if (state.interactive) displayStatus(state);
|
|
2403
|
-
},
|
|
2404
|
-
onRequest: (method, path, requestId) => {
|
|
2405
|
-
state.pendingRequests.set(requestId, {
|
|
2406
|
-
startTime: Date.now(),
|
|
2407
|
-
method,
|
|
2408
|
-
path
|
|
2409
|
-
});
|
|
2410
|
-
logActivity(state, { type: "request", method, path, requestId });
|
|
2411
|
-
if (state.interactive) displayStatus(state);
|
|
2412
|
-
},
|
|
2413
|
-
onResponse: (status, durationMs, requestId) => {
|
|
2414
|
-
const pending = state.pendingRequests.get(requestId);
|
|
2415
|
-
state.pendingRequests.delete(requestId);
|
|
2416
|
-
state.opencodeConnected = true;
|
|
2417
|
-
const lastEntry = state.activityLog[state.activityLog.length - 1];
|
|
2418
|
-
if (lastEntry && lastEntry.requestId === requestId) {
|
|
2419
|
-
lastEntry.type = "response";
|
|
2420
|
-
lastEntry.status = status;
|
|
2421
|
-
lastEntry.durationMs = durationMs;
|
|
2422
|
-
} else if (pending) {
|
|
2423
|
-
logActivity(state, {
|
|
2424
|
-
type: "response",
|
|
2425
|
-
method: pending.method,
|
|
2426
|
-
path: pending.path,
|
|
2427
|
-
status,
|
|
2428
|
-
durationMs,
|
|
2429
|
-
requestId
|
|
2430
|
-
});
|
|
2431
|
-
}
|
|
2432
|
-
if (state.interactive) displayStatus(state);
|
|
2433
|
-
},
|
|
2434
|
-
onInfo: (message) => {
|
|
2435
|
-
logActivity(state, { type: "info", message });
|
|
2436
2554
|
if (state.interactive) displayStatus(state);
|
|
2437
2555
|
}
|
|
2556
|
+
}).catch((error2) => {
|
|
2557
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2558
|
+
logActivity(state, {
|
|
2559
|
+
type: "error",
|
|
2560
|
+
error: `Failed to drain queued messages on connect: ${message}`
|
|
2561
|
+
});
|
|
2562
|
+
if (state.interactive) displayStatus(state);
|
|
2438
2563
|
});
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
return;
|
|
2443
|
-
} catch (error2) {
|
|
2444
|
-
state.reconnectAttempt++;
|
|
2445
|
-
const delay = getReconnectDelay(state.reconnectAttempt);
|
|
2446
|
-
if (error2.message === "Unauthorized") {
|
|
2447
|
-
state.reconnecting = false;
|
|
2448
|
-
if (!isReconnect) {
|
|
2449
|
-
tunnelSpinner?.fail("Unauthorized");
|
|
2450
|
-
}
|
|
2451
|
-
throw error2;
|
|
2452
|
-
}
|
|
2564
|
+
},
|
|
2565
|
+
onDisconnected: (code, reason) => {
|
|
2566
|
+
state.connected = false;
|
|
2453
2567
|
logActivity(state, {
|
|
2454
|
-
type: "
|
|
2455
|
-
|
|
2568
|
+
type: "info",
|
|
2569
|
+
message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
|
|
2456
2570
|
});
|
|
2571
|
+
emitAgentDisconnected(state.agentId, { code, reason });
|
|
2457
2572
|
if (state.interactive) displayStatus(state);
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
state.reconnecting = false;
|
|
2462
|
-
};
|
|
2463
|
-
const triggerReconnect = async () => {
|
|
2464
|
-
if (!state.reconnecting) {
|
|
2465
|
-
state.reconnectPromise = connectWithRetry(true).catch((err) => {
|
|
2466
|
-
logActivity(state, {
|
|
2467
|
-
type: "error",
|
|
2468
|
-
error: `Reconnection failed: ${err.message}`
|
|
2469
|
-
});
|
|
2573
|
+
},
|
|
2574
|
+
onError: (error2) => {
|
|
2575
|
+
logActivity(state, { type: "error", error: error2 });
|
|
2470
2576
|
if (state.interactive) displayStatus(state);
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
state
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2577
|
+
},
|
|
2578
|
+
// Web traffic is proxied transparently; only note opencode is live.
|
|
2579
|
+
onResponse: () => {
|
|
2580
|
+
state.opencodeConnected = true;
|
|
2581
|
+
},
|
|
2582
|
+
// A channel message was queued and the api-worker pinged us over the
|
|
2583
|
+
// tunnel to drain immediately instead of waiting for the next poll tick.
|
|
2584
|
+
// Best-effort + non-fatal: mirror the on-connect drain block. A failed
|
|
2585
|
+
// drain here is logged and swallowed — the steady-state poll retries, so
|
|
2586
|
+
// a lost/failed ping can never orphan a message (§2 invariant).
|
|
2587
|
+
onDrainPing: () => {
|
|
2588
|
+
if (!state.running) return;
|
|
2589
|
+
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
2590
|
+
channelDriver.drainPending().then((processed) => {
|
|
2591
|
+
if (processed > 0) {
|
|
2592
|
+
state.messageCount += processed;
|
|
2593
|
+
logActivity(state, {
|
|
2594
|
+
type: "info",
|
|
2595
|
+
message: `Drained ${processed} queued message(s) on ping`
|
|
2596
|
+
});
|
|
2597
|
+
if (state.interactive) displayStatus(state);
|
|
2598
|
+
}
|
|
2599
|
+
}).catch((error2) => {
|
|
2600
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2601
|
+
logActivity(state, {
|
|
2602
|
+
type: "error",
|
|
2603
|
+
error: `Failed to drain queued messages on ping: ${message}`
|
|
2604
|
+
});
|
|
2605
|
+
if (state.interactive) displayStatus(state);
|
|
2490
2606
|
});
|
|
2491
|
-
|
|
2492
|
-
}
|
|
2607
|
+
},
|
|
2608
|
+
onInfo: (message) => logActivity(state, { type: "info", message })
|
|
2493
2609
|
}
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2610
|
+
});
|
|
2611
|
+
state.connection = connection;
|
|
2612
|
+
try {
|
|
2613
|
+
await connection.connect();
|
|
2614
|
+
} catch (error2) {
|
|
2615
|
+
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
2616
|
+
throw error2;
|
|
2617
|
+
}
|
|
2618
|
+
if (!interactive || state.json) {
|
|
2619
|
+
log(state, "Driving channel messages...");
|
|
2499
2620
|
}
|
|
2500
|
-
await
|
|
2501
|
-
await cleanup(state
|
|
2621
|
+
await driveChannels(state, channelDriver);
|
|
2622
|
+
await cleanup(state);
|
|
2502
2623
|
if (state.json) {
|
|
2503
2624
|
console.log(
|
|
2504
2625
|
JSON.stringify({
|
|
@@ -2512,7 +2633,7 @@ async function run(options) {
|
|
|
2512
2633
|
await shutdownTelemetry();
|
|
2513
2634
|
process.exit(0);
|
|
2514
2635
|
} catch (error2) {
|
|
2515
|
-
await cleanup(state
|
|
2636
|
+
await cleanup(state);
|
|
2516
2637
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2517
2638
|
if (state.json) {
|
|
2518
2639
|
console.log(JSON.stringify({ status: "error", error: message }));
|
|
@@ -2530,16 +2651,22 @@ async function run(options) {
|
|
|
2530
2651
|
|
|
2531
2652
|
// src/index.ts
|
|
2532
2653
|
var program = new Command();
|
|
2533
|
-
program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
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);
|
|
2537
2664
|
}
|
|
2538
2665
|
});
|
|
2539
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);
|
|
2540
|
-
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 }));
|
|
2541
2668
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
2542
|
-
program.command("run").description("Connect to Evident and process messages").
|
|
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(
|
|
2543
2670
|
(options) => {
|
|
2544
2671
|
run({
|
|
2545
2672
|
agent: options.agent,
|