@evident-ai/cli 0.2.1-dev.fab83f9 → 3.0.1-dev.0590aa5
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 +131 -80
- package/dist/index.js +1685 -1226
- 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
|
});
|
|
@@ -691,6 +684,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
691
684
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
692
685
|
}
|
|
693
686
|
|
|
687
|
+
// src/lib/opencode/opencode-version-gate.ts
|
|
688
|
+
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
|
|
689
|
+
function isQueueValidatedVersion(version) {
|
|
690
|
+
if (!version) return false;
|
|
691
|
+
return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version);
|
|
692
|
+
}
|
|
693
|
+
function buildOpenCodeVersionWarning(version) {
|
|
694
|
+
if (isQueueValidatedVersion(version)) return null;
|
|
695
|
+
const detected = version ? `v${version}` : "unknown";
|
|
696
|
+
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
697
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
698
|
+
}
|
|
699
|
+
|
|
694
700
|
// src/lib/opencode/process.ts
|
|
695
701
|
import { execSync, spawn } from "child_process";
|
|
696
702
|
var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
|
|
@@ -844,12 +850,12 @@ async function findHealthyOpenCodeInstances() {
|
|
|
844
850
|
}
|
|
845
851
|
async function startOpenCode(port) {
|
|
846
852
|
let command = "opencode";
|
|
847
|
-
let args = ["serve", "--port", port.toString()];
|
|
853
|
+
let args = ["serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
848
854
|
try {
|
|
849
855
|
execSync("which opencode", { stdio: "ignore" });
|
|
850
856
|
} catch {
|
|
851
857
|
command = "npx";
|
|
852
|
-
args = ["opencode", "serve", "--port", port.toString()];
|
|
858
|
+
args = ["opencode", "serve", "--port", port.toString(), "--hostname", "127.0.0.1"];
|
|
853
859
|
}
|
|
854
860
|
const child = spawn(command, args, {
|
|
855
861
|
detached: true,
|
|
@@ -981,20 +987,68 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
981
987
|
}
|
|
982
988
|
|
|
983
989
|
// src/lib/opencode/session.ts
|
|
984
|
-
|
|
985
|
-
|
|
990
|
+
function opencodeBase(port) {
|
|
991
|
+
return `http://127.0.0.1:${port}`;
|
|
992
|
+
}
|
|
993
|
+
async function getOpenCodeDirectory(port) {
|
|
994
|
+
try {
|
|
995
|
+
const res = await fetch(`${opencodeBase(port)}/path`);
|
|
996
|
+
if (!res.ok) return null;
|
|
997
|
+
const body = await res.json();
|
|
998
|
+
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;
|
|
999
|
+
return dir && dir.trim() ? dir.trim() : null;
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function roleOf(m) {
|
|
1005
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1006
|
+
if (typeof m.role === "string") return m.role;
|
|
1007
|
+
const infoRole = m.info?.role;
|
|
1008
|
+
return typeof infoRole === "string" ? infoRole : void 0;
|
|
1009
|
+
}
|
|
1010
|
+
function completedOf(m) {
|
|
1011
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1012
|
+
return m.info?.time?.completed;
|
|
1013
|
+
}
|
|
1014
|
+
function idOf(m) {
|
|
1015
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1016
|
+
if (typeof m.id === "string") return m.id;
|
|
1017
|
+
const infoId = m.info?.id;
|
|
1018
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1019
|
+
}
|
|
1020
|
+
function parentIdOf(m) {
|
|
1021
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1022
|
+
if (typeof m.parentID === "string") return m.parentID;
|
|
1023
|
+
const infoParent = m.info?.parentID;
|
|
1024
|
+
return typeof infoParent === "string" ? infoParent : void 0;
|
|
1025
|
+
}
|
|
1026
|
+
function finishOf(m) {
|
|
1027
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1028
|
+
if (typeof m.finish === "string") return m.finish;
|
|
1029
|
+
const infoFinish = m.info?.finish;
|
|
1030
|
+
return typeof infoFinish === "string" ? infoFinish : void 0;
|
|
1031
|
+
}
|
|
1032
|
+
async function createOpenCodeSession(port, directory) {
|
|
1033
|
+
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1034
|
+
if (directory && directory.trim()) {
|
|
1035
|
+
url.searchParams.set("directory", directory.trim());
|
|
1036
|
+
}
|
|
1037
|
+
const response = await fetch(url, {
|
|
986
1038
|
method: "POST",
|
|
987
1039
|
headers: { "Content-Type": "application/json" },
|
|
988
1040
|
body: JSON.stringify({})
|
|
989
1041
|
});
|
|
990
1042
|
if (!response.ok) {
|
|
991
|
-
|
|
1043
|
+
const text = await response.text().catch(() => "");
|
|
1044
|
+
throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
|
|
992
1045
|
}
|
|
993
1046
|
const data = await response.json();
|
|
994
1047
|
return data.id;
|
|
995
1048
|
}
|
|
996
|
-
async function
|
|
1049
|
+
async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
997
1050
|
const body = {
|
|
1051
|
+
messageID: messageId,
|
|
998
1052
|
parts: [{ type: "text", text: content }]
|
|
999
1053
|
};
|
|
1000
1054
|
if (options?.agent) {
|
|
@@ -1009,220 +1063,206 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1009
1063
|
};
|
|
1010
1064
|
}
|
|
1011
1065
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
() => null
|
|
1067
|
-
);
|
|
1068
|
-
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
1069
|
-
return { title: session?.title };
|
|
1070
|
-
} catch (err) {
|
|
1071
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
1072
|
-
throw new Error("Message processing timed out");
|
|
1073
|
-
}
|
|
1074
|
-
throw err;
|
|
1075
|
-
} finally {
|
|
1076
|
-
clearTimeout(timer);
|
|
1077
|
-
pollDone = true;
|
|
1078
|
-
}
|
|
1079
|
-
};
|
|
1080
|
-
const [result] = await Promise.all([sendMessage(), pollInteractive()]);
|
|
1081
|
-
return result;
|
|
1066
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers: { "Content-Type": "application/json" },
|
|
1069
|
+
body: JSON.stringify(body)
|
|
1070
|
+
});
|
|
1071
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1072
|
+
const text = await res.text().catch(() => "");
|
|
1073
|
+
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1077
|
+
if (!messages || messages.length === 0) return null;
|
|
1078
|
+
const byParent = messages.find(
|
|
1079
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1080
|
+
);
|
|
1081
|
+
if (byParent) return byParent;
|
|
1082
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1083
|
+
if (userIndex === -1) return null;
|
|
1084
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1085
|
+
if (roleOf(messages[i]) === "assistant") return messages[i];
|
|
1086
|
+
}
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1090
|
+
if (!messages || messages.length === 0) return null;
|
|
1091
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1092
|
+
const m = messages[i];
|
|
1093
|
+
if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
|
|
1094
|
+
}
|
|
1095
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1096
|
+
if (userIndex === -1) return null;
|
|
1097
|
+
let last = null;
|
|
1098
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1099
|
+
const role = roleOf(messages[i]);
|
|
1100
|
+
if (role === "user") break;
|
|
1101
|
+
if (role === "assistant") last = messages[i];
|
|
1102
|
+
}
|
|
1103
|
+
return last;
|
|
1104
|
+
}
|
|
1105
|
+
function messageRunState(messages, userMessageId) {
|
|
1106
|
+
if (!messages || messages.length === 0) return "unknown";
|
|
1107
|
+
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
1108
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1109
|
+
if (!hasUser) {
|
|
1110
|
+
if (!reply) return "unknown";
|
|
1111
|
+
}
|
|
1112
|
+
if (!reply) return "queued";
|
|
1113
|
+
if (completedOf(reply) == null) return "running";
|
|
1114
|
+
if (finishOf(reply) === "tool-calls") return "running";
|
|
1115
|
+
return "done";
|
|
1116
|
+
}
|
|
1117
|
+
function opencodeMessageIdFor(queuedMessageId) {
|
|
1118
|
+
const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
|
|
1119
|
+
return `msg_${sanitized}`;
|
|
1082
1120
|
}
|
|
1083
1121
|
|
|
1084
1122
|
// src/lib/tunnel/connection.ts
|
|
1085
|
-
import
|
|
1123
|
+
import WebSocket2 from "ws";
|
|
1086
1124
|
|
|
1087
1125
|
// 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
|
-
|
|
1126
|
+
import WebSocket from "ws";
|
|
1127
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
1128
|
+
var STRIP_REQ = /* @__PURE__ */ new Set([
|
|
1129
|
+
"host",
|
|
1130
|
+
"connection",
|
|
1131
|
+
"keep-alive",
|
|
1132
|
+
"proxy-authorization",
|
|
1133
|
+
"transfer-encoding",
|
|
1134
|
+
"upgrade",
|
|
1135
|
+
"content-length"
|
|
1136
|
+
]);
|
|
1137
|
+
var STRIP_RES = /* @__PURE__ */ new Set([
|
|
1138
|
+
"connection",
|
|
1139
|
+
"keep-alive",
|
|
1140
|
+
"transfer-encoding",
|
|
1141
|
+
"content-encoding",
|
|
1142
|
+
"content-length"
|
|
1143
|
+
]);
|
|
1144
|
+
var StreamForwarder = class {
|
|
1145
|
+
constructor(ws, port, callbacks = {}) {
|
|
1146
|
+
this.ws = ws;
|
|
1147
|
+
this.port = port;
|
|
1148
|
+
this.callbacks = callbacks;
|
|
1149
|
+
}
|
|
1150
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1151
|
+
/**
|
|
1152
|
+
* Handle an edge→agent frame. Unknown frame types are ignored.
|
|
1153
|
+
*/
|
|
1154
|
+
handleFrame(frame) {
|
|
1155
|
+
switch (frame.type) {
|
|
1156
|
+
case "open":
|
|
1157
|
+
this.callbacks.onOpen?.(frame.sid, frame.method, frame.path);
|
|
1158
|
+
void this.handleOpen(frame);
|
|
1159
|
+
break;
|
|
1160
|
+
case "req_data":
|
|
1161
|
+
this.inflight.get(frame.sid)?.pushBody?.(Buffer.from(frame.b64, "base64"));
|
|
1162
|
+
break;
|
|
1163
|
+
case "req_end":
|
|
1164
|
+
this.inflight.get(frame.sid)?.endBody?.();
|
|
1165
|
+
break;
|
|
1166
|
+
case "abort":
|
|
1167
|
+
this.inflight.get(frame.sid)?.abort?.();
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1173
|
+
*/
|
|
1174
|
+
abortAll() {
|
|
1175
|
+
for (const stream of this.inflight.values()) {
|
|
1107
1176
|
try {
|
|
1108
|
-
|
|
1177
|
+
stream.abort();
|
|
1109
1178
|
} catch {
|
|
1110
|
-
body = text;
|
|
1111
1179
|
}
|
|
1112
|
-
} else {
|
|
1113
|
-
body = text;
|
|
1114
1180
|
}
|
|
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
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
function sendResponse(ws, requestId, response) {
|
|
1128
|
-
const bodyStr = JSON.stringify(response.body ?? null);
|
|
1129
|
-
const bodyBytes = Buffer.from(bodyStr, "utf-8");
|
|
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;
|
|
1139
|
-
}
|
|
1140
|
-
sendResponseAsChunks(ws, requestId, response, bodyBytes);
|
|
1141
|
-
}
|
|
1142
|
-
function sendResponseAsChunks(ws, requestId, response, bodyBytes) {
|
|
1143
|
-
const chunks = splitIntoChunks(bodyBytes, CHUNK_SIZE);
|
|
1144
|
-
ws.send(
|
|
1145
|
-
JSON.stringify({
|
|
1146
|
-
type: "response_start",
|
|
1147
|
-
id: requestId,
|
|
1148
|
-
total_chunks: chunks.length,
|
|
1149
|
-
total_size: bodyBytes.length,
|
|
1150
|
-
payload: {
|
|
1151
|
-
status: response.status,
|
|
1152
|
-
headers: response.headers
|
|
1153
|
-
}
|
|
1154
|
-
})
|
|
1155
|
-
);
|
|
1156
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
1157
|
-
ws.send(
|
|
1158
|
-
JSON.stringify({
|
|
1159
|
-
type: "response_chunk",
|
|
1160
|
-
id: requestId,
|
|
1161
|
-
chunk_index: i,
|
|
1162
|
-
data: chunks[i].toString("base64")
|
|
1163
|
-
})
|
|
1164
|
-
);
|
|
1181
|
+
this.inflight.clear();
|
|
1165
1182
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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));
|
|
1183
|
+
send(frame) {
|
|
1184
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
1185
|
+
this.ws.send(JSON.stringify(frame));
|
|
1186
|
+
}
|
|
1177
1187
|
}
|
|
1178
|
-
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1188
|
+
async handleOpen(frame) {
|
|
1189
|
+
const { sid, method, path, headers, has_body } = frame;
|
|
1190
|
+
if (path === TUNNEL_DRAIN_PING_PATH) {
|
|
1191
|
+
this.callbacks.onDrainPing?.();
|
|
1192
|
+
this.send({ type: "head", sid, status: 204, headers: {} });
|
|
1193
|
+
this.send({ type: "res_end", sid });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const ac = new AbortController();
|
|
1197
|
+
let bodyPromise;
|
|
1198
|
+
let pushBody;
|
|
1199
|
+
let endBody;
|
|
1200
|
+
if (has_body) {
|
|
1201
|
+
const chunks = [];
|
|
1202
|
+
bodyPromise = new Promise((resolve) => {
|
|
1203
|
+
pushBody = (buf) => {
|
|
1204
|
+
chunks.push(buf);
|
|
1205
|
+
};
|
|
1206
|
+
endBody = () => {
|
|
1207
|
+
resolve(Buffer.concat(chunks));
|
|
1208
|
+
};
|
|
1209
|
+
});
|
|
1191
1210
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1211
|
+
const fwdHeaders = {};
|
|
1212
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
1213
|
+
if (!STRIP_REQ.has(k.toLowerCase())) fwdHeaders[k] = v;
|
|
1194
1214
|
}
|
|
1195
|
-
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1215
|
+
this.inflight.set(sid, { pushBody, endBody, abort: () => ac.abort() });
|
|
1216
|
+
const body = bodyPromise ? await bodyPromise : void 0;
|
|
1217
|
+
if (ac.signal.aborted) {
|
|
1218
|
+
this.inflight.delete(sid);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
let upstream;
|
|
1222
|
+
try {
|
|
1223
|
+
upstream = await fetch(`http://${LOOPBACK_HOST}:${this.port}${path}`, {
|
|
1224
|
+
method,
|
|
1225
|
+
headers: fwdHeaders,
|
|
1226
|
+
body,
|
|
1227
|
+
redirect: "manual",
|
|
1228
|
+
signal: ac.signal
|
|
1229
|
+
});
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
this.inflight.delete(sid);
|
|
1232
|
+
if (!ac.signal.aborted) {
|
|
1233
|
+
this.send({ type: "res_err", sid, message: `upstream fetch failed: ${String(err)}` });
|
|
1203
1234
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const resHeaders = {};
|
|
1238
|
+
upstream.headers.forEach((value, key) => {
|
|
1239
|
+
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1240
|
+
});
|
|
1241
|
+
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1242
|
+
this.callbacks.onHead?.(sid, upstream.status);
|
|
1243
|
+
try {
|
|
1244
|
+
if (upstream.body) {
|
|
1245
|
+
const reader = upstream.body.getReader();
|
|
1246
|
+
while (true) {
|
|
1247
|
+
const { done, value } = await reader.read();
|
|
1248
|
+
if (done) break;
|
|
1249
|
+
const chunk = Buffer.from(value);
|
|
1250
|
+
for (let i = 0; i < chunk.length; i += MAX_FRAME_BYTES) {
|
|
1251
|
+
const slice = chunk.subarray(i, i + MAX_FRAME_BYTES);
|
|
1252
|
+
this.send({ type: "res_data", sid, b64: slice.toString("base64") });
|
|
1213
1253
|
}
|
|
1214
1254
|
}
|
|
1215
1255
|
}
|
|
1256
|
+
this.send({ type: "res_end", sid });
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
if (!ac.signal.aborted) {
|
|
1259
|
+
this.send({ type: "res_err", sid, message: String(err) });
|
|
1260
|
+
}
|
|
1261
|
+
} finally {
|
|
1262
|
+
this.inflight.delete(sid);
|
|
1216
1263
|
}
|
|
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
1264
|
}
|
|
1225
|
-
}
|
|
1265
|
+
};
|
|
1226
1266
|
|
|
1227
1267
|
// src/lib/tunnel/connection.ts
|
|
1228
1268
|
var MAX_RECONNECT_DELAY = 3e4;
|
|
@@ -1232,6 +1272,33 @@ function getReconnectDelay(attempt) {
|
|
|
1232
1272
|
const jitter = Math.random() * 1e3;
|
|
1233
1273
|
return Math.min(exponentialDelay + jitter, MAX_RECONNECT_DELAY);
|
|
1234
1274
|
}
|
|
1275
|
+
function describeSocketError(error2, url) {
|
|
1276
|
+
const code = error2.code;
|
|
1277
|
+
switch (code) {
|
|
1278
|
+
case "ECONNREFUSED":
|
|
1279
|
+
return `connection refused at ${url} \u2014 is the tunnel relay running? (ECONNREFUSED)`;
|
|
1280
|
+
case "ENOTFOUND":
|
|
1281
|
+
return `host not found for ${url} \u2014 check the tunnel URL (ENOTFOUND)`;
|
|
1282
|
+
case "ETIMEDOUT":
|
|
1283
|
+
return `connection timed out to ${url} (ETIMEDOUT)`;
|
|
1284
|
+
case "ECONNRESET":
|
|
1285
|
+
return `connection reset by ${url} (ECONNRESET)`;
|
|
1286
|
+
default: {
|
|
1287
|
+
const base = error2.message?.trim();
|
|
1288
|
+
const suffix = code ? ` (${code})` : "";
|
|
1289
|
+
return `${base && base.length > 0 ? base : "socket error"}${suffix} connecting to ${url}`;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
var STREAM_FRAME_TYPES = /* @__PURE__ */ new Set([
|
|
1294
|
+
"open",
|
|
1295
|
+
"req_data",
|
|
1296
|
+
"req_end",
|
|
1297
|
+
"abort"
|
|
1298
|
+
]);
|
|
1299
|
+
function isStreamFrame(message) {
|
|
1300
|
+
return STREAM_FRAME_TYPES.has(message.type);
|
|
1301
|
+
}
|
|
1235
1302
|
function connectTunnel(options) {
|
|
1236
1303
|
const {
|
|
1237
1304
|
agentId,
|
|
@@ -1242,330 +1309,1218 @@ function connectTunnel(options) {
|
|
|
1242
1309
|
onError,
|
|
1243
1310
|
onRequest,
|
|
1244
1311
|
onResponse,
|
|
1245
|
-
onInfo
|
|
1312
|
+
onInfo,
|
|
1313
|
+
onDrainPing
|
|
1246
1314
|
} = options;
|
|
1247
1315
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1248
1316
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
1249
|
-
const activeEventSubscriptions = /* @__PURE__ */ new Map();
|
|
1250
1317
|
return new Promise((resolve, reject) => {
|
|
1251
|
-
const ws = new
|
|
1318
|
+
const ws = new WebSocket2(url, {
|
|
1252
1319
|
headers: {
|
|
1253
1320
|
Authorization: authHeader
|
|
1254
1321
|
}
|
|
1255
1322
|
});
|
|
1323
|
+
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1324
|
+
const forwarder = new StreamForwarder(ws, port, {
|
|
1325
|
+
onOpen: (sid, method, path) => {
|
|
1326
|
+
if (path === TUNNEL_DRAIN_PING_PATH) return;
|
|
1327
|
+
streamStartTimes.set(sid, Date.now());
|
|
1328
|
+
onRequest?.(method, path, sid);
|
|
1329
|
+
},
|
|
1330
|
+
onHead: (sid, status) => {
|
|
1331
|
+
const startedAt = streamStartTimes.get(sid);
|
|
1332
|
+
streamStartTimes.delete(sid);
|
|
1333
|
+
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1334
|
+
},
|
|
1335
|
+
onDrainPing: () => onDrainPing?.()
|
|
1336
|
+
});
|
|
1256
1337
|
const connectionTimeout = setTimeout(() => {
|
|
1257
1338
|
ws.close();
|
|
1258
1339
|
reject(new Error("Connection timeout"));
|
|
1259
1340
|
}, 3e4);
|
|
1341
|
+
let upgradeRejection = null;
|
|
1342
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
1343
|
+
clearTimeout(connectionTimeout);
|
|
1344
|
+
const chunks = [];
|
|
1345
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
1346
|
+
res.on("end", () => {
|
|
1347
|
+
const bodyRaw = Buffer.concat(chunks).toString("utf8").trim();
|
|
1348
|
+
let detail = bodyRaw;
|
|
1349
|
+
try {
|
|
1350
|
+
const parsed = JSON.parse(bodyRaw);
|
|
1351
|
+
detail = parsed.error ?? parsed.message ?? bodyRaw;
|
|
1352
|
+
if (parsed.details) detail += ` (${parsed.details})`;
|
|
1353
|
+
} catch {
|
|
1354
|
+
}
|
|
1355
|
+
const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
|
|
1356
|
+
upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
|
|
1357
|
+
onError?.(`Tunnel refused by relay (${upgradeRejection})`);
|
|
1358
|
+
reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1260
1361
|
ws.on("open", () => {
|
|
1261
1362
|
onInfo?.("WebSocket connection established");
|
|
1262
1363
|
});
|
|
1263
|
-
ws.on("message",
|
|
1364
|
+
ws.on("message", (data) => {
|
|
1365
|
+
let message;
|
|
1264
1366
|
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
|
-
}
|
|
1367
|
+
message = JSON.parse(data.toString());
|
|
1323
1368
|
} catch (error2) {
|
|
1324
1369
|
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1325
1370
|
onError?.(`Failed to handle message: ${errorMessage}`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (isStreamFrame(message)) {
|
|
1374
|
+
forwarder.handleFrame(message);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
switch (message.type) {
|
|
1378
|
+
case "connected": {
|
|
1379
|
+
clearTimeout(connectionTimeout);
|
|
1380
|
+
const connectedAgentId = message.agent_id ?? agentId;
|
|
1381
|
+
onConnected?.(connectedAgentId);
|
|
1382
|
+
resolve({
|
|
1383
|
+
ws,
|
|
1384
|
+
close: () => ws.close(1e3, "CLI shutdown")
|
|
1385
|
+
});
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
case "error":
|
|
1389
|
+
clearTimeout(connectionTimeout);
|
|
1390
|
+
onError?.(message.message || "Unknown tunnel error");
|
|
1391
|
+
if (message.code === "unauthorized") {
|
|
1392
|
+
ws.close();
|
|
1393
|
+
reject(new Error("Unauthorized"));
|
|
1394
|
+
}
|
|
1395
|
+
break;
|
|
1396
|
+
case "ping":
|
|
1397
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
1398
|
+
break;
|
|
1326
1399
|
}
|
|
1327
1400
|
});
|
|
1328
1401
|
ws.on("error", (error2) => {
|
|
1329
1402
|
clearTimeout(connectionTimeout);
|
|
1330
|
-
|
|
1331
|
-
|
|
1403
|
+
const detail = upgradeRejection ?? describeSocketError(error2, url);
|
|
1404
|
+
onError?.(`Connection error: ${detail}`);
|
|
1405
|
+
reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
|
|
1332
1406
|
});
|
|
1333
1407
|
ws.on("close", (code, reason) => {
|
|
1334
|
-
const reasonStr = reason.toString() || "No reason provided";
|
|
1408
|
+
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1409
|
+
forwarder.abortAll();
|
|
1410
|
+
streamStartTimes.clear();
|
|
1335
1411
|
onDisconnected?.(code, reasonStr);
|
|
1336
|
-
for (const [, controller] of activeEventSubscriptions) {
|
|
1337
|
-
controller.abort();
|
|
1338
|
-
}
|
|
1339
|
-
activeEventSubscriptions.clear();
|
|
1340
1412
|
});
|
|
1341
1413
|
});
|
|
1342
1414
|
}
|
|
1343
1415
|
|
|
1344
|
-
// src/
|
|
1345
|
-
var
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1416
|
+
// src/lib/tunnel/runner-connection.ts
|
|
1417
|
+
var RunnerConnection = class {
|
|
1418
|
+
opts;
|
|
1419
|
+
sleep;
|
|
1420
|
+
connection = null;
|
|
1421
|
+
resolvedAgentId;
|
|
1422
|
+
/** True while a (re)connect loop is in flight. */
|
|
1423
|
+
reconnecting = false;
|
|
1424
|
+
/** The in-flight reconnect promise, awaitable by the caller. */
|
|
1425
|
+
reconnectPromise = null;
|
|
1426
|
+
/** 1-based count of the current reconnect attempt streak. */
|
|
1427
|
+
reconnectAttempt = 0;
|
|
1428
|
+
constructor(opts) {
|
|
1429
|
+
this.opts = opts;
|
|
1430
|
+
this.resolvedAgentId = opts.agentId;
|
|
1431
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1432
|
+
}
|
|
1433
|
+
get agentId() {
|
|
1434
|
+
return this.resolvedAgentId;
|
|
1435
|
+
}
|
|
1436
|
+
/** Establish the initial tunnel connection (with retry/backoff). */
|
|
1437
|
+
async connect() {
|
|
1438
|
+
await this.connectWithRetry(false);
|
|
1439
|
+
}
|
|
1440
|
+
/** Close the active connection (idempotent). */
|
|
1441
|
+
close() {
|
|
1442
|
+
if (this.connection) {
|
|
1443
|
+
try {
|
|
1444
|
+
this.connection.close();
|
|
1445
|
+
} catch {
|
|
1446
|
+
}
|
|
1447
|
+
this.connection = null;
|
|
1361
1448
|
}
|
|
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
1449
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1450
|
+
async connectWithRetry(isReconnect) {
|
|
1451
|
+
if (isReconnect && this.reconnecting) return;
|
|
1452
|
+
this.reconnecting = true;
|
|
1453
|
+
this.close();
|
|
1454
|
+
const { events } = this.opts;
|
|
1455
|
+
while (this.opts.isRunning()) {
|
|
1456
|
+
try {
|
|
1457
|
+
this.connection = await connectTunnel({
|
|
1458
|
+
agentId: this.resolvedAgentId,
|
|
1459
|
+
authHeader: this.opts.getAuthHeader(),
|
|
1460
|
+
port: this.opts.port,
|
|
1461
|
+
onConnected: (agentId) => {
|
|
1462
|
+
this.reconnectAttempt = 0;
|
|
1463
|
+
this.reconnecting = false;
|
|
1464
|
+
this.resolvedAgentId = agentId;
|
|
1465
|
+
events.onConnected(agentId, isReconnect);
|
|
1466
|
+
},
|
|
1467
|
+
onDisconnected: (code, reason) => {
|
|
1468
|
+
events.onDisconnected(code, reason);
|
|
1469
|
+
if (this.opts.isRunning() && code !== 1e3 && !this.reconnecting) {
|
|
1470
|
+
this.reconnectPromise = this.connectWithRetry(true).catch((err) => {
|
|
1471
|
+
events.onError?.(`Reconnection failed: ${err.message}`);
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
onError: (error2) => events.onError?.(error2),
|
|
1476
|
+
onResponse: () => events.onResponse?.(),
|
|
1477
|
+
onDrainPing: () => events.onDrainPing?.(),
|
|
1478
|
+
onInfo: (message) => events.onInfo?.(message)
|
|
1479
|
+
});
|
|
1480
|
+
return;
|
|
1481
|
+
} catch (error2) {
|
|
1482
|
+
this.reconnectAttempt++;
|
|
1483
|
+
if (error2.message === "Unauthorized") {
|
|
1484
|
+
this.reconnecting = false;
|
|
1485
|
+
throw error2;
|
|
1486
|
+
}
|
|
1487
|
+
const delay = getReconnectDelay(this.reconnectAttempt);
|
|
1488
|
+
events.onReconnecting?.(this.reconnectAttempt);
|
|
1489
|
+
events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
|
|
1490
|
+
await this.sleep(delay);
|
|
1491
|
+
}
|
|
1391
1492
|
}
|
|
1392
|
-
|
|
1393
|
-
} catch (error2) {
|
|
1394
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
1395
|
-
return { valid: false, error: `Failed to validate agent: ${message}` };
|
|
1493
|
+
this.reconnecting = false;
|
|
1396
1494
|
}
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
// src/lib/channels/driver.ts
|
|
1498
|
+
function messageIdOf(m) {
|
|
1499
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1500
|
+
if (typeof m.id === "string") return m.id;
|
|
1501
|
+
const infoId = m.info?.id;
|
|
1502
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1503
|
+
}
|
|
1504
|
+
var DEFAULT_RETRY_POLICY = {
|
|
1505
|
+
maxAttempts: 6,
|
|
1506
|
+
baseDelayMs: 500,
|
|
1507
|
+
maxDelayMs: 3e4
|
|
1508
|
+
};
|
|
1509
|
+
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1510
|
+
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1511
|
+
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1512
|
+
var ChannelAuthError = class extends Error {
|
|
1399
1513
|
constructor(message) {
|
|
1400
1514
|
super(message);
|
|
1401
|
-
this.name = "
|
|
1515
|
+
this.name = "ChannelAuthError";
|
|
1402
1516
|
}
|
|
1403
1517
|
};
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
}
|
|
1411
|
-
async function getPendingConversations(agentId, authHeader, conversationFilter) {
|
|
1412
|
-
const apiUrl = getApiUrlConfig();
|
|
1413
|
-
const response = await fetch(`${apiUrl}/agents/${agentId}/conversations/pending`, {
|
|
1414
|
-
headers: { Authorization: authHeader }
|
|
1415
|
-
});
|
|
1416
|
-
checkAuthResponse(response, "fetching pending conversations");
|
|
1417
|
-
if (!response.ok) {
|
|
1418
|
-
throw new Error(`Failed to get pending conversations: HTTP ${response.status}`);
|
|
1419
|
-
}
|
|
1420
|
-
const data = await response.json();
|
|
1421
|
-
let conversations = data.conversations;
|
|
1422
|
-
if (conversationFilter) {
|
|
1423
|
-
conversations = conversations.filter((c) => c.id === conversationFilter);
|
|
1518
|
+
var ChannelTerminalError = class extends Error {
|
|
1519
|
+
status;
|
|
1520
|
+
constructor(message, status) {
|
|
1521
|
+
super(message);
|
|
1522
|
+
this.name = "ChannelTerminalError";
|
|
1523
|
+
this.status = status;
|
|
1424
1524
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
const
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1525
|
+
};
|
|
1526
|
+
function backoffDelay(attempt, policy) {
|
|
1527
|
+
const exp = policy.baseDelayMs * Math.pow(2, attempt);
|
|
1528
|
+
const capped = Math.min(policy.maxDelayMs, exp);
|
|
1529
|
+
return Math.floor(Math.random() * capped);
|
|
1530
|
+
}
|
|
1531
|
+
function isRetryableStatus(status) {
|
|
1532
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
1533
|
+
}
|
|
1534
|
+
var ChannelDriver = class {
|
|
1535
|
+
agentId;
|
|
1536
|
+
port;
|
|
1537
|
+
apiUrl;
|
|
1538
|
+
getAuthHeader;
|
|
1539
|
+
conversationFilter;
|
|
1540
|
+
retry;
|
|
1541
|
+
log;
|
|
1542
|
+
fetchImpl;
|
|
1543
|
+
sleep;
|
|
1544
|
+
pausedPollIntervalMs;
|
|
1545
|
+
pausedMaxWaitMs;
|
|
1546
|
+
dispatchConfirmMs;
|
|
1547
|
+
now;
|
|
1548
|
+
/** Cache of conversationId → opencode sessionId. */
|
|
1549
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1550
|
+
/**
|
|
1551
|
+
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1552
|
+
* session: one polling loop services all of that session's in-flight messages.
|
|
1553
|
+
* A session entry exists while it has any in-flight (dispatched-but-not-done)
|
|
1554
|
+
* message; it is removed once its in-flight set empties.
|
|
1555
|
+
*/
|
|
1556
|
+
watchers = /* @__PURE__ */ new Map();
|
|
1557
|
+
/**
|
|
1558
|
+
* AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
|
|
1559
|
+
* dispatched and are still in-flight. A message in this set is never
|
|
1560
|
+
* re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
|
|
1561
|
+
* Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
|
|
1562
|
+
* idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
|
|
1563
|
+
* a steady-state-poll re-dispatch will not double-run the message.
|
|
1564
|
+
*/
|
|
1565
|
+
dispatched = /* @__PURE__ */ new Set();
|
|
1566
|
+
/**
|
|
1567
|
+
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1568
|
+
* first session creation so drain-created sessions are rooted at the project
|
|
1569
|
+
* directory and thus visible in `opencode web`'s session list. `undefined` =
|
|
1570
|
+
* not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
|
|
1571
|
+
*/
|
|
1572
|
+
opencodeDirectory = void 0;
|
|
1573
|
+
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1574
|
+
draining = false;
|
|
1575
|
+
constructor(config2) {
|
|
1576
|
+
this.agentId = config2.agentId;
|
|
1577
|
+
this.port = config2.port;
|
|
1578
|
+
this.apiUrl = config2.apiUrl.replace(/\/$/, "");
|
|
1579
|
+
this.getAuthHeader = config2.getAuthHeader;
|
|
1580
|
+
this.conversationFilter = config2.conversationFilter ?? null;
|
|
1581
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
|
|
1582
|
+
this.log = config2.log ?? (() => {
|
|
1583
|
+
});
|
|
1584
|
+
this.fetchImpl = config2.fetchImpl ?? fetch;
|
|
1585
|
+
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1586
|
+
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1587
|
+
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1588
|
+
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1589
|
+
this.now = config2.now ?? (() => Date.now());
|
|
1590
|
+
}
|
|
1591
|
+
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1592
|
+
get opencodeBase() {
|
|
1593
|
+
return `http://127.0.0.1:${this.port}`;
|
|
1594
|
+
}
|
|
1595
|
+
// -------------------------------------------------------------------------
|
|
1596
|
+
// Public API
|
|
1597
|
+
// -------------------------------------------------------------------------
|
|
1598
|
+
/**
|
|
1599
|
+
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1600
|
+
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
1601
|
+
* Re-entrant calls while a drain is in flight are skipped (return 0).
|
|
1602
|
+
*
|
|
1603
|
+
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1604
|
+
*/
|
|
1605
|
+
async drainPending() {
|
|
1606
|
+
if (this.draining) return 0;
|
|
1607
|
+
this.draining = true;
|
|
1608
|
+
let dispatched = 0;
|
|
1609
|
+
try {
|
|
1610
|
+
const conversations = await this.getPendingConversations();
|
|
1611
|
+
if (conversations.length > 0) {
|
|
1612
|
+
const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
|
|
1613
|
+
this.log({
|
|
1614
|
+
level: "info",
|
|
1615
|
+
message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
for (const conv of conversations) {
|
|
1619
|
+
dispatched += await this.processConversation(conv);
|
|
1620
|
+
}
|
|
1621
|
+
} finally {
|
|
1622
|
+
this.draining = false;
|
|
1623
|
+
}
|
|
1624
|
+
return dispatched;
|
|
1436
1625
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
body: JSON.stringify({ status: "processing" })
|
|
1626
|
+
/**
|
|
1627
|
+
* True while any per-session watcher has a non-empty in-flight dispatched set
|
|
1628
|
+
* (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
|
|
1629
|
+
* the process while a dispatched message is still queued/running — which would
|
|
1630
|
+
* kill the turn and orphan its reply.
|
|
1631
|
+
*/
|
|
1632
|
+
hasInFlightWatchers() {
|
|
1633
|
+
for (const watcher of this.watchers.values()) {
|
|
1634
|
+
if (watcher.inFlight.size > 0) return true;
|
|
1447
1635
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1636
|
+
return false;
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Await all outstanding per-session watchers (WI-3).
|
|
1640
|
+
*
|
|
1641
|
+
* In production the watcher loops are deliberately started-not-awaited so the
|
|
1642
|
+
* drain loop never blocks on them and process exit is not held up (the cron
|
|
1643
|
+
* recovers any abandoned ones). This helper exists primarily for deterministic
|
|
1644
|
+
* tests that need to observe a watcher's effect (the `processing`/`done` PATCH
|
|
1645
|
+
* or its giving up) after a non-blocking `drainPending`. Watcher loops never
|
|
1646
|
+
* reject, so this resolves.
|
|
1647
|
+
*/
|
|
1648
|
+
async flushPausedWatchers() {
|
|
1649
|
+
while (true) {
|
|
1650
|
+
const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
|
|
1651
|
+
if (loops.length === 0) return;
|
|
1652
|
+
await Promise.all(loops);
|
|
1653
|
+
const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
|
|
1654
|
+
if (!stillLive) return;
|
|
1460
1655
|
}
|
|
1461
|
-
);
|
|
1462
|
-
checkAuthResponse(response, "reporting interactive event");
|
|
1463
|
-
if (!response.ok) {
|
|
1464
|
-
throw new Error(`Failed to report interactive event: HTTP ${response.status}`);
|
|
1465
1656
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1657
|
+
// -------------------------------------------------------------------------
|
|
1658
|
+
// Conversation processing (WI-3 — async dispatch)
|
|
1659
|
+
// -------------------------------------------------------------------------
|
|
1660
|
+
/**
|
|
1661
|
+
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1662
|
+
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
1663
|
+
* per-session watcher. Does NOT block on the turn and does NOT call
|
|
1664
|
+
* `markProcessing` here — that fires from the watcher on running-start.
|
|
1665
|
+
*
|
|
1666
|
+
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
1667
|
+
*/
|
|
1668
|
+
async processConversation(conv) {
|
|
1669
|
+
const sessionId = await this.ensureSession(conv);
|
|
1670
|
+
const messages = await this.getPendingMessages(conv.id);
|
|
1671
|
+
let dispatched = 0;
|
|
1672
|
+
for (const message of messages) {
|
|
1673
|
+
if (this.dispatched.has(message.id)) {
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
const opencodeMessageId = opencodeMessageIdFor(message.id);
|
|
1677
|
+
const options = {
|
|
1678
|
+
agent: message.opencode_agent ?? void 0,
|
|
1679
|
+
model: message.opencode_model ?? void 0
|
|
1680
|
+
};
|
|
1681
|
+
try {
|
|
1682
|
+
this.log({
|
|
1683
|
+
level: "info",
|
|
1684
|
+
message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
|
|
1685
|
+
conversation_id: conv.id,
|
|
1686
|
+
message_id: message.id
|
|
1687
|
+
});
|
|
1688
|
+
await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
|
|
1689
|
+
} catch (err) {
|
|
1690
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1691
|
+
this.dispatched.delete(message.id);
|
|
1692
|
+
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1693
|
+
});
|
|
1694
|
+
this.log({
|
|
1695
|
+
level: "error",
|
|
1696
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1697
|
+
conversation_id: conv.id,
|
|
1698
|
+
message_id: message.id
|
|
1699
|
+
});
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
this.dispatched.add(message.id);
|
|
1703
|
+
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1704
|
+
dispatched += 1;
|
|
1705
|
+
}
|
|
1706
|
+
this.ensureWatcherRunning(sessionId);
|
|
1707
|
+
return dispatched;
|
|
1708
|
+
}
|
|
1709
|
+
async ensureSession(conv) {
|
|
1710
|
+
const cached = this.sessions.get(conv.id);
|
|
1711
|
+
if (cached) return cached;
|
|
1712
|
+
if (conv.opencode_session_id) {
|
|
1713
|
+
this.sessions.set(conv.id, conv.opencode_session_id);
|
|
1714
|
+
return conv.opencode_session_id;
|
|
1715
|
+
}
|
|
1716
|
+
const directory = await this.resolveOpenCodeDirectory();
|
|
1717
|
+
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1718
|
+
this.sessions.set(conv.id, sessionId);
|
|
1719
|
+
await this.persistSession(conv.id, sessionId).catch(() => {
|
|
1720
|
+
});
|
|
1721
|
+
return sessionId;
|
|
1472
1722
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1723
|
+
/**
|
|
1724
|
+
* Lazily resolve (and cache) opencode's root directory via `GET /path`.
|
|
1725
|
+
* Resolved once per driver: `undefined` until first lookup, then the directory
|
|
1726
|
+
* string or `null` if unavailable (we don't keep retrying a missing `/path`).
|
|
1727
|
+
*/
|
|
1728
|
+
async resolveOpenCodeDirectory() {
|
|
1729
|
+
if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
|
|
1730
|
+
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
1731
|
+
if (!this.opencodeDirectory) {
|
|
1732
|
+
this.log({
|
|
1733
|
+
level: "info",
|
|
1734
|
+
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
1735
|
+
});
|
|
1479
1736
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1737
|
+
return this.opencodeDirectory;
|
|
1738
|
+
}
|
|
1739
|
+
// -------------------------------------------------------------------------
|
|
1740
|
+
// Per-session watcher (WI-3)
|
|
1741
|
+
// -------------------------------------------------------------------------
|
|
1742
|
+
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1743
|
+
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1744
|
+
let watcher = this.watchers.get(sessionId);
|
|
1745
|
+
if (!watcher) {
|
|
1746
|
+
watcher = {
|
|
1747
|
+
conv,
|
|
1748
|
+
inFlight: /* @__PURE__ */ new Map(),
|
|
1749
|
+
loop: null,
|
|
1750
|
+
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1751
|
+
reportedPermissions: /* @__PURE__ */ new Set()
|
|
1752
|
+
};
|
|
1753
|
+
this.watchers.set(sessionId, watcher);
|
|
1754
|
+
}
|
|
1755
|
+
const now = this.now();
|
|
1756
|
+
watcher.inFlight.set(message.id, {
|
|
1757
|
+
evidentMessageId: message.id,
|
|
1758
|
+
opencodeMessageId,
|
|
1759
|
+
message,
|
|
1760
|
+
dispatchedAt: now,
|
|
1761
|
+
deadline: now + this.pausedMaxWaitMs,
|
|
1762
|
+
started: false,
|
|
1763
|
+
done: false
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Start (but do NOT await) the per-session watcher loop if it has in-flight
|
|
1768
|
+
* work and is not already running. Single-flight per session. The loop is
|
|
1769
|
+
* tracked on the watcher and cleared when it settles; it never rejects (fully
|
|
1770
|
+
* guarded), so a failed poll/callback can never crash the run loop — the cron
|
|
1771
|
+
* stays as the safety net.
|
|
1772
|
+
*/
|
|
1773
|
+
ensureWatcherRunning(sessionId) {
|
|
1774
|
+
const watcher = this.watchers.get(sessionId);
|
|
1775
|
+
if (!watcher) return;
|
|
1776
|
+
if (watcher.loop) return;
|
|
1777
|
+
if (watcher.inFlight.size === 0) {
|
|
1778
|
+
this.watchers.delete(sessionId);
|
|
1779
|
+
return;
|
|
1491
1780
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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 })
|
|
1781
|
+
const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
|
|
1782
|
+
watcher.loop = null;
|
|
1783
|
+
if (watcher.inFlight.size === 0) {
|
|
1784
|
+
this.watchers.delete(sessionId);
|
|
1785
|
+
}
|
|
1502
1786
|
});
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1787
|
+
watcher.loop = loop;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The per-session polling loop (WI-3). Once per tick it:
|
|
1791
|
+
* 1. polls `GET /session/:id/message` once and, per in-flight message,
|
|
1792
|
+
* computes `messageRunState` and fires markProcessing (queued→running) /
|
|
1793
|
+
* markDone (done) exactly once per transition;
|
|
1794
|
+
* 2. applies the idle-path re-dispatch guard (a dispatched message that never
|
|
1795
|
+
* APPEARS → re-dispatch — D1 obligation 2);
|
|
1796
|
+
* 3. polls `/question` + `/permission` (scoped to the session) and surfaces
|
|
1797
|
+
* NEW ones via `reportInteraction`, carrying the PAUSED message's own
|
|
1798
|
+
* `source_message_id`;
|
|
1799
|
+
* 4. drops messages that completed or timed out from the in-flight set.
|
|
1800
|
+
* Exits when the in-flight set empties. Never throws.
|
|
1801
|
+
*/
|
|
1802
|
+
async runWatcherLoop(sessionId, watcher) {
|
|
1803
|
+
try {
|
|
1804
|
+
while (watcher.inFlight.size > 0) {
|
|
1805
|
+
await this.sleep(this.pausedPollIntervalMs);
|
|
1806
|
+
let messages = null;
|
|
1807
|
+
try {
|
|
1808
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1809
|
+
if (res.ok) {
|
|
1810
|
+
const body = await res.json();
|
|
1811
|
+
messages = Array.isArray(body) ? body : null;
|
|
1812
|
+
}
|
|
1813
|
+
} catch {
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
1817
|
+
await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
|
|
1818
|
+
}
|
|
1819
|
+
await this.pollInteractions(sessionId, watcher, messages);
|
|
1820
|
+
}
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
if (err instanceof ChannelAuthError) {
|
|
1823
|
+
this.log({
|
|
1824
|
+
level: "error",
|
|
1825
|
+
message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
|
|
1826
|
+
conversation_id: watcher.conv.id
|
|
1827
|
+
});
|
|
1828
|
+
for (const evidentMessageId of [...watcher.inFlight.keys()]) {
|
|
1829
|
+
this.removeInFlight(watcher, evidentMessageId);
|
|
1830
|
+
}
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
this.log({
|
|
1834
|
+
level: "error",
|
|
1835
|
+
message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1836
|
+
conversation_id: watcher.conv.id
|
|
1837
|
+
});
|
|
1506
1838
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
1842
|
+
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
1843
|
+
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
1844
|
+
* in-flight set on completion or timeout.
|
|
1845
|
+
*/
|
|
1846
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
1847
|
+
const conv = watcher.conv;
|
|
1848
|
+
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1849
|
+
if ((state === "running" || state === "done") && !inFlight.started) {
|
|
1850
|
+
let claimed;
|
|
1851
|
+
try {
|
|
1852
|
+
claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1855
|
+
this.log({
|
|
1856
|
+
level: "error",
|
|
1857
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1858
|
+
conversation_id: conv.id,
|
|
1859
|
+
message_id: inFlight.evidentMessageId
|
|
1860
|
+
});
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
inFlight.started = true;
|
|
1864
|
+
if (!claimed) {
|
|
1865
|
+
this.log({
|
|
1866
|
+
level: "info",
|
|
1867
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
1868
|
+
conversation_id: conv.id,
|
|
1869
|
+
message_id: inFlight.evidentMessageId
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (state === "done") {
|
|
1874
|
+
if (!inFlight.done) {
|
|
1875
|
+
this.log({
|
|
1876
|
+
level: "info",
|
|
1877
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
1878
|
+
conversation_id: conv.id,
|
|
1879
|
+
message_id: inFlight.evidentMessageId
|
|
1880
|
+
});
|
|
1881
|
+
try {
|
|
1882
|
+
await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1885
|
+
if (err instanceof ChannelTerminalError) {
|
|
1886
|
+
this.log({
|
|
1887
|
+
level: "error",
|
|
1888
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
1889
|
+
conversation_id: conv.id,
|
|
1890
|
+
message_id: inFlight.evidentMessageId
|
|
1891
|
+
});
|
|
1892
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (this.now() >= inFlight.deadline) {
|
|
1896
|
+
this.log({
|
|
1897
|
+
level: "error",
|
|
1898
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
1899
|
+
conversation_id: conv.id,
|
|
1900
|
+
message_id: inFlight.evidentMessageId
|
|
1901
|
+
});
|
|
1902
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
this.log({
|
|
1906
|
+
level: "error",
|
|
1907
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1908
|
+
conversation_id: conv.id,
|
|
1909
|
+
message_id: inFlight.evidentMessageId
|
|
1910
|
+
});
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
inFlight.done = true;
|
|
1914
|
+
}
|
|
1915
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
if (state === "unknown") {
|
|
1919
|
+
if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
|
|
1920
|
+
await this.redispatchInFlight(sessionId, inFlight);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (this.now() >= inFlight.deadline) {
|
|
1924
|
+
this.log({
|
|
1925
|
+
level: "info",
|
|
1926
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
1927
|
+
conversation_id: conv.id,
|
|
1928
|
+
message_id: inFlight.evidentMessageId
|
|
1929
|
+
});
|
|
1930
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1509
1931
|
}
|
|
1510
|
-
return { acquired: true };
|
|
1511
|
-
} catch (error2) {
|
|
1512
|
-
if (error2 instanceof AuthenticationError) throw error2;
|
|
1513
|
-
return { acquired: false, error: String(error2) };
|
|
1514
1932
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1933
|
+
/**
|
|
1934
|
+
* Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
|
|
1935
|
+
* opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
|
|
1936
|
+
* fact 9) — one user message + one reply even if the original DID land. Resets
|
|
1937
|
+
* the dispatch timestamp so the guard doesn't immediately fire again.
|
|
1938
|
+
*/
|
|
1939
|
+
async redispatchInFlight(sessionId, inFlight) {
|
|
1940
|
+
const options = {
|
|
1941
|
+
agent: inFlight.message.opencode_agent ?? void 0,
|
|
1942
|
+
model: inFlight.message.opencode_model ?? void 0
|
|
1943
|
+
};
|
|
1944
|
+
this.log({
|
|
1945
|
+
level: "info",
|
|
1946
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
|
|
1947
|
+
message_id: inFlight.evidentMessageId
|
|
1948
|
+
});
|
|
1949
|
+
try {
|
|
1950
|
+
await sendPromptAsync(
|
|
1951
|
+
this.port,
|
|
1952
|
+
sessionId,
|
|
1953
|
+
inFlight.message.content,
|
|
1954
|
+
options,
|
|
1955
|
+
inFlight.opencodeMessageId
|
|
1956
|
+
);
|
|
1957
|
+
} catch (err) {
|
|
1958
|
+
this.log({
|
|
1959
|
+
level: "error",
|
|
1960
|
+
message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1961
|
+
message_id: inFlight.evidentMessageId
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
inFlight.dispatchedAt = this.now();
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Remove a message from the in-flight set AND the authoritative dispatched
|
|
1968
|
+
* set. Once the in-flight set empties, the watcher loop's `while` guard exits
|
|
1969
|
+
* and its `.finally` removes the session entry from `this.watchers`.
|
|
1970
|
+
*/
|
|
1971
|
+
removeInFlight(watcher, evidentMessageId) {
|
|
1972
|
+
watcher.inFlight.delete(evidentMessageId);
|
|
1973
|
+
this.dispatched.delete(evidentMessageId);
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
|
|
1977
|
+
* via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
|
|
1978
|
+
* `source_message_id` so the server @mentions the correct person under
|
|
1979
|
+
* concurrency. Dedups by interaction id across ticks (reused per-session sets).
|
|
1980
|
+
*
|
|
1981
|
+
* The interaction is attributed to the in-flight message it paused on. opencode
|
|
1982
|
+
* stamps a `messageID` on a permission (and `tool.messageID` on a question) =
|
|
1983
|
+
* the assistant message id, whose `parentID` is the user message id — but the
|
|
1984
|
+
* simplest robust attribution here is: the single in-flight message that is
|
|
1985
|
+
* RUNNING (not done) is the one that paused. With one running message that is
|
|
1986
|
+
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
1987
|
+
* oldest running message.
|
|
1988
|
+
*/
|
|
1989
|
+
async pollInteractions(sessionId, watcher, messages) {
|
|
1990
|
+
let questions = [];
|
|
1991
|
+
try {
|
|
1992
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
1993
|
+
if (res.ok) {
|
|
1994
|
+
const body = await res.json();
|
|
1995
|
+
questions = Array.isArray(body) ? body : [];
|
|
1996
|
+
}
|
|
1997
|
+
} catch {
|
|
1998
|
+
}
|
|
1999
|
+
for (const q of questions) {
|
|
2000
|
+
if (q.sessionID !== sessionId) continue;
|
|
2001
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2002
|
+
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
2003
|
+
const reported = await this.reportInteraction(
|
|
2004
|
+
watcher.conv.id,
|
|
2005
|
+
"question",
|
|
2006
|
+
q,
|
|
2007
|
+
paused?.message.source_message_id ?? void 0
|
|
2008
|
+
);
|
|
2009
|
+
if (reported) watcher.reportedQuestions.add(q.id);
|
|
2010
|
+
}
|
|
2011
|
+
let permissions = [];
|
|
2012
|
+
try {
|
|
2013
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2014
|
+
if (res.ok) {
|
|
2015
|
+
const body = await res.json();
|
|
2016
|
+
permissions = Array.isArray(body) ? body : [];
|
|
2017
|
+
}
|
|
2018
|
+
} catch {
|
|
2019
|
+
}
|
|
2020
|
+
for (const p of permissions) {
|
|
2021
|
+
if (p.sessionID !== sessionId) continue;
|
|
2022
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2023
|
+
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
2024
|
+
const reported = await this.reportInteraction(
|
|
2025
|
+
watcher.conv.id,
|
|
2026
|
+
"permission",
|
|
2027
|
+
p,
|
|
2028
|
+
paused?.message.source_message_id ?? void 0
|
|
2029
|
+
);
|
|
2030
|
+
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2035
|
+
*
|
|
2036
|
+
* The interaction carries `interactionMessageId` — the ASSISTANT message id
|
|
2037
|
+
* that raised it (a question's `tool.messageID` / a permission's `messageID`).
|
|
2038
|
+
* That assistant message is the reply to ONE of our minted user messages
|
|
2039
|
+
* (correlated by `parentID`, GATE-B). So when we have the tick's message
|
|
2040
|
+
* snapshot, we resolve each running in-flight message's correlated assistant
|
|
2041
|
+
* reply (`findAssistantReplyAfter`) and match its id against
|
|
2042
|
+
* `interactionMessageId` — giving an EXACT attribution even with several
|
|
2043
|
+
* messages in flight concurrently in one session.
|
|
2044
|
+
*
|
|
2045
|
+
* We fall back to the oldest running message ONLY when no exact match is
|
|
2046
|
+
* possible (the id is absent, the snapshot is missing, or the reply has not yet
|
|
2047
|
+
* been correlated). With a single running message either path is exact. Never
|
|
2048
|
+
* throws.
|
|
2049
|
+
*
|
|
2050
|
+
* Attribution must NOT depend on our own `started` PATCH flag: opencode can
|
|
2051
|
+
* START a turn AND raise a question/permission BEFORE our next tick fires
|
|
2052
|
+
* `markProcessing` (which sets `started`). Relying on `started` would leave the
|
|
2053
|
+
* running set empty in that window and let the server fall back to "newest
|
|
2054
|
+
* processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
|
|
2055
|
+
* person whose active turn actually paused. So we derive "running" from the
|
|
2056
|
+
* tick's `messages` snapshot via `messageRunState` instead.
|
|
2057
|
+
*/
|
|
2058
|
+
attributeInteraction(watcher, interactionMessageId, messages) {
|
|
2059
|
+
const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
|
|
2060
|
+
if (inFlight.length === 0) return void 0;
|
|
2061
|
+
if (interactionMessageId && messages) {
|
|
2062
|
+
const exact = inFlight.find((m) => {
|
|
2063
|
+
const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
|
|
2064
|
+
return reply != null && messageIdOf(reply) === interactionMessageId;
|
|
2065
|
+
});
|
|
2066
|
+
if (exact) return exact;
|
|
2067
|
+
}
|
|
2068
|
+
const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
|
|
2069
|
+
if (messages) {
|
|
2070
|
+
const runningPerSnapshot = inFlight.filter(
|
|
2071
|
+
(m) => messageRunState(messages, m.opencodeMessageId) === "running"
|
|
2072
|
+
);
|
|
2073
|
+
if (runningPerSnapshot.length > 0) {
|
|
2074
|
+
return runningPerSnapshot.sort(byOldest)[0];
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const startedRunning = inFlight.filter((m) => m.started);
|
|
2078
|
+
if (startedRunning.length > 0) {
|
|
2079
|
+
return startedRunning.sort(byOldest)[0];
|
|
2080
|
+
}
|
|
2081
|
+
return inFlight.sort(byOldest)[0];
|
|
2082
|
+
}
|
|
2083
|
+
// -------------------------------------------------------------------------
|
|
2084
|
+
// Evident API calls (combinedAuth thread routes)
|
|
2085
|
+
// -------------------------------------------------------------------------
|
|
2086
|
+
async getPendingConversations() {
|
|
2087
|
+
const res = await this.fetchImpl(
|
|
2088
|
+
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
1521
2089
|
{
|
|
1522
|
-
|
|
1523
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
1524
|
-
body: JSON.stringify({ correlation_id: correlationId })
|
|
2090
|
+
headers: { Authorization: this.getAuthHeader() }
|
|
1525
2091
|
}
|
|
1526
2092
|
);
|
|
1527
|
-
|
|
1528
|
-
|
|
2093
|
+
this.assertAuth(res, "fetching pending conversations");
|
|
2094
|
+
if (!res.ok) {
|
|
2095
|
+
throw new Error(`Failed to get pending conversations: HTTP ${res.status}`);
|
|
2096
|
+
}
|
|
2097
|
+
const data = await res.json();
|
|
2098
|
+
let conversations = data.conversations;
|
|
2099
|
+
if (this.conversationFilter) {
|
|
2100
|
+
conversations = conversations.filter((c) => c.id === this.conversationFilter);
|
|
2101
|
+
}
|
|
2102
|
+
return conversations;
|
|
2103
|
+
}
|
|
2104
|
+
async getPendingMessages(conversationId) {
|
|
2105
|
+
const res = await this.fetchImpl(
|
|
2106
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
2107
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2108
|
+
);
|
|
2109
|
+
this.assertAuth(res, "fetching pending messages");
|
|
2110
|
+
if (!res.ok) {
|
|
2111
|
+
throw new Error(`Failed to get messages: HTTP ${res.status}`);
|
|
2112
|
+
}
|
|
2113
|
+
return await res.json();
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
2117
|
+
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
2118
|
+
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
2119
|
+
* deep-linked "View in Evident" notice).
|
|
2120
|
+
*
|
|
2121
|
+
* Return/throw contract (consumed by the watcher's swap-to-running guard):
|
|
2122
|
+
* - returns `true` → the server transitioned the row to processing;
|
|
2123
|
+
* - returns `false` → the server gave a DEFINITIVE "already-processing"
|
|
2124
|
+
* answer (a non-retryable, non-auth status — e.g. a
|
|
2125
|
+
* conflict because a duplicate already transitioned it),
|
|
2126
|
+
* so the caller treats it as already-started and does NOT
|
|
2127
|
+
* retry;
|
|
2128
|
+
* - throws `ChannelAuthError` on 401/403 (terminal auth failure);
|
|
2129
|
+
* - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
|
|
2130
|
+
* network-level error from `fetch`) — i.e. NO definitive server response —
|
|
2131
|
+
* so the caller leaves the message un-started and retries the swap on the
|
|
2132
|
+
* next tick.
|
|
2133
|
+
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2134
|
+
* retry vehicle for the swap-to-running.
|
|
2135
|
+
*/
|
|
2136
|
+
async markProcessing(conversationId, messageId, sessionId) {
|
|
2137
|
+
const res = await this.fetchImpl(
|
|
2138
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2139
|
+
{
|
|
2140
|
+
method: "PATCH",
|
|
2141
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2142
|
+
body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
|
|
2143
|
+
}
|
|
2144
|
+
);
|
|
2145
|
+
this.assertAuth(res, "marking message as processing");
|
|
2146
|
+
if (res.ok) return true;
|
|
2147
|
+
if (isRetryableStatus(res.status)) {
|
|
2148
|
+
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
2149
|
+
}
|
|
1529
2150
|
return false;
|
|
1530
2151
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2152
|
+
/**
|
|
2153
|
+
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
|
|
2154
|
+
* .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
2155
|
+
* `queued_conversation_messages.status`/`processed_at` gate makes a re-call
|
|
2156
|
+
* for an already-`done` message a no-op (no double Slack post). Fired by the
|
|
2157
|
+
* watcher on per-message completion (Task 3.4) — no `confirmCompletion`
|
|
2158
|
+
* round-trip (we already observed completion via the message list).
|
|
2159
|
+
*
|
|
2160
|
+
* SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
|
|
2161
|
+
* services its in-flight messages SEQUENTIALLY within a tick
|
|
2162
|
+
* (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
|
|
2163
|
+
* backoff here would BLOCK sibling messages in the SAME session/tick: while
|
|
2164
|
+
* message A's done PATCH burned its internal retries, message B could not be
|
|
2165
|
+
* swapped to running even though opencode had already started it. Instead this
|
|
2166
|
+
* does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
|
|
2167
|
+
* handler already relies on, leaning on the per-tick retry across ticks
|
|
2168
|
+
* (bounded by `inFlight.deadline`) rather than an in-call retry:
|
|
2169
|
+
* - resolves (`void`) → the server transitioned the row to done
|
|
2170
|
+
* (or idempotently confirmed already-done);
|
|
2171
|
+
* - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
|
|
2172
|
+
* cleanup, Finding 1);
|
|
2173
|
+
* - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
|
|
2174
|
+
* succeed → straight to the cron, Finding 4);
|
|
2175
|
+
* - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
|
|
2176
|
+
* (no definitive server response → the
|
|
2177
|
+
* watcher retries next tick within the
|
|
2178
|
+
* deadline, Finding 4).
|
|
2179
|
+
*/
|
|
2180
|
+
async markDone(conversationId, messageId, sessionId) {
|
|
2181
|
+
const res = await this.fetchImpl(
|
|
2182
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1537
2183
|
{
|
|
1538
|
-
method: "
|
|
1539
|
-
headers: { Authorization:
|
|
2184
|
+
method: "PATCH",
|
|
2185
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2186
|
+
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
1540
2187
|
}
|
|
1541
2188
|
);
|
|
2189
|
+
this.assertAuth(res, "marking message as done");
|
|
2190
|
+
if (res.ok) return;
|
|
2191
|
+
if (isRetryableStatus(res.status)) {
|
|
2192
|
+
throw new Error(`marking message as done: HTTP ${res.status}`);
|
|
2193
|
+
}
|
|
2194
|
+
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
2195
|
+
}
|
|
2196
|
+
async markFailed(conversationId, messageId) {
|
|
2197
|
+
await this.callWithRetry(
|
|
2198
|
+
"marking message as failed",
|
|
2199
|
+
() => this.fetchImpl(
|
|
2200
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2201
|
+
{
|
|
2202
|
+
method: "PATCH",
|
|
2203
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2204
|
+
body: JSON.stringify({ status: "failed" })
|
|
2205
|
+
}
|
|
2206
|
+
)
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
async persistSession(conversationId, sessionId) {
|
|
2210
|
+
const res = await this.fetchImpl(
|
|
2211
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
|
|
2212
|
+
{
|
|
2213
|
+
method: "PATCH",
|
|
2214
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2215
|
+
body: JSON.stringify({ opencode_session_id: sessionId })
|
|
2216
|
+
}
|
|
2217
|
+
);
|
|
2218
|
+
this.assertAuth(res, "persisting session id");
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
2222
|
+
* `POST .../interactive-event {type, data, source_message_id?}`. The server
|
|
2223
|
+
* persists the interaction and posts a link to the proxied opencode-web
|
|
2224
|
+
* conversation, @mentioning the user who triggered THIS message's turn.
|
|
2225
|
+
*
|
|
2226
|
+
* WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
|
|
2227
|
+
* ts (`message.source_message_id`). The server resolves the @mention from that
|
|
2228
|
+
* message's user FIRST (falling back to the old "newest processing" precedence
|
|
2229
|
+
* only when absent), so the correct person is mentioned under concurrency. It
|
|
2230
|
+
* is OPTIONAL for back-compat with older clients / legacy rows.
|
|
2231
|
+
*/
|
|
2232
|
+
async reportInteraction(conversationId, type, data, sourceMessageId) {
|
|
2233
|
+
try {
|
|
2234
|
+
await this.callWithRetry(
|
|
2235
|
+
"reporting interactive event",
|
|
2236
|
+
() => this.fetchImpl(
|
|
2237
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
2238
|
+
{
|
|
2239
|
+
method: "POST",
|
|
2240
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2241
|
+
body: JSON.stringify(
|
|
2242
|
+
sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
|
|
2243
|
+
)
|
|
2244
|
+
}
|
|
2245
|
+
)
|
|
2246
|
+
);
|
|
2247
|
+
this.log({
|
|
2248
|
+
level: "info",
|
|
2249
|
+
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
2250
|
+
conversation_id: conversationId
|
|
2251
|
+
});
|
|
2252
|
+
return true;
|
|
2253
|
+
} catch (err) {
|
|
2254
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
2255
|
+
this.log({
|
|
2256
|
+
level: "error",
|
|
2257
|
+
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2258
|
+
conversation_id: conversationId
|
|
2259
|
+
});
|
|
2260
|
+
return false;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
// -------------------------------------------------------------------------
|
|
2264
|
+
// Retry wrapper
|
|
2265
|
+
// -------------------------------------------------------------------------
|
|
2266
|
+
/**
|
|
2267
|
+
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
2268
|
+
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
2269
|
+
* (401/403) are terminal and surface as `ChannelAuthError`; other 4xx are
|
|
2270
|
+
* terminal too. No on-disk persistence — a crash mid-retry drops the callback
|
|
2271
|
+
* (accepted by ADR-0039).
|
|
2272
|
+
*/
|
|
2273
|
+
async callWithRetry(context, call) {
|
|
2274
|
+
let lastError;
|
|
2275
|
+
for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
|
|
2276
|
+
let res;
|
|
2277
|
+
try {
|
|
2278
|
+
res = await call();
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
lastError = err;
|
|
2281
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
2282
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
throw err;
|
|
2286
|
+
}
|
|
2287
|
+
if (res.status === 401 || res.status === 403) {
|
|
2288
|
+
throw new ChannelAuthError(
|
|
2289
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
if (res.ok) return;
|
|
2293
|
+
if (isRetryableStatus(res.status)) {
|
|
2294
|
+
lastError = new Error(`${context}: HTTP ${res.status}`);
|
|
2295
|
+
if (attempt < this.retry.maxAttempts - 1) {
|
|
2296
|
+
await this.sleep(backoffDelay(attempt, this.retry));
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
break;
|
|
2300
|
+
}
|
|
2301
|
+
throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
|
|
2302
|
+
}
|
|
2303
|
+
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
2304
|
+
}
|
|
2305
|
+
assertAuth(res, context) {
|
|
2306
|
+
if (res.status === 401 || res.status === 403) {
|
|
2307
|
+
throw new ChannelAuthError(
|
|
2308
|
+
`Authentication failed during ${context}: HTTP ${res.status}. Your session may have expired.`
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
// src/commands/ensure-opencode.ts
|
|
2315
|
+
import chalk5 from "chalk";
|
|
2316
|
+
import ora2 from "ora";
|
|
2317
|
+
import { select as select2 } from "@inquirer/prompts";
|
|
2318
|
+
async function ensureOpenCodeRunning(ctx) {
|
|
2319
|
+
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
2320
|
+
if (healthCheck.healthy) {
|
|
2321
|
+
return { port: ctx.port, process: null, version: healthCheck.version ?? null };
|
|
2322
|
+
}
|
|
2323
|
+
const runningInstances = await findHealthyOpenCodeInstances();
|
|
2324
|
+
if (runningInstances.length > 0) {
|
|
2325
|
+
if (!ctx.interactive) {
|
|
2326
|
+
throw new Error(
|
|
2327
|
+
`OpenCode not found on port ${ctx.port}, but running on port ${runningInstances[0].port}. Use --port ${runningInstances[0].port}`
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
blank();
|
|
2331
|
+
console.log(chalk5.yellow("Found OpenCode running on different port(s):"));
|
|
2332
|
+
for (const instance of runningInstances) {
|
|
2333
|
+
const ver = instance.version ? ` (v${instance.version})` : "";
|
|
2334
|
+
const cwd = instance.cwd ? ` in ${instance.cwd}` : "";
|
|
2335
|
+
console.log(chalk5.dim(` * Port ${instance.port}${ver}${cwd}`));
|
|
2336
|
+
}
|
|
2337
|
+
blank();
|
|
2338
|
+
if (runningInstances.length === 1) {
|
|
2339
|
+
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
2340
|
+
console.log(
|
|
2341
|
+
chalk5.dim(
|
|
2342
|
+
` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
2343
|
+
)
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
blank();
|
|
2347
|
+
throw new Error(`OpenCode not running on port ${ctx.port}`);
|
|
2348
|
+
}
|
|
2349
|
+
if (!isOpenCodeInstalled()) {
|
|
2350
|
+
if (!ctx.interactive) {
|
|
2351
|
+
throw new Error("OpenCode is not installed. Install it with: npm install -g opencode-ai");
|
|
2352
|
+
}
|
|
2353
|
+
const result = await promptOpenCodeInstall(true);
|
|
2354
|
+
if (result === "exit") process.exit(0);
|
|
2355
|
+
if (result !== "installed" && !isOpenCodeInstalled()) {
|
|
2356
|
+
throw new Error("OpenCode is not installed");
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
if (!ctx.interactive) {
|
|
2360
|
+
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
2361
|
+
const proc = await startOpenCode(ctx.port);
|
|
2362
|
+
const health = await waitForOpenCodeHealth(ctx.port, 3e4);
|
|
2363
|
+
if (!health.healthy) {
|
|
2364
|
+
throw new Error(
|
|
2365
|
+
`OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
|
|
2369
|
+
return { port: ctx.port, process: proc, version: health.version ?? null };
|
|
2370
|
+
}
|
|
2371
|
+
let port = ctx.port;
|
|
2372
|
+
if (isPortInUse(port)) {
|
|
2373
|
+
console.log(chalk5.yellow(`
|
|
2374
|
+
Port ${port} is already in use.`));
|
|
2375
|
+
const alternativePort = findAvailablePort(port + 1);
|
|
2376
|
+
if (alternativePort) {
|
|
2377
|
+
const useAlternative = await select2({
|
|
2378
|
+
message: `Use port ${alternativePort} instead?`,
|
|
2379
|
+
choices: [
|
|
2380
|
+
{ name: `Yes, use port ${alternativePort}`, value: "yes" },
|
|
2381
|
+
{ name: "No, I will free the port manually", value: "no" }
|
|
2382
|
+
]
|
|
2383
|
+
});
|
|
2384
|
+
if (useAlternative === "yes") {
|
|
2385
|
+
port = alternativePort;
|
|
2386
|
+
} else {
|
|
2387
|
+
throw new Error(`Port ${ctx.port} is in use`);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
const action = await select2({
|
|
2392
|
+
message: "OpenCode is not running. What would you like to do?",
|
|
2393
|
+
choices: [
|
|
2394
|
+
{
|
|
2395
|
+
name: "Start OpenCode for me",
|
|
2396
|
+
value: "start",
|
|
2397
|
+
description: `Run 'opencode serve --port ${port}'`
|
|
2398
|
+
},
|
|
2399
|
+
{
|
|
2400
|
+
name: "Show me the command",
|
|
2401
|
+
value: "manual",
|
|
2402
|
+
description: "Display the command to run manually"
|
|
2403
|
+
},
|
|
2404
|
+
{
|
|
2405
|
+
name: "Continue without OpenCode",
|
|
2406
|
+
value: "continue",
|
|
2407
|
+
description: "Requests will fail until OpenCode starts"
|
|
2408
|
+
}
|
|
2409
|
+
]
|
|
2410
|
+
});
|
|
2411
|
+
if (action === "manual") {
|
|
2412
|
+
blank();
|
|
2413
|
+
console.log(chalk5.bold("Run this command in another terminal:"));
|
|
2414
|
+
blank();
|
|
2415
|
+
console.log(` ${chalk5.cyan(`opencode serve --port ${port}`)}`);
|
|
2416
|
+
blank();
|
|
2417
|
+
throw new Error("Please start OpenCode manually");
|
|
2418
|
+
}
|
|
2419
|
+
if (action === "start") {
|
|
2420
|
+
const spinner = ora2("Starting OpenCode...").start();
|
|
2421
|
+
const proc = await startOpenCode(port);
|
|
2422
|
+
const health = await waitForOpenCodeHealth(port, 3e4);
|
|
2423
|
+
if (!health.healthy) {
|
|
2424
|
+
spinner.fail("Failed to start OpenCode");
|
|
2425
|
+
throw new Error("OpenCode failed to start");
|
|
2426
|
+
}
|
|
2427
|
+
spinner.stop();
|
|
2428
|
+
return { port, process: proc, version: health.version ?? null };
|
|
2429
|
+
}
|
|
2430
|
+
return { port, process: null, version: null };
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// src/commands/agent-lookup.ts
|
|
2434
|
+
async function readErrorMessage(response) {
|
|
2435
|
+
const text = await response.text().catch(() => "");
|
|
2436
|
+
if (!text) return response.statusText || void 0;
|
|
2437
|
+
try {
|
|
2438
|
+
const data = JSON.parse(text);
|
|
2439
|
+
const message = data.message ?? data.error;
|
|
2440
|
+
if (typeof message === "string" && message.trim()) {
|
|
2441
|
+
return message;
|
|
2442
|
+
}
|
|
1542
2443
|
} catch {
|
|
1543
2444
|
}
|
|
2445
|
+
return text.trim() || response.statusText || void 0;
|
|
2446
|
+
}
|
|
2447
|
+
function authFailureHint(apiUrl, serverMessage) {
|
|
2448
|
+
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
2449
|
+
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.`;
|
|
1544
2450
|
}
|
|
1545
|
-
async function
|
|
2451
|
+
async function resolveAgentIdFromKey(authHeader) {
|
|
1546
2452
|
const apiUrl = getApiUrlConfig();
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
2453
|
+
try {
|
|
2454
|
+
const response = await fetch(`${apiUrl}/me`, {
|
|
2455
|
+
headers: { Authorization: authHeader }
|
|
2456
|
+
});
|
|
2457
|
+
if (response.status === 401) {
|
|
2458
|
+
const serverMessage = await readErrorMessage(response);
|
|
2459
|
+
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
2460
|
+
}
|
|
2461
|
+
if (!response.ok) {
|
|
2462
|
+
const serverMessage = await readErrorMessage(response);
|
|
2463
|
+
return {
|
|
2464
|
+
error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
const data = await response.json();
|
|
2468
|
+
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
2469
|
+
return { agent_id: data.agent_id };
|
|
2470
|
+
}
|
|
2471
|
+
return {
|
|
2472
|
+
error: "Cannot resolve agent ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
2473
|
+
};
|
|
2474
|
+
} catch (error2) {
|
|
2475
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
2476
|
+
return { error: `Failed to resolve agent from key: ${message}` };
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
async function getAgentInfo(agentId, authHeader) {
|
|
2480
|
+
const apiUrl = getApiUrlConfig();
|
|
2481
|
+
try {
|
|
2482
|
+
const response = await fetch(`${apiUrl}/agents/${agentId}`, {
|
|
2483
|
+
headers: { Authorization: authHeader }
|
|
2484
|
+
});
|
|
2485
|
+
if (response.status === 401) {
|
|
2486
|
+
const serverMessage = await readErrorMessage(response);
|
|
2487
|
+
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
2488
|
+
}
|
|
2489
|
+
if (response.status === 403) {
|
|
2490
|
+
const serverMessage = await readErrorMessage(response);
|
|
2491
|
+
return {
|
|
2492
|
+
valid: false,
|
|
2493
|
+
error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
if (response.status === 404) {
|
|
2497
|
+
const serverMessage = await readErrorMessage(response);
|
|
2498
|
+
return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
|
|
2499
|
+
}
|
|
2500
|
+
if (!response.ok) {
|
|
2501
|
+
const serverMessage = await readErrorMessage(response);
|
|
2502
|
+
return {
|
|
2503
|
+
valid: false,
|
|
2504
|
+
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
const agent = await response.json();
|
|
2508
|
+
if (agent.agent_type !== "local") {
|
|
2509
|
+
return {
|
|
2510
|
+
valid: false,
|
|
2511
|
+
error: `Agent is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
return { valid: true, agent };
|
|
2515
|
+
} catch (error2) {
|
|
2516
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
2517
|
+
return { valid: false, error: `Failed to validate agent: ${message}` };
|
|
1558
2518
|
}
|
|
1559
2519
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
1565
|
-
body: JSON.stringify({ title })
|
|
1566
|
-
});
|
|
1567
|
-
checkAuthResponse(response, "updating conversation title");
|
|
1568
|
-
}
|
|
2520
|
+
|
|
2521
|
+
// src/commands/run.ts
|
|
2522
|
+
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
2523
|
+
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
1569
2524
|
function log(state, message, isError = false) {
|
|
1570
2525
|
if (state.json) {
|
|
1571
2526
|
console.log(
|
|
@@ -1576,7 +2531,7 @@ function log(state, message, isError = false) {
|
|
|
1576
2531
|
})
|
|
1577
2532
|
);
|
|
1578
2533
|
} else if (!state.interactive) {
|
|
1579
|
-
const prefix = isError ?
|
|
2534
|
+
const prefix = isError ? chalk6.red("\u2717") : chalk6.green("\u2022");
|
|
1580
2535
|
console.log(`${prefix} ${message}`);
|
|
1581
2536
|
}
|
|
1582
2537
|
}
|
|
@@ -1589,7 +2544,6 @@ function logActivity(state, entry) {
|
|
|
1589
2544
|
if (state.activityLog.length > MAX_ACTIVITY_LOG_ENTRIES) {
|
|
1590
2545
|
state.activityLog.shift();
|
|
1591
2546
|
}
|
|
1592
|
-
state.lastActivity = fullEntry.timestamp;
|
|
1593
2547
|
if (!state.interactive) {
|
|
1594
2548
|
if (entry.type === "error") {
|
|
1595
2549
|
log(state, entry.error ?? "Unknown error", true);
|
|
@@ -1598,130 +2552,21 @@ function logActivity(state, entry) {
|
|
|
1598
2552
|
}
|
|
1599
2553
|
}
|
|
1600
2554
|
}
|
|
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
2555
|
function displayStatus(state) {
|
|
1647
2556
|
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`)
|
|
2557
|
+
const attempt = state.connection?.reconnectAttempt ?? 0;
|
|
2558
|
+
const tunnel = state.connected ? chalk6.green("tunnel: connected") : attempt > 0 ? chalk6.yellow(`tunnel: reconnecting (#${attempt})`) : chalk6.yellow("tunnel: connecting");
|
|
2559
|
+
const opencode = state.opencodeConnected ? chalk6.green(`opencode: :${state.port}`) : chalk6.red(`opencode: :${state.port} (down)`);
|
|
2560
|
+
const messages = state.messageCount > 0 ? chalk6.dim(` \xB7 ${state.messageCount} processed`) : "";
|
|
2561
|
+
const last = state.activityLog[state.activityLog.length - 1];
|
|
2562
|
+
const detail = last ? chalk6.dim(` \xB7 ${last.type === "error" ? last.error ?? "" : last.message ?? ""}`) : "";
|
|
2563
|
+
const agent = state.agentName ?? state.agentId;
|
|
2564
|
+
console.log(
|
|
2565
|
+
`${chalk6.bold("Evident")} ${chalk6.dim(agent)} ${tunnel} ${opencode}${messages}${detail}`
|
|
1700
2566
|
);
|
|
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
2567
|
}
|
|
1723
2568
|
async function promptForLogin(promptMessage, successMessage) {
|
|
1724
|
-
const action = await
|
|
2569
|
+
const action = await select3({
|
|
1725
2570
|
message: promptMessage,
|
|
1726
2571
|
choices: [
|
|
1727
2572
|
{
|
|
@@ -1737,7 +2582,7 @@ async function promptForLogin(promptMessage, successMessage) {
|
|
|
1737
2582
|
]
|
|
1738
2583
|
});
|
|
1739
2584
|
if (action === "exit") {
|
|
1740
|
-
console.log(
|
|
2585
|
+
console.log(chalk6.dim(`
|
|
1741
2586
|
You can log in later by running: ${getCliName()} login`));
|
|
1742
2587
|
process.exit(0);
|
|
1743
2588
|
}
|
|
@@ -1748,137 +2593,10 @@ You can log in later by running: ${getCliName()} login`));
|
|
|
1748
2593
|
process.exit(1);
|
|
1749
2594
|
}
|
|
1750
2595
|
blank();
|
|
1751
|
-
console.log(
|
|
2596
|
+
console.log(chalk6.green(successMessage));
|
|
1752
2597
|
blank();
|
|
1753
2598
|
return { token: credentials2.token, authType: "bearer", user: credentials2.user };
|
|
1754
2599
|
}
|
|
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
2600
|
var AUTH_EXPIRED_EXIT_CODE = 77;
|
|
1883
2601
|
async function handleAuthError(state, error2) {
|
|
1884
2602
|
logActivity(state, {
|
|
@@ -1888,12 +2606,12 @@ async function handleAuthError(state, error2) {
|
|
|
1888
2606
|
if (state.interactive) displayStatus(state);
|
|
1889
2607
|
if (!state.interactive) {
|
|
1890
2608
|
blank();
|
|
1891
|
-
console.log(
|
|
1892
|
-
console.log(
|
|
2609
|
+
console.log(chalk6.red("Authentication expired"));
|
|
2610
|
+
console.log(chalk6.dim("Your authentication token is no longer valid."));
|
|
1893
2611
|
blank();
|
|
1894
|
-
console.log(
|
|
1895
|
-
console.log(
|
|
1896
|
-
console.log(
|
|
2612
|
+
console.log(chalk6.dim("To fix this:"));
|
|
2613
|
+
console.log(chalk6.dim(` 1. Run '${getCliName()} login' to re-authenticate`));
|
|
2614
|
+
console.log(chalk6.dim(" 2. Restart this command"));
|
|
1897
2615
|
blank();
|
|
1898
2616
|
await cleanup(state);
|
|
1899
2617
|
await shutdownTelemetry();
|
|
@@ -1901,7 +2619,7 @@ async function handleAuthError(state, error2) {
|
|
|
1901
2619
|
return { success: false };
|
|
1902
2620
|
}
|
|
1903
2621
|
blank();
|
|
1904
|
-
console.log(
|
|
2622
|
+
console.log(chalk6.yellow("Your authentication has expired."));
|
|
1905
2623
|
blank();
|
|
1906
2624
|
try {
|
|
1907
2625
|
const credentials2 = await promptForLogin(
|
|
@@ -1914,285 +2632,62 @@ async function handleAuthError(state, error2) {
|
|
|
1914
2632
|
return { success: false };
|
|
1915
2633
|
}
|
|
1916
2634
|
}
|
|
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) {
|
|
2635
|
+
async function driveChannels(state, driver) {
|
|
1925
2636
|
let idlePolls = 0;
|
|
1926
|
-
let currentAuthHeader = authHeader;
|
|
1927
2637
|
while (state.running) {
|
|
1928
|
-
if (state.reconnecting && state.reconnectPromise) {
|
|
1929
|
-
logActivity(state, {
|
|
1930
|
-
type: "info",
|
|
1931
|
-
message: "Waiting for tunnel reconnection..."
|
|
1932
|
-
});
|
|
2638
|
+
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
2639
|
+
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
1933
2640
|
if (state.interactive) displayStatus(state);
|
|
1934
|
-
await state.reconnectPromise;
|
|
2641
|
+
await state.connection.reconnectPromise;
|
|
1935
2642
|
}
|
|
1936
2643
|
try {
|
|
1937
|
-
const
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
state.conversationFilter ?? void 0
|
|
1941
|
-
);
|
|
1942
|
-
state.consecutiveFetchFailures = 0;
|
|
1943
|
-
if (conversations.length > 0) {
|
|
2644
|
+
const processed = await driver.drainPending();
|
|
2645
|
+
state.messageCount += processed;
|
|
2646
|
+
if (processed > 0 || driver.hasInFlightWatchers()) {
|
|
1944
2647
|
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) {
|
|
2648
|
+
if (processed > 0 && state.interactive) displayStatus(state);
|
|
2649
|
+
} else if (state.idleTimeout !== null) {
|
|
2650
|
+
idlePolls++;
|
|
2651
|
+
if (idlePolls === 1) {
|
|
2125
2652
|
logActivity(state, {
|
|
2126
2653
|
type: "info",
|
|
2127
|
-
message:
|
|
2654
|
+
message: `Queue empty, waiting (timeout: ${state.idleTimeout}s)...`
|
|
2128
2655
|
});
|
|
2129
2656
|
if (state.interactive) displayStatus(state);
|
|
2130
|
-
break;
|
|
2131
2657
|
}
|
|
2132
2658
|
}
|
|
2133
2659
|
} catch (error2) {
|
|
2134
|
-
if (error2 instanceof
|
|
2660
|
+
if (error2 instanceof ChannelAuthError) {
|
|
2135
2661
|
const result = await handleAuthError(state, error2);
|
|
2136
2662
|
if (result.success && result.newAuthHeader) {
|
|
2137
|
-
currentAuthHeader = result.newAuthHeader;
|
|
2138
2663
|
state.authHeader = result.newAuthHeader;
|
|
2139
|
-
logActivity(state, {
|
|
2140
|
-
type: "info",
|
|
2141
|
-
message: "Continuing with new credentials..."
|
|
2142
|
-
});
|
|
2664
|
+
logActivity(state, { type: "info", message: "Continuing with new credentials..." });
|
|
2143
2665
|
if (state.interactive) displayStatus(state);
|
|
2144
2666
|
continue;
|
|
2145
|
-
} else {
|
|
2146
|
-
state.running = false;
|
|
2147
|
-
break;
|
|
2148
2667
|
}
|
|
2668
|
+
state.running = false;
|
|
2669
|
+
break;
|
|
2149
2670
|
}
|
|
2150
2671
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
2151
|
-
logActivity(state, {
|
|
2152
|
-
type: "error",
|
|
2153
|
-
error: `Queue processing error: ${errorMessage}`
|
|
2154
|
-
});
|
|
2672
|
+
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
2155
2673
|
if (state.interactive) displayStatus(state);
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
await triggerReconnect();
|
|
2165
|
-
state.consecutiveFetchFailures = 0;
|
|
2166
|
-
}
|
|
2674
|
+
}
|
|
2675
|
+
await new Promise((resolve) => setTimeout(resolve, CHANNEL_POLL_INTERVAL_MS));
|
|
2676
|
+
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
2677
|
+
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
2678
|
+
if (idleMs > state.idleTimeout * 1e3) {
|
|
2679
|
+
logActivity(state, { type: "info", message: "Idle timeout reached" });
|
|
2680
|
+
if (state.interactive) displayStatus(state);
|
|
2681
|
+
break;
|
|
2167
2682
|
}
|
|
2168
|
-
await new Promise((resolve) => setTimeout(resolve, MESSAGE_POLL_INTERVAL_MS));
|
|
2169
2683
|
}
|
|
2170
2684
|
}
|
|
2171
2685
|
}
|
|
2172
|
-
async function cleanup(state
|
|
2686
|
+
async function cleanup(state) {
|
|
2173
2687
|
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;
|
|
2688
|
+
if (state.connection) {
|
|
2689
|
+
state.connection.close();
|
|
2690
|
+
state.connection = null;
|
|
2196
2691
|
}
|
|
2197
2692
|
if (state.opencodeProcess) {
|
|
2198
2693
|
stopOpenCode(state.opencodeProcess);
|
|
@@ -2211,7 +2706,6 @@ async function run(options) {
|
|
|
2211
2706
|
agentId: options.agent || "",
|
|
2212
2707
|
agentName: null,
|
|
2213
2708
|
port: options.port ?? 4096,
|
|
2214
|
-
verbose: options.verbose ?? false,
|
|
2215
2709
|
conversationFilter: options.conversation ?? null,
|
|
2216
2710
|
idleTimeout: options.idleTimeout ?? null,
|
|
2217
2711
|
json: options.json ?? false,
|
|
@@ -2219,22 +2713,11 @@ async function run(options) {
|
|
|
2219
2713
|
connected: false,
|
|
2220
2714
|
opencodeConnected: false,
|
|
2221
2715
|
opencodeVersion: null,
|
|
2222
|
-
reconnectAttempt: 0,
|
|
2223
2716
|
opencodeProcess: null,
|
|
2224
|
-
|
|
2717
|
+
connection: null,
|
|
2225
2718
|
running: true,
|
|
2226
2719
|
activityLog: [],
|
|
2227
|
-
displayInitialized: false,
|
|
2228
|
-
lastActivity: /* @__PURE__ */ new Date(),
|
|
2229
|
-
pendingRequests: /* @__PURE__ */ new Map(),
|
|
2230
|
-
sessions: /* @__PURE__ */ new Map(),
|
|
2231
2720
|
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
2721
|
authHeader: ""
|
|
2239
2722
|
};
|
|
2240
2723
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
@@ -2251,7 +2734,7 @@ async function run(options) {
|
|
|
2251
2734
|
} else {
|
|
2252
2735
|
log(state, "Shutting down...");
|
|
2253
2736
|
}
|
|
2254
|
-
await cleanup(state
|
|
2737
|
+
await cleanup(state);
|
|
2255
2738
|
await shutdownTelemetry();
|
|
2256
2739
|
process.exit(0);
|
|
2257
2740
|
};
|
|
@@ -2263,13 +2746,13 @@ async function run(options) {
|
|
|
2263
2746
|
if (!interactive) {
|
|
2264
2747
|
printError("Authentication required");
|
|
2265
2748
|
blank();
|
|
2266
|
-
console.log(
|
|
2267
|
-
console.log(
|
|
2749
|
+
console.log(chalk6.dim("Set EVIDENT_AGENT_KEY environment variable for CI"));
|
|
2750
|
+
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
2268
2751
|
blank();
|
|
2269
2752
|
process.exit(1);
|
|
2270
2753
|
}
|
|
2271
2754
|
blank();
|
|
2272
|
-
console.log(
|
|
2755
|
+
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
2273
2756
|
blank();
|
|
2274
2757
|
credentials2 = await promptForLogin(
|
|
2275
2758
|
"Would you like to log in now?",
|
|
@@ -2283,6 +2766,12 @@ async function run(options) {
|
|
|
2283
2766
|
if (resolved.agent_id) {
|
|
2284
2767
|
state.agentId = resolved.agent_id;
|
|
2285
2768
|
log(state, `Resolved agent ID from key: ${state.agentId}`);
|
|
2769
|
+
if (state.interactive && !state.json) {
|
|
2770
|
+
logActivity(state, {
|
|
2771
|
+
type: "info",
|
|
2772
|
+
message: `Agent ID resolved from key: ${state.agentId}`
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2286
2775
|
} else {
|
|
2287
2776
|
printError(resolved.error || "Failed to resolve agent ID from key");
|
|
2288
2777
|
process.exit(1);
|
|
@@ -2290,7 +2779,7 @@ async function run(options) {
|
|
|
2290
2779
|
} else {
|
|
2291
2780
|
printError("--agent is required when not using EVIDENT_AGENT_KEY");
|
|
2292
2781
|
blank();
|
|
2293
|
-
console.log(
|
|
2782
|
+
console.log(chalk6.dim("Either provide --agent <id> or set EVIDENT_AGENT_KEY"));
|
|
2294
2783
|
blank();
|
|
2295
2784
|
process.exit(1);
|
|
2296
2785
|
}
|
|
@@ -2309,15 +2798,15 @@ async function run(options) {
|
|
|
2309
2798
|
);
|
|
2310
2799
|
if (interactive && !state.json) {
|
|
2311
2800
|
blank();
|
|
2312
|
-
console.log(
|
|
2313
|
-
console.log(
|
|
2801
|
+
console.log(chalk6.bold("Evident Run"));
|
|
2802
|
+
console.log(chalk6.dim("-".repeat(40)));
|
|
2314
2803
|
}
|
|
2315
|
-
const spinner = interactive && !state.json ?
|
|
2804
|
+
const spinner = interactive && !state.json ? ora3("Validating agent...").start() : null;
|
|
2316
2805
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
2317
2806
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
2318
2807
|
spinner?.fail("Authentication failed");
|
|
2319
2808
|
blank();
|
|
2320
|
-
console.log(
|
|
2809
|
+
console.log(chalk6.yellow("Your authentication token is invalid or expired."));
|
|
2321
2810
|
blank();
|
|
2322
2811
|
credentials2 = await promptForLogin(
|
|
2323
2812
|
"Would you like to log in again?",
|
|
@@ -2333,172 +2822,136 @@ async function run(options) {
|
|
|
2333
2822
|
}
|
|
2334
2823
|
spinner?.succeed(`Agent: ${validation.agent.name || state.agentId}`);
|
|
2335
2824
|
state.agentName = validation.agent.name;
|
|
2336
|
-
const ocSpinner = interactive && !state.json ?
|
|
2825
|
+
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
2337
2826
|
try {
|
|
2338
|
-
await ensureOpenCodeRunning(
|
|
2827
|
+
const oc = await ensureOpenCodeRunning({
|
|
2828
|
+
port: state.port,
|
|
2829
|
+
interactive: state.interactive,
|
|
2830
|
+
agentId: state.agentId,
|
|
2831
|
+
log: (message) => log(state, message)
|
|
2832
|
+
});
|
|
2833
|
+
state.port = oc.port;
|
|
2834
|
+
state.opencodeProcess = oc.process;
|
|
2835
|
+
state.opencodeVersion = oc.version;
|
|
2836
|
+
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2339
2837
|
const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2340
2838
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
|
|
2839
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
2840
|
+
if (versionWarning) {
|
|
2841
|
+
log(state, versionWarning, false);
|
|
2842
|
+
if (state.interactive && !state.json) {
|
|
2843
|
+
logActivity(state, { type: "info", message: versionWarning });
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2341
2846
|
} catch (error2) {
|
|
2342
2847
|
ocSpinner?.fail(error2.message);
|
|
2343
2848
|
throw error2;
|
|
2344
2849
|
}
|
|
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;
|
|
2850
|
+
const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
|
|
2851
|
+
const channelDriver = new ChannelDriver({
|
|
2852
|
+
agentId: state.agentId,
|
|
2853
|
+
port: state.port,
|
|
2854
|
+
apiUrl: getApiUrlConfig(),
|
|
2855
|
+
getAuthHeader: () => state.authHeader,
|
|
2856
|
+
conversationFilter: state.conversationFilter,
|
|
2857
|
+
log: (entry) => logActivity(state, {
|
|
2858
|
+
type: entry.level === "error" ? "error" : "info",
|
|
2859
|
+
message: entry.message,
|
|
2860
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
2861
|
+
})
|
|
2862
|
+
});
|
|
2863
|
+
const connection = new RunnerConnection({
|
|
2864
|
+
agentId: state.agentId,
|
|
2865
|
+
getAuthHeader: () => state.authHeader,
|
|
2866
|
+
port: state.port,
|
|
2867
|
+
isRunning: () => state.running,
|
|
2868
|
+
events: {
|
|
2869
|
+
onConnected: (agentId, isReconnect) => {
|
|
2870
|
+
state.connected = true;
|
|
2871
|
+
state.agentId = agentId;
|
|
2872
|
+
logActivity(state, {
|
|
2873
|
+
type: "info",
|
|
2874
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (agent: ${agentId})`
|
|
2875
|
+
});
|
|
2876
|
+
emitAgentConnected(state.agentId, { port: state.port });
|
|
2877
|
+
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
2878
|
+
if (state.interactive) displayStatus(state);
|
|
2879
|
+
channelDriver.drainPending().then((processed) => {
|
|
2880
|
+
if (processed > 0) {
|
|
2881
|
+
state.messageCount += processed;
|
|
2379
2882
|
logActivity(state, {
|
|
2380
2883
|
type: "info",
|
|
2381
|
-
message: `
|
|
2382
|
-
});
|
|
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
|
|
2884
|
+
message: `Drained ${processed} queued message(s) on connect`
|
|
2409
2885
|
});
|
|
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
2886
|
if (state.interactive) displayStatus(state);
|
|
2437
2887
|
}
|
|
2888
|
+
}).catch((error2) => {
|
|
2889
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2890
|
+
logActivity(state, {
|
|
2891
|
+
type: "error",
|
|
2892
|
+
error: `Failed to drain queued messages on connect: ${message}`
|
|
2893
|
+
});
|
|
2894
|
+
if (state.interactive) displayStatus(state);
|
|
2438
2895
|
});
|
|
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
|
-
}
|
|
2896
|
+
},
|
|
2897
|
+
onDisconnected: (code, reason) => {
|
|
2898
|
+
state.connected = false;
|
|
2453
2899
|
logActivity(state, {
|
|
2454
|
-
type: "
|
|
2455
|
-
|
|
2900
|
+
type: "info",
|
|
2901
|
+
message: `Tunnel disconnected (code: ${code}, reason: ${reason})`
|
|
2456
2902
|
});
|
|
2903
|
+
emitAgentDisconnected(state.agentId, { code, reason });
|
|
2457
2904
|
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
|
-
});
|
|
2905
|
+
},
|
|
2906
|
+
onError: (error2) => {
|
|
2907
|
+
logActivity(state, { type: "error", error: error2 });
|
|
2470
2908
|
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
|
-
|
|
2909
|
+
},
|
|
2910
|
+
// Web traffic is proxied transparently; only note opencode is live.
|
|
2911
|
+
onResponse: () => {
|
|
2912
|
+
state.opencodeConnected = true;
|
|
2913
|
+
},
|
|
2914
|
+
// A channel message was queued and the api-worker pinged us over the
|
|
2915
|
+
// tunnel to drain immediately instead of waiting for the next poll tick.
|
|
2916
|
+
// Best-effort + non-fatal: mirror the on-connect drain block. A failed
|
|
2917
|
+
// drain here is logged and swallowed — the steady-state poll retries, so
|
|
2918
|
+
// a lost/failed ping can never orphan a message (§2 invariant).
|
|
2919
|
+
onDrainPing: () => {
|
|
2920
|
+
if (!state.running) return;
|
|
2921
|
+
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
2922
|
+
channelDriver.drainPending().then((processed) => {
|
|
2923
|
+
if (processed > 0) {
|
|
2924
|
+
state.messageCount += processed;
|
|
2925
|
+
logActivity(state, {
|
|
2926
|
+
type: "info",
|
|
2927
|
+
message: `Drained ${processed} queued message(s) on ping`
|
|
2928
|
+
});
|
|
2929
|
+
if (state.interactive) displayStatus(state);
|
|
2930
|
+
}
|
|
2931
|
+
}).catch((error2) => {
|
|
2932
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2933
|
+
logActivity(state, {
|
|
2934
|
+
type: "error",
|
|
2935
|
+
error: `Failed to drain queued messages on ping: ${message}`
|
|
2936
|
+
});
|
|
2937
|
+
if (state.interactive) displayStatus(state);
|
|
2490
2938
|
});
|
|
2491
|
-
|
|
2492
|
-
}
|
|
2939
|
+
},
|
|
2940
|
+
onInfo: (message) => logActivity(state, { type: "info", message })
|
|
2493
2941
|
}
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2942
|
+
});
|
|
2943
|
+
state.connection = connection;
|
|
2944
|
+
try {
|
|
2945
|
+
await connection.connect();
|
|
2946
|
+
} catch (error2) {
|
|
2947
|
+
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
2948
|
+
throw error2;
|
|
2499
2949
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2950
|
+
if (!interactive || state.json) {
|
|
2951
|
+
log(state, "Driving channel messages...");
|
|
2952
|
+
}
|
|
2953
|
+
await driveChannels(state, channelDriver);
|
|
2954
|
+
await cleanup(state);
|
|
2502
2955
|
if (state.json) {
|
|
2503
2956
|
console.log(
|
|
2504
2957
|
JSON.stringify({
|
|
@@ -2512,7 +2965,7 @@ async function run(options) {
|
|
|
2512
2965
|
await shutdownTelemetry();
|
|
2513
2966
|
process.exit(0);
|
|
2514
2967
|
} catch (error2) {
|
|
2515
|
-
await cleanup(state
|
|
2968
|
+
await cleanup(state);
|
|
2516
2969
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2517
2970
|
if (state.json) {
|
|
2518
2971
|
console.log(JSON.stringify({ status: "error", error: message }));
|
|
@@ -2530,16 +2983,22 @@ async function run(options) {
|
|
|
2530
2983
|
|
|
2531
2984
|
// src/index.ts
|
|
2532
2985
|
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
|
-
|
|
2986
|
+
program.name("evident").description("Run OpenCode locally and connect it to Evident").version("0.1.0").option(
|
|
2987
|
+
"--endpoint <url>",
|
|
2988
|
+
"Evident API base URL (default: production; e.g. http://localhost:3001)"
|
|
2989
|
+
).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
|
|
2990
|
+
const { endpoint, tunnel } = thisCommand.opts();
|
|
2991
|
+
if (endpoint) {
|
|
2992
|
+
setEndpoint(endpoint);
|
|
2993
|
+
}
|
|
2994
|
+
if (tunnel) {
|
|
2995
|
+
setTunnelUrl(tunnel);
|
|
2537
2996
|
}
|
|
2538
2997
|
});
|
|
2539
2998
|
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);
|
|
2999
|
+
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
3000
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
2542
|
-
program.command("run").description("Connect to Evident and process messages").
|
|
3001
|
+
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
3002
|
(options) => {
|
|
2544
3003
|
run({
|
|
2545
3004
|
agent: options.agent,
|