@evident-ai/cli 3.0.0 → 3.0.1-dev.284117a
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/dist/index.js +379 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,10 +32,10 @@ function setTunnelUrl(url) {
|
|
|
32
32
|
tunnelOverride = url ? url.replace(/\/+$/, "") : void 0;
|
|
33
33
|
}
|
|
34
34
|
function getApiUrl() {
|
|
35
|
-
return process.env.EVIDENT_API_URL ??
|
|
35
|
+
return endpointOverride ?? process.env.EVIDENT_API_URL ?? defaults.apiUrl;
|
|
36
36
|
}
|
|
37
37
|
function getTunnelUrl() {
|
|
38
|
-
return process.env.EVIDENT_TUNNEL_URL ??
|
|
38
|
+
return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
|
|
39
39
|
}
|
|
40
40
|
var config = new Conf({
|
|
41
41
|
projectName: "evident",
|
|
@@ -54,19 +54,28 @@ function getApiUrlConfig() {
|
|
|
54
54
|
function getTunnelUrlConfig() {
|
|
55
55
|
return getTunnelUrl();
|
|
56
56
|
}
|
|
57
|
+
function credentialsKey() {
|
|
58
|
+
return getApiUrl();
|
|
59
|
+
}
|
|
57
60
|
function getCredentials() {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
user: credentials.get("user"),
|
|
61
|
-
expiresAt: credentials.get("expiresAt")
|
|
62
|
-
};
|
|
61
|
+
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
62
|
+
return byEndpoint[credentialsKey()] ?? {};
|
|
63
63
|
}
|
|
64
64
|
function setCredentials(creds) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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);
|
|
68
72
|
}
|
|
69
73
|
function clearCredentials() {
|
|
74
|
+
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
75
|
+
delete byEndpoint[credentialsKey()];
|
|
76
|
+
credentials.set("byEndpoint", byEndpoint);
|
|
77
|
+
}
|
|
78
|
+
function clearAllCredentials() {
|
|
70
79
|
credentials.clear();
|
|
71
80
|
}
|
|
72
81
|
function getCliName() {
|
|
@@ -176,7 +185,6 @@ var api = {
|
|
|
176
185
|
|
|
177
186
|
// src/lib/keychain.ts
|
|
178
187
|
var SERVICE_NAME = "evident-cli";
|
|
179
|
-
var ACCOUNT_NAME = "default";
|
|
180
188
|
async function getKeytar() {
|
|
181
189
|
try {
|
|
182
190
|
const keytar = await import("keytar");
|
|
@@ -188,10 +196,13 @@ async function getKeytar() {
|
|
|
188
196
|
return null;
|
|
189
197
|
}
|
|
190
198
|
}
|
|
199
|
+
function keychainAccount() {
|
|
200
|
+
return getApiUrlConfig();
|
|
201
|
+
}
|
|
191
202
|
async function storeToken(credentials2) {
|
|
192
203
|
const keytar = await getKeytar();
|
|
193
204
|
if (keytar) {
|
|
194
|
-
await keytar.setPassword(SERVICE_NAME,
|
|
205
|
+
await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
|
|
195
206
|
} else {
|
|
196
207
|
setCredentials({
|
|
197
208
|
token: credentials2.token,
|
|
@@ -203,12 +214,13 @@ async function storeToken(credentials2) {
|
|
|
203
214
|
async function getToken() {
|
|
204
215
|
const keytar = await getKeytar();
|
|
205
216
|
if (keytar) {
|
|
206
|
-
const
|
|
217
|
+
const account = keychainAccount();
|
|
218
|
+
const stored = await keytar.getPassword(SERVICE_NAME, account);
|
|
207
219
|
if (stored) {
|
|
208
220
|
try {
|
|
209
221
|
return JSON.parse(stored);
|
|
210
222
|
} catch {
|
|
211
|
-
await keytar.deletePassword(SERVICE_NAME,
|
|
223
|
+
await keytar.deletePassword(SERVICE_NAME, account);
|
|
212
224
|
return null;
|
|
213
225
|
}
|
|
214
226
|
}
|
|
@@ -223,12 +235,26 @@ async function getToken() {
|
|
|
223
235
|
}
|
|
224
236
|
return null;
|
|
225
237
|
}
|
|
226
|
-
async function deleteToken() {
|
|
238
|
+
async function deleteToken(options = {}) {
|
|
227
239
|
const keytar = await getKeytar();
|
|
228
240
|
if (keytar) {
|
|
229
|
-
|
|
241
|
+
if (options.all) {
|
|
242
|
+
const all = await keytar.findCredentials(SERVICE_NAME).catch(() => []);
|
|
243
|
+
await Promise.all(
|
|
244
|
+
all.map(
|
|
245
|
+
(entry) => keytar.deletePassword(SERVICE_NAME, entry.account).catch(() => {
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
} else {
|
|
250
|
+
await keytar.deletePassword(SERVICE_NAME, keychainAccount());
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (options.all) {
|
|
254
|
+
clearAllCredentials();
|
|
255
|
+
} else {
|
|
256
|
+
clearCredentials();
|
|
230
257
|
}
|
|
231
|
-
clearCredentials();
|
|
232
258
|
}
|
|
233
259
|
|
|
234
260
|
// src/utils/ui.ts
|
|
@@ -396,25 +422,32 @@ async function login(options) {
|
|
|
396
422
|
}
|
|
397
423
|
|
|
398
424
|
// src/commands/logout.ts
|
|
399
|
-
async function logout() {
|
|
425
|
+
async function logout(options = {}) {
|
|
426
|
+
if (options.all) {
|
|
427
|
+
await deleteToken({ all: true });
|
|
428
|
+
printSuccess("Logged out of all endpoints.");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
400
431
|
const credentials2 = await getToken();
|
|
401
432
|
if (!credentials2) {
|
|
402
|
-
printWarning(
|
|
433
|
+
printWarning(`You are not logged in to ${getApiUrlConfig()}.`);
|
|
403
434
|
return;
|
|
404
435
|
}
|
|
405
436
|
await deleteToken();
|
|
406
|
-
printSuccess(
|
|
437
|
+
printSuccess(`Logged out of ${getApiUrlConfig()}.`);
|
|
407
438
|
}
|
|
408
439
|
|
|
409
440
|
// src/commands/whoami.ts
|
|
410
441
|
import chalk3 from "chalk";
|
|
411
442
|
async function whoami() {
|
|
443
|
+
const apiUrl = getApiUrlConfig();
|
|
412
444
|
const credentials2 = await getToken();
|
|
413
445
|
if (!credentials2) {
|
|
414
|
-
printError(
|
|
446
|
+
printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
|
|
415
447
|
process.exit(1);
|
|
416
448
|
}
|
|
417
449
|
blank();
|
|
450
|
+
console.log(keyValue("Endpoint", apiUrl));
|
|
418
451
|
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
419
452
|
console.log(keyValue("User ID", credentials2.user.id));
|
|
420
453
|
if (credentials2.expiresAt) {
|
|
@@ -449,6 +482,7 @@ var TelemetryEventTypes = {
|
|
|
449
482
|
|
|
450
483
|
// ../../packages/types/src/tunnel/index.ts
|
|
451
484
|
var MAX_FRAME_BYTES = 256 * 1024;
|
|
485
|
+
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
452
486
|
|
|
453
487
|
// src/lib/telemetry.ts
|
|
454
488
|
var CLI_VERSION = process.env.npm_package_version || "unknown";
|
|
@@ -940,14 +974,59 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
940
974
|
}
|
|
941
975
|
|
|
942
976
|
// src/lib/opencode/session.ts
|
|
943
|
-
|
|
944
|
-
|
|
977
|
+
function opencodeBase(port) {
|
|
978
|
+
return `http://127.0.0.1:${port}`;
|
|
979
|
+
}
|
|
980
|
+
async function getOpenCodeDirectory(port) {
|
|
981
|
+
try {
|
|
982
|
+
const res = await fetch(`${opencodeBase(port)}/path`);
|
|
983
|
+
if (!res.ok) return null;
|
|
984
|
+
const body = await res.json();
|
|
985
|
+
const dir = typeof body.directory === "string" && body.directory || typeof body.worktree === "string" && body.worktree || typeof body.path?.cwd === "string" && body.path.cwd || typeof body.path?.directory === "string" && body.path.directory || null;
|
|
986
|
+
return dir && dir.trim() ? dir.trim() : null;
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function roleOf(m) {
|
|
992
|
+
if (!m || typeof m !== "object") return void 0;
|
|
993
|
+
if (typeof m.role === "string") return m.role;
|
|
994
|
+
const infoRole = m.info?.role;
|
|
995
|
+
return typeof infoRole === "string" ? infoRole : void 0;
|
|
996
|
+
}
|
|
997
|
+
function completedOf(m) {
|
|
998
|
+
if (!m || typeof m !== "object") return void 0;
|
|
999
|
+
return m.info?.time?.completed;
|
|
1000
|
+
}
|
|
1001
|
+
async function getSessionMessages(port, sessionId) {
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
|
|
1004
|
+
if (!res.ok) return null;
|
|
1005
|
+
const body = await res.json();
|
|
1006
|
+
return Array.isArray(body) ? body : null;
|
|
1007
|
+
} catch {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function isTurnComplete(messages) {
|
|
1012
|
+
if (!messages || messages.length === 0) return false;
|
|
1013
|
+
const last = messages[messages.length - 1];
|
|
1014
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1015
|
+
return completedOf(last) != null;
|
|
1016
|
+
}
|
|
1017
|
+
async function createOpenCodeSession(port, directory) {
|
|
1018
|
+
const url = new URL(`${opencodeBase(port)}/session`);
|
|
1019
|
+
if (directory && directory.trim()) {
|
|
1020
|
+
url.searchParams.set("directory", directory.trim());
|
|
1021
|
+
}
|
|
1022
|
+
const response = await fetch(url, {
|
|
945
1023
|
method: "POST",
|
|
946
1024
|
headers: { "Content-Type": "application/json" },
|
|
947
1025
|
body: JSON.stringify({})
|
|
948
1026
|
});
|
|
949
1027
|
if (!response.ok) {
|
|
950
|
-
|
|
1028
|
+
const text = await response.text().catch(() => "");
|
|
1029
|
+
throw new Error(`Failed to create session: HTTP ${response.status}${text ? `: ${text}` : ""}`);
|
|
951
1030
|
}
|
|
952
1031
|
const data = await response.json();
|
|
953
1032
|
return data.id;
|
|
@@ -977,7 +1056,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
977
1056
|
if (pollDone) break;
|
|
978
1057
|
if (hooks?.onQuestion) {
|
|
979
1058
|
try {
|
|
980
|
-
const res = await fetch(
|
|
1059
|
+
const res = await fetch(`${opencodeBase(port)}/question`);
|
|
981
1060
|
if (res.ok) {
|
|
982
1061
|
const questions = await res.json();
|
|
983
1062
|
for (const q of questions) {
|
|
@@ -992,7 +1071,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
992
1071
|
}
|
|
993
1072
|
if (hooks?.onPermission) {
|
|
994
1073
|
try {
|
|
995
|
-
const res = await fetch(
|
|
1074
|
+
const res = await fetch(`${opencodeBase(port)}/permission`);
|
|
996
1075
|
if (res.ok) {
|
|
997
1076
|
const permissions = await res.json();
|
|
998
1077
|
for (const p of permissions) {
|
|
@@ -1011,7 +1090,7 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1011
1090
|
const controller = new AbortController();
|
|
1012
1091
|
const timer = setTimeout(() => controller.abort(), maxWaitMs);
|
|
1013
1092
|
try {
|
|
1014
|
-
const res = await fetch(
|
|
1093
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
|
|
1015
1094
|
method: "POST",
|
|
1016
1095
|
headers: { "Content-Type": "application/json" },
|
|
1017
1096
|
body: JSON.stringify(body),
|
|
@@ -1021,11 +1100,14 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1021
1100
|
const text = await res.text().catch(() => "");
|
|
1022
1101
|
throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1023
1102
|
}
|
|
1024
|
-
const sessionRes = await fetch(
|
|
1103
|
+
const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
|
|
1025
1104
|
() => null
|
|
1026
1105
|
);
|
|
1027
1106
|
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
1028
|
-
|
|
1107
|
+
const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
|
|
1108
|
+
const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
|
|
1109
|
+
const awaitingInteraction = reportedInteraction && !turnComplete;
|
|
1110
|
+
return { title: session?.title, awaitingInteraction };
|
|
1029
1111
|
} catch (err) {
|
|
1030
1112
|
if (err instanceof Error && err.name === "AbortError") {
|
|
1031
1113
|
throw new Error("Message processing timed out");
|
|
@@ -1108,6 +1190,12 @@ var StreamForwarder = class {
|
|
|
1108
1190
|
}
|
|
1109
1191
|
async handleOpen(frame) {
|
|
1110
1192
|
const { sid, method, path, headers, has_body } = frame;
|
|
1193
|
+
if (path === TUNNEL_DRAIN_PING_PATH) {
|
|
1194
|
+
this.callbacks.onDrainPing?.();
|
|
1195
|
+
this.send({ type: "head", sid, status: 204, headers: {} });
|
|
1196
|
+
this.send({ type: "res_end", sid });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1111
1199
|
const ac = new AbortController();
|
|
1112
1200
|
let bodyPromise;
|
|
1113
1201
|
let pushBody;
|
|
@@ -1224,7 +1312,8 @@ function connectTunnel(options) {
|
|
|
1224
1312
|
onError,
|
|
1225
1313
|
onRequest,
|
|
1226
1314
|
onResponse,
|
|
1227
|
-
onInfo
|
|
1315
|
+
onInfo,
|
|
1316
|
+
onDrainPing
|
|
1228
1317
|
} = options;
|
|
1229
1318
|
const tunnelUrl = getTunnelUrlConfig();
|
|
1230
1319
|
const url = `${tunnelUrl}/tunnel/${agentId}/connect`;
|
|
@@ -1237,6 +1326,7 @@ function connectTunnel(options) {
|
|
|
1237
1326
|
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1238
1327
|
const forwarder = new StreamForwarder(ws, port, {
|
|
1239
1328
|
onOpen: (sid, method, path) => {
|
|
1329
|
+
if (path === TUNNEL_DRAIN_PING_PATH) return;
|
|
1240
1330
|
streamStartTimes.set(sid, Date.now());
|
|
1241
1331
|
onRequest?.(method, path, sid);
|
|
1242
1332
|
},
|
|
@@ -1244,7 +1334,8 @@ function connectTunnel(options) {
|
|
|
1244
1334
|
const startedAt = streamStartTimes.get(sid);
|
|
1245
1335
|
streamStartTimes.delete(sid);
|
|
1246
1336
|
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1247
|
-
}
|
|
1337
|
+
},
|
|
1338
|
+
onDrainPing: () => onDrainPing?.()
|
|
1248
1339
|
});
|
|
1249
1340
|
const connectionTimeout = setTimeout(() => {
|
|
1250
1341
|
ws.close();
|
|
@@ -1386,6 +1477,7 @@ var RunnerConnection = class {
|
|
|
1386
1477
|
},
|
|
1387
1478
|
onError: (error2) => events.onError?.(error2),
|
|
1388
1479
|
onResponse: () => events.onResponse?.(),
|
|
1480
|
+
onDrainPing: () => events.onDrainPing?.(),
|
|
1389
1481
|
onInfo: (message) => events.onInfo?.(message)
|
|
1390
1482
|
});
|
|
1391
1483
|
return;
|
|
@@ -1411,6 +1503,8 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1411
1503
|
baseDelayMs: 500,
|
|
1412
1504
|
maxDelayMs: 3e4
|
|
1413
1505
|
};
|
|
1506
|
+
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1507
|
+
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1414
1508
|
var ChannelAuthError = class extends Error {
|
|
1415
1509
|
constructor(message) {
|
|
1416
1510
|
super(message);
|
|
@@ -1435,8 +1529,24 @@ var ChannelDriver = class {
|
|
|
1435
1529
|
log;
|
|
1436
1530
|
fetchImpl;
|
|
1437
1531
|
sleep;
|
|
1532
|
+
pausedPollIntervalMs;
|
|
1533
|
+
pausedMaxWaitMs;
|
|
1438
1534
|
/** Cache of conversationId → opencode sessionId. */
|
|
1439
1535
|
sessions = /* @__PURE__ */ new Map();
|
|
1536
|
+
/**
|
|
1537
|
+
* Outstanding paused-session watchers, keyed by `message.id` (WI-2-CLI).
|
|
1538
|
+
* Single-flight per message: while a watcher is live for a message we never
|
|
1539
|
+
* start a second one. The message stays `processing` for the watcher's lifetime
|
|
1540
|
+
* so the `?status=pending` drain cannot double-claim it.
|
|
1541
|
+
*/
|
|
1542
|
+
watchers = /* @__PURE__ */ new Map();
|
|
1543
|
+
/**
|
|
1544
|
+
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1545
|
+
* first session creation so drain-created sessions are rooted at the project
|
|
1546
|
+
* directory and thus visible in `opencode web`'s session list. `undefined` =
|
|
1547
|
+
* not yet resolved; `null` = resolved-but-unavailable (don't keep retrying).
|
|
1548
|
+
*/
|
|
1549
|
+
opencodeDirectory = void 0;
|
|
1440
1550
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1441
1551
|
draining = false;
|
|
1442
1552
|
constructor(config2) {
|
|
@@ -1450,6 +1560,8 @@ var ChannelDriver = class {
|
|
|
1450
1560
|
});
|
|
1451
1561
|
this.fetchImpl = config2.fetchImpl ?? fetch;
|
|
1452
1562
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1563
|
+
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1564
|
+
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1453
1565
|
}
|
|
1454
1566
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1455
1567
|
get opencodeBase() {
|
|
@@ -1471,6 +1583,13 @@ var ChannelDriver = class {
|
|
|
1471
1583
|
let processed = 0;
|
|
1472
1584
|
try {
|
|
1473
1585
|
const conversations = await this.getPendingConversations();
|
|
1586
|
+
if (conversations.length > 0) {
|
|
1587
|
+
const total = conversations.reduce((sum, c) => sum + (c.pending_message_count ?? 0), 0);
|
|
1588
|
+
this.log({
|
|
1589
|
+
level: "info",
|
|
1590
|
+
message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1474
1593
|
for (const conv of conversations) {
|
|
1475
1594
|
processed += await this.processConversation(conv);
|
|
1476
1595
|
}
|
|
@@ -1479,6 +1598,18 @@ var ChannelDriver = class {
|
|
|
1479
1598
|
}
|
|
1480
1599
|
return processed;
|
|
1481
1600
|
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Await all outstanding paused-session watchers (WI-2-CLI).
|
|
1603
|
+
*
|
|
1604
|
+
* In production the watchers are deliberately started-not-awaited so the drain
|
|
1605
|
+
* loop never blocks on them and process exit is not held up (the cron recovers
|
|
1606
|
+
* any abandoned ones). This helper exists primarily for deterministic tests
|
|
1607
|
+
* that need to observe the watcher's effect (the `done` PATCH or its giving up)
|
|
1608
|
+
* after a non-blocking `drainPending`. Watchers never reject, so this resolves.
|
|
1609
|
+
*/
|
|
1610
|
+
async flushPausedWatchers() {
|
|
1611
|
+
await Promise.all([...this.watchers.values()]);
|
|
1612
|
+
}
|
|
1482
1613
|
// -------------------------------------------------------------------------
|
|
1483
1614
|
// Conversation processing
|
|
1484
1615
|
// -------------------------------------------------------------------------
|
|
@@ -1498,7 +1629,13 @@ var ChannelDriver = class {
|
|
|
1498
1629
|
continue;
|
|
1499
1630
|
}
|
|
1500
1631
|
try {
|
|
1501
|
-
|
|
1632
|
+
this.log({
|
|
1633
|
+
level: "info",
|
|
1634
|
+
message: `Sending queued message ${message.id.slice(0, 8)} to OpenCode (session ${sessionId.slice(0, 8)})`,
|
|
1635
|
+
conversation_id: conv.id,
|
|
1636
|
+
message_id: message.id
|
|
1637
|
+
});
|
|
1638
|
+
const result = await sendMessageToOpenCode(
|
|
1502
1639
|
this.port,
|
|
1503
1640
|
sessionId,
|
|
1504
1641
|
message.content,
|
|
@@ -1511,6 +1648,16 @@ var ChannelDriver = class {
|
|
|
1511
1648
|
onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
|
|
1512
1649
|
}
|
|
1513
1650
|
);
|
|
1651
|
+
if (result.awaitingInteraction) {
|
|
1652
|
+
this.log({
|
|
1653
|
+
level: "info",
|
|
1654
|
+
message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
|
|
1655
|
+
conversation_id: conv.id,
|
|
1656
|
+
message_id: message.id
|
|
1657
|
+
});
|
|
1658
|
+
this.startPausedWatcher(conv, message, sessionId);
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1514
1661
|
await this.confirmCompletion(sessionId);
|
|
1515
1662
|
await this.markDone(conv.id, message.id, sessionId);
|
|
1516
1663
|
processed += 1;
|
|
@@ -1541,32 +1688,136 @@ var ChannelDriver = class {
|
|
|
1541
1688
|
this.sessions.set(conv.id, conv.opencode_session_id);
|
|
1542
1689
|
return conv.opencode_session_id;
|
|
1543
1690
|
}
|
|
1544
|
-
const
|
|
1691
|
+
const directory = await this.resolveOpenCodeDirectory();
|
|
1692
|
+
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
1545
1693
|
this.sessions.set(conv.id, sessionId);
|
|
1546
1694
|
await this.persistSession(conv.id, sessionId).catch(() => {
|
|
1547
1695
|
});
|
|
1548
1696
|
return sessionId;
|
|
1549
1697
|
}
|
|
1550
1698
|
/**
|
|
1551
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1699
|
+
* Lazily resolve (and cache) opencode's root directory via `GET /path`.
|
|
1700
|
+
* Resolved once per driver: `undefined` until first lookup, then the directory
|
|
1701
|
+
* string or `null` if unavailable (we don't keep retrying a missing `/path`).
|
|
1702
|
+
*/
|
|
1703
|
+
async resolveOpenCodeDirectory() {
|
|
1704
|
+
if (this.opencodeDirectory !== void 0) return this.opencodeDirectory;
|
|
1705
|
+
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
1706
|
+
if (!this.opencodeDirectory) {
|
|
1707
|
+
this.log({
|
|
1708
|
+
level: "info",
|
|
1709
|
+
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
return this.opencodeDirectory;
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Local reconcile: re-query `GET /session/:id/message` and check whether the
|
|
1716
|
+
* last assistant message is message-level complete (`isTurnComplete`).
|
|
1717
|
+
*
|
|
1718
|
+
* This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
|
|
1719
|
+
* blocking POST already returned, and `markDone` causes the server to re-fetch
|
|
1720
|
+
* the messages itself (via `extractTextFromMessages`) when delivering the
|
|
1721
|
+
* reply — so this round-trip never gates delivery. We keep it only to surface a
|
|
1722
|
+
* truthful diagnostic when opencode hasn't yet recorded a completed assistant
|
|
1723
|
+
* turn at reconcile time, then proceed to `markDone` regardless. Uses the
|
|
1724
|
+
* injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
|
|
1554
1725
|
*/
|
|
1555
1726
|
async confirmCompletion(sessionId) {
|
|
1556
1727
|
try {
|
|
1557
|
-
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
1728
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1558
1729
|
if (!res.ok) return;
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1730
|
+
const body = await res.json();
|
|
1731
|
+
const messages = Array.isArray(body) ? body : null;
|
|
1732
|
+
if (!isTurnComplete(messages)) {
|
|
1561
1733
|
this.log({
|
|
1562
1734
|
level: "info",
|
|
1563
|
-
message: `Session ${sessionId.slice(0, 8)} not
|
|
1735
|
+
message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
|
|
1564
1736
|
});
|
|
1565
1737
|
}
|
|
1566
1738
|
} catch {
|
|
1567
1739
|
}
|
|
1568
1740
|
}
|
|
1569
1741
|
// -------------------------------------------------------------------------
|
|
1742
|
+
// Paused-session watcher (WI-2-CLI)
|
|
1743
|
+
// -------------------------------------------------------------------------
|
|
1744
|
+
/**
|
|
1745
|
+
* Start (but do NOT await) a watcher that resumes a paused turn to completion.
|
|
1746
|
+
*
|
|
1747
|
+
* Single-flight per message: if a watcher is already live for this message we
|
|
1748
|
+
* skip. The returned watcher promise is tracked in `this.watchers` and removed
|
|
1749
|
+
* when it settles; it never rejects (the body is fully guarded), so a failed
|
|
1750
|
+
* poll/markDone can never crash the run loop — the cron stays as the safety net.
|
|
1751
|
+
*/
|
|
1752
|
+
startPausedWatcher(conv, message, sessionId) {
|
|
1753
|
+
if (this.watchers.has(message.id)) return;
|
|
1754
|
+
const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
|
|
1755
|
+
this.watchers.delete(message.id);
|
|
1756
|
+
});
|
|
1757
|
+
this.watchers.set(message.id, watcher);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Poll `GET /session/:id/message` until the SAME paused turn is message-level
|
|
1761
|
+
* complete — the last message is an ASSISTANT message whose
|
|
1762
|
+
* `info.time.completed` is set (the user answered the question/permission in
|
|
1763
|
+
* opencode web and opencode finished the turn) — then complete it via the
|
|
1764
|
+
* EXISTING `markDone` PATCH — NO re-send of the original message. The server
|
|
1765
|
+
* re-fetches the assistant messages on completion, so the watcher does not
|
|
1766
|
+
* pass any reply text.
|
|
1767
|
+
*
|
|
1768
|
+
* Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
|
|
1769
|
+
* tests' injection) and reuses only the pure `isTurnComplete` predicate from
|
|
1770
|
+
* `session.ts` — we do NOT call `getSessionMessages` (which uses the global
|
|
1771
|
+
* `fetch`) here.
|
|
1772
|
+
*
|
|
1773
|
+
* Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
|
|
1774
|
+
* reset): on timeout we STOP and leave the message `processing` so the cron
|
|
1775
|
+
* remains the last-resort safety net. The whole body is wrapped so any
|
|
1776
|
+
* poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
|
|
1777
|
+
* out of the run loop.
|
|
1778
|
+
*/
|
|
1779
|
+
async watchPausedSession(conv, message, sessionId) {
|
|
1780
|
+
const deadline = Date.now() + this.pausedMaxWaitMs;
|
|
1781
|
+
try {
|
|
1782
|
+
while (Date.now() < deadline) {
|
|
1783
|
+
await this.sleep(this.pausedPollIntervalMs);
|
|
1784
|
+
let completed = false;
|
|
1785
|
+
try {
|
|
1786
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1787
|
+
if (res.ok) {
|
|
1788
|
+
const body = await res.json();
|
|
1789
|
+
const messages = Array.isArray(body) ? body : null;
|
|
1790
|
+
completed = isTurnComplete(messages);
|
|
1791
|
+
}
|
|
1792
|
+
} catch {
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
if (!completed) continue;
|
|
1796
|
+
this.log({
|
|
1797
|
+
level: "info",
|
|
1798
|
+
message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
|
|
1799
|
+
conversation_id: conv.id,
|
|
1800
|
+
message_id: message.id
|
|
1801
|
+
});
|
|
1802
|
+
await this.markDone(conv.id, message.id, sessionId);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
this.log({
|
|
1806
|
+
level: "info",
|
|
1807
|
+
message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
|
|
1808
|
+
conversation_id: conv.id,
|
|
1809
|
+
message_id: message.id
|
|
1810
|
+
});
|
|
1811
|
+
} catch (err) {
|
|
1812
|
+
this.log({
|
|
1813
|
+
level: "error",
|
|
1814
|
+
message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1815
|
+
conversation_id: conv.id,
|
|
1816
|
+
message_id: message.id
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
// -------------------------------------------------------------------------
|
|
1570
1821
|
// Evident API calls (combinedAuth thread routes)
|
|
1571
1822
|
// -------------------------------------------------------------------------
|
|
1572
1823
|
async getPendingConversations() {
|
|
@@ -1848,23 +2099,45 @@ Port ${port} is already in use.`));
|
|
|
1848
2099
|
spinner.fail("Failed to start OpenCode");
|
|
1849
2100
|
throw new Error("OpenCode failed to start");
|
|
1850
2101
|
}
|
|
1851
|
-
spinner.
|
|
1852
|
-
`OpenCode running on port ${port}${health.version ? ` (v${health.version})` : ""}`
|
|
1853
|
-
);
|
|
2102
|
+
spinner.stop();
|
|
1854
2103
|
return { port, process: proc, version: health.version ?? null };
|
|
1855
2104
|
}
|
|
1856
2105
|
return { port, process: null, version: null };
|
|
1857
2106
|
}
|
|
1858
2107
|
|
|
1859
2108
|
// src/commands/agent-lookup.ts
|
|
2109
|
+
async function readErrorMessage(response) {
|
|
2110
|
+
const text = await response.text().catch(() => "");
|
|
2111
|
+
if (!text) return response.statusText || void 0;
|
|
2112
|
+
try {
|
|
2113
|
+
const data = JSON.parse(text);
|
|
2114
|
+
const message = data.message ?? data.error;
|
|
2115
|
+
if (typeof message === "string" && message.trim()) {
|
|
2116
|
+
return message;
|
|
2117
|
+
}
|
|
2118
|
+
} catch {
|
|
2119
|
+
}
|
|
2120
|
+
return text.trim() || response.statusText || void 0;
|
|
2121
|
+
}
|
|
2122
|
+
function authFailureHint(apiUrl, serverMessage) {
|
|
2123
|
+
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
2124
|
+
return `Authentication failed${reason}. Your credentials were rejected by ${apiUrl}. This usually means you logged in against a different environment, or your session expired \u2014 log in again pointing at this endpoint and retry.`;
|
|
2125
|
+
}
|
|
1860
2126
|
async function resolveAgentIdFromKey(authHeader) {
|
|
1861
2127
|
const apiUrl = getApiUrlConfig();
|
|
1862
2128
|
try {
|
|
1863
2129
|
const response = await fetch(`${apiUrl}/me`, {
|
|
1864
2130
|
headers: { Authorization: authHeader }
|
|
1865
2131
|
});
|
|
2132
|
+
if (response.status === 401) {
|
|
2133
|
+
const serverMessage = await readErrorMessage(response);
|
|
2134
|
+
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
2135
|
+
}
|
|
1866
2136
|
if (!response.ok) {
|
|
1867
|
-
|
|
2137
|
+
const serverMessage = await readErrorMessage(response);
|
|
2138
|
+
return {
|
|
2139
|
+
error: `Failed to resolve agent from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
2140
|
+
};
|
|
1868
2141
|
}
|
|
1869
2142
|
const data = await response.json();
|
|
1870
2143
|
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
@@ -1884,14 +2157,27 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
1884
2157
|
const response = await fetch(`${apiUrl}/agents/${agentId}`, {
|
|
1885
2158
|
headers: { Authorization: authHeader }
|
|
1886
2159
|
});
|
|
1887
|
-
if (response.status === 404) {
|
|
1888
|
-
return { valid: false, error: "Agent not found" };
|
|
1889
|
-
}
|
|
1890
2160
|
if (response.status === 401) {
|
|
1891
|
-
|
|
2161
|
+
const serverMessage = await readErrorMessage(response);
|
|
2162
|
+
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
2163
|
+
}
|
|
2164
|
+
if (response.status === 403) {
|
|
2165
|
+
const serverMessage = await readErrorMessage(response);
|
|
2166
|
+
return {
|
|
2167
|
+
valid: false,
|
|
2168
|
+
error: serverMessage ?? "You do not have access to this agent (it may belong to a different team or organization)."
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
if (response.status === 404) {
|
|
2172
|
+
const serverMessage = await readErrorMessage(response);
|
|
2173
|
+
return { valid: false, error: serverMessage ?? `Agent ${agentId} not found` };
|
|
1892
2174
|
}
|
|
1893
2175
|
if (!response.ok) {
|
|
1894
|
-
|
|
2176
|
+
const serverMessage = await readErrorMessage(response);
|
|
2177
|
+
return {
|
|
2178
|
+
valid: false,
|
|
2179
|
+
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
2180
|
+
};
|
|
1895
2181
|
}
|
|
1896
2182
|
const agent = await response.json();
|
|
1897
2183
|
if (agent.agent_type !== "local") {
|
|
@@ -2258,7 +2544,22 @@ async function run(options) {
|
|
|
2258
2544
|
emitAgentConnected(state.agentId, { port: state.port });
|
|
2259
2545
|
if (!isReconnect) tunnelSpinner?.succeed("Tunnel connected");
|
|
2260
2546
|
if (state.interactive) displayStatus(state);
|
|
2261
|
-
channelDriver.drainPending().
|
|
2547
|
+
channelDriver.drainPending().then((processed) => {
|
|
2548
|
+
if (processed > 0) {
|
|
2549
|
+
state.messageCount += processed;
|
|
2550
|
+
logActivity(state, {
|
|
2551
|
+
type: "info",
|
|
2552
|
+
message: `Drained ${processed} queued message(s) on connect`
|
|
2553
|
+
});
|
|
2554
|
+
if (state.interactive) displayStatus(state);
|
|
2555
|
+
}
|
|
2556
|
+
}).catch((error2) => {
|
|
2557
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2558
|
+
logActivity(state, {
|
|
2559
|
+
type: "error",
|
|
2560
|
+
error: `Failed to drain queued messages on connect: ${message}`
|
|
2561
|
+
});
|
|
2562
|
+
if (state.interactive) displayStatus(state);
|
|
2262
2563
|
});
|
|
2263
2564
|
},
|
|
2264
2565
|
onDisconnected: (code, reason) => {
|
|
@@ -2278,6 +2579,32 @@ async function run(options) {
|
|
|
2278
2579
|
onResponse: () => {
|
|
2279
2580
|
state.opencodeConnected = true;
|
|
2280
2581
|
},
|
|
2582
|
+
// A channel message was queued and the api-worker pinged us over the
|
|
2583
|
+
// tunnel to drain immediately instead of waiting for the next poll tick.
|
|
2584
|
+
// Best-effort + non-fatal: mirror the on-connect drain block. A failed
|
|
2585
|
+
// drain here is logged and swallowed — the steady-state poll retries, so
|
|
2586
|
+
// a lost/failed ping can never orphan a message (§2 invariant).
|
|
2587
|
+
onDrainPing: () => {
|
|
2588
|
+
if (!state.running) return;
|
|
2589
|
+
logActivity(state, { type: "info", message: "Drain ping received \u2014 draining" });
|
|
2590
|
+
channelDriver.drainPending().then((processed) => {
|
|
2591
|
+
if (processed > 0) {
|
|
2592
|
+
state.messageCount += processed;
|
|
2593
|
+
logActivity(state, {
|
|
2594
|
+
type: "info",
|
|
2595
|
+
message: `Drained ${processed} queued message(s) on ping`
|
|
2596
|
+
});
|
|
2597
|
+
if (state.interactive) displayStatus(state);
|
|
2598
|
+
}
|
|
2599
|
+
}).catch((error2) => {
|
|
2600
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2601
|
+
logActivity(state, {
|
|
2602
|
+
type: "error",
|
|
2603
|
+
error: `Failed to drain queued messages on ping: ${message}`
|
|
2604
|
+
});
|
|
2605
|
+
if (state.interactive) displayStatus(state);
|
|
2606
|
+
});
|
|
2607
|
+
},
|
|
2281
2608
|
onInfo: (message) => logActivity(state, { type: "info", message })
|
|
2282
2609
|
}
|
|
2283
2610
|
});
|
|
@@ -2288,9 +2615,7 @@ async function run(options) {
|
|
|
2288
2615
|
if (error2.message === "Unauthorized") tunnelSpinner?.fail("Unauthorized");
|
|
2289
2616
|
throw error2;
|
|
2290
2617
|
}
|
|
2291
|
-
if (interactive
|
|
2292
|
-
displayStatus(state);
|
|
2293
|
-
} else {
|
|
2618
|
+
if (!interactive || state.json) {
|
|
2294
2619
|
log(state, "Driving channel messages...");
|
|
2295
2620
|
}
|
|
2296
2621
|
await driveChannels(state, channelDriver);
|
|
@@ -2339,7 +2664,7 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
2339
2664
|
}
|
|
2340
2665
|
});
|
|
2341
2666
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
2342
|
-
program.command("logout").description("Remove stored credentials").action(logout);
|
|
2667
|
+
program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
|
|
2343
2668
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
2344
2669
|
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option("-v, --verbose", "Show detailed request/response information").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").action(
|
|
2345
2670
|
(options) => {
|