@evident-ai/cli 3.1.1-dev.3ec6e7f → 3.1.1-dev.45e3647
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 +45 -0
- package/dist/index.js +1432 -327
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,8 +11,8 @@ import chalk2 from "chalk";
|
|
|
11
11
|
|
|
12
12
|
// src/lib/config.ts
|
|
13
13
|
import Conf from "conf";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { chmodSync, existsSync, statSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
16
|
var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
|
|
17
17
|
var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
|
|
18
18
|
var defaults = {
|
|
@@ -38,17 +38,39 @@ function getApiUrl() {
|
|
|
38
38
|
function getTunnelUrl() {
|
|
39
39
|
return tunnelOverride ?? process.env.EVIDENT_TUNNEL_URL ?? defaults.tunnelUrl;
|
|
40
40
|
}
|
|
41
|
-
var config = new Conf({
|
|
42
|
-
projectName: "evident",
|
|
43
|
-
projectSuffix: "",
|
|
44
|
-
defaults
|
|
45
|
-
});
|
|
46
41
|
var credentials = new Conf({
|
|
47
42
|
projectName: "evident",
|
|
48
43
|
projectSuffix: "",
|
|
49
44
|
configName: "credentials",
|
|
50
|
-
defaults: {}
|
|
45
|
+
defaults: {},
|
|
46
|
+
configFileMode: 384
|
|
51
47
|
});
|
|
48
|
+
var CREDENTIALS_FILE_MODE = 384;
|
|
49
|
+
var CREDENTIALS_DIR_MODE = 448;
|
|
50
|
+
var permissionWarningEmitted = false;
|
|
51
|
+
function hardenCredentialsPermissions() {
|
|
52
|
+
if (process.platform === "win32") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const file = credentials.path;
|
|
56
|
+
for (const [path, mode] of [
|
|
57
|
+
[file, CREDENTIALS_FILE_MODE],
|
|
58
|
+
[dirname(file), CREDENTIALS_DIR_MODE]
|
|
59
|
+
]) {
|
|
60
|
+
try {
|
|
61
|
+
if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
|
|
62
|
+
chmodSync(path, mode);
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!permissionWarningEmitted) {
|
|
66
|
+
permissionWarningEmitted = true;
|
|
67
|
+
console.error(
|
|
68
|
+
`[config] could not restrict permissions on ${path}; the credentials file may be readable by other users on this machine: ${err instanceof Error ? err.message : String(err)}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
52
74
|
function getApiUrlConfig() {
|
|
53
75
|
return getApiUrl();
|
|
54
76
|
}
|
|
@@ -59,6 +81,7 @@ function credentialsKey() {
|
|
|
59
81
|
return getApiUrl();
|
|
60
82
|
}
|
|
61
83
|
function getCredentials() {
|
|
84
|
+
hardenCredentialsPermissions();
|
|
62
85
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
63
86
|
return byEndpoint[credentialsKey()] ?? {};
|
|
64
87
|
}
|
|
@@ -70,14 +93,17 @@ function setCredentials(creds) {
|
|
|
70
93
|
expiresAt: creds.expiresAt
|
|
71
94
|
};
|
|
72
95
|
credentials.set("byEndpoint", byEndpoint);
|
|
96
|
+
hardenCredentialsPermissions();
|
|
73
97
|
}
|
|
74
98
|
function clearCredentials() {
|
|
75
99
|
const byEndpoint = credentials.get("byEndpoint") ?? {};
|
|
76
100
|
delete byEndpoint[credentialsKey()];
|
|
77
101
|
credentials.set("byEndpoint", byEndpoint);
|
|
102
|
+
hardenCredentialsPermissions();
|
|
78
103
|
}
|
|
79
104
|
function clearAllCredentials() {
|
|
80
105
|
credentials.clear();
|
|
106
|
+
hardenCredentialsPermissions();
|
|
81
107
|
}
|
|
82
108
|
function getCliName() {
|
|
83
109
|
const argv1 = process.argv[1] || "";
|
|
@@ -236,16 +262,28 @@ async function getToken() {
|
|
|
236
262
|
}
|
|
237
263
|
return null;
|
|
238
264
|
}
|
|
265
|
+
function toError(err) {
|
|
266
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
267
|
+
}
|
|
239
268
|
async function deleteToken(options = {}) {
|
|
240
269
|
const keytar = await getKeytar();
|
|
270
|
+
const failures = [];
|
|
241
271
|
if (keytar) {
|
|
242
272
|
if (options.all) {
|
|
243
|
-
|
|
273
|
+
let accounts = [];
|
|
274
|
+
try {
|
|
275
|
+
accounts = await keytar.findCredentials(SERVICE_NAME);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
failures.push({ type: "enumerate", error: toError(err) });
|
|
278
|
+
}
|
|
244
279
|
await Promise.all(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
280
|
+
accounts.map(async (entry) => {
|
|
281
|
+
try {
|
|
282
|
+
await keytar.deletePassword(SERVICE_NAME, entry.account);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
failures.push({ type: "delete", account: entry.account, error: toError(err) });
|
|
285
|
+
}
|
|
286
|
+
})
|
|
249
287
|
);
|
|
250
288
|
} else {
|
|
251
289
|
await keytar.deletePassword(SERVICE_NAME, keychainAccount());
|
|
@@ -256,6 +294,7 @@ async function deleteToken(options = {}) {
|
|
|
256
294
|
} else {
|
|
257
295
|
clearCredentials();
|
|
258
296
|
}
|
|
297
|
+
return { failures };
|
|
259
298
|
}
|
|
260
299
|
|
|
261
300
|
// src/utils/ui.ts
|
|
@@ -373,8 +412,10 @@ async function deviceFlowLogin(options) {
|
|
|
373
412
|
}
|
|
374
413
|
async function tokenLogin() {
|
|
375
414
|
console.log("Token login mode.");
|
|
376
|
-
console.log("
|
|
377
|
-
console.log(
|
|
415
|
+
console.log("Create a token under Settings \u2192 CLI tokens in the dashboard, then paste it below.");
|
|
416
|
+
console.log(
|
|
417
|
+
"(Alternatively, run `evident login` on a machine with a browser, or set EVIDENT_TOKEN for CI.)"
|
|
418
|
+
);
|
|
378
419
|
blank();
|
|
379
420
|
process.stdout.write("Paste token: ");
|
|
380
421
|
const token = await new Promise((resolve3) => {
|
|
@@ -398,13 +439,22 @@ async function tokenLogin() {
|
|
|
398
439
|
printError("No token provided.");
|
|
399
440
|
process.exit(1);
|
|
400
441
|
}
|
|
442
|
+
await validateAndStoreToken(token);
|
|
443
|
+
}
|
|
444
|
+
async function validateAndStoreToken(token) {
|
|
401
445
|
const spinner = ora("Validating token...").start();
|
|
402
446
|
try {
|
|
403
|
-
const result = await api.
|
|
447
|
+
const result = await api.get("/me", {
|
|
448
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
449
|
+
});
|
|
450
|
+
if (!result.user) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
"This token is not a user login (e.g. a runner key). Paste a CLI token instead."
|
|
453
|
+
);
|
|
454
|
+
}
|
|
404
455
|
await storeToken({
|
|
405
456
|
token,
|
|
406
|
-
user: result.user
|
|
407
|
-
expiresAt: result.expires_at
|
|
457
|
+
user: { email: result.user.email }
|
|
408
458
|
});
|
|
409
459
|
spinner.stop();
|
|
410
460
|
printSuccess(`Logged in as ${chalk2.bold(result.user.email)}`);
|
|
@@ -424,9 +474,22 @@ async function login(options) {
|
|
|
424
474
|
}
|
|
425
475
|
|
|
426
476
|
// src/commands/logout.ts
|
|
477
|
+
function describeFailure(failure) {
|
|
478
|
+
if (failure.type === "enumerate") {
|
|
479
|
+
return `could not list stored keychain entries (${failure.error.message})`;
|
|
480
|
+
}
|
|
481
|
+
return `${failure.account} (${failure.error.message})`;
|
|
482
|
+
}
|
|
427
483
|
async function logout(options = {}) {
|
|
428
484
|
if (options.all) {
|
|
429
|
-
await deleteToken({ all: true });
|
|
485
|
+
const result = await deleteToken({ all: true });
|
|
486
|
+
if (result.failures.length > 0) {
|
|
487
|
+
printError(
|
|
488
|
+
`Failed to fully clear your keychain: ${result.failures.map(describeFailure).join("; ")}. Your local credentials file was cleared, but stale keychain entries may remain \u2014 run \`evident logout --all\` again, or remove them manually from your OS keychain / credential manager.`
|
|
489
|
+
);
|
|
490
|
+
process.exitCode = 1;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
430
493
|
printSuccess("Logged out of all endpoints.");
|
|
431
494
|
return;
|
|
432
495
|
}
|
|
@@ -448,23 +511,485 @@ async function whoami() {
|
|
|
448
511
|
printError(`Not logged in to ${apiUrl}. Run the \`login\` command to authenticate.`);
|
|
449
512
|
process.exit(1);
|
|
450
513
|
}
|
|
451
|
-
blank();
|
|
452
|
-
console.log(keyValue("Endpoint", apiUrl));
|
|
453
|
-
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
514
|
+
blank();
|
|
515
|
+
console.log(keyValue("Endpoint", apiUrl));
|
|
516
|
+
console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
|
|
517
|
+
if (credentials2.user.id) {
|
|
518
|
+
console.log(keyValue("User ID", credentials2.user.id));
|
|
519
|
+
}
|
|
520
|
+
if (credentials2.expiresAt) {
|
|
521
|
+
const expiresAt = new Date(credentials2.expiresAt);
|
|
522
|
+
const now = /* @__PURE__ */ new Date();
|
|
523
|
+
if (expiresAt < now) {
|
|
524
|
+
console.log(keyValue("Status", chalk3.red("Token expired")));
|
|
525
|
+
} else {
|
|
526
|
+
const daysRemaining = Math.ceil(
|
|
527
|
+
(expiresAt.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24)
|
|
528
|
+
);
|
|
529
|
+
console.log(keyValue("Expires", `${daysRemaining} days`));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
blank();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/lib/auth.ts
|
|
536
|
+
async function getAuthCredentials() {
|
|
537
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
538
|
+
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
539
|
+
if (runnerKey) {
|
|
540
|
+
return {
|
|
541
|
+
token: runnerKey,
|
|
542
|
+
authType: "agent_key",
|
|
543
|
+
keySource: "runner_key",
|
|
544
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
if (agentKey) {
|
|
548
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
549
|
+
}
|
|
550
|
+
const userToken = process.env.EVIDENT_TOKEN;
|
|
551
|
+
if (userToken) {
|
|
552
|
+
return { token: userToken, authType: "bearer" };
|
|
553
|
+
}
|
|
554
|
+
const keychainCreds = await getToken();
|
|
555
|
+
if (keychainCreds) {
|
|
556
|
+
return {
|
|
557
|
+
token: keychainCreds.token,
|
|
558
|
+
authType: "bearer",
|
|
559
|
+
user: keychainCreds.user
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
function getAuthHeader(credentials2) {
|
|
565
|
+
if (credentials2.authType === "agent_key") {
|
|
566
|
+
return `SandboxKey ${credentials2.token}`;
|
|
567
|
+
}
|
|
568
|
+
return `Bearer ${credentials2.token}`;
|
|
569
|
+
}
|
|
570
|
+
function isInteractive(jsonOutput) {
|
|
571
|
+
if (jsonOutput) return false;
|
|
572
|
+
if (process.env.CI) return false;
|
|
573
|
+
if (process.env.GITHUB_ACTIONS) return false;
|
|
574
|
+
if (!process.stdin.isTTY) return false;
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/commands/agent-lookup.ts
|
|
579
|
+
async function readErrorMessage(response) {
|
|
580
|
+
const text = await response.text().catch(() => "");
|
|
581
|
+
if (!text) return response.statusText || void 0;
|
|
582
|
+
try {
|
|
583
|
+
const data = JSON.parse(text);
|
|
584
|
+
const message = data.message ?? data.error;
|
|
585
|
+
if (typeof message === "string" && message.trim()) {
|
|
586
|
+
return message;
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
return text.trim() || response.statusText || void 0;
|
|
591
|
+
}
|
|
592
|
+
function authFailureHint(apiUrl, serverMessage) {
|
|
593
|
+
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
594
|
+
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.`;
|
|
595
|
+
}
|
|
596
|
+
async function resolveAgentIdFromKey(authHeader) {
|
|
597
|
+
const apiUrl = getApiUrlConfig();
|
|
598
|
+
try {
|
|
599
|
+
const response = await fetch(`${apiUrl}/me`, {
|
|
600
|
+
headers: { Authorization: authHeader }
|
|
601
|
+
});
|
|
602
|
+
if (response.status === 401) {
|
|
603
|
+
const serverMessage = await readErrorMessage(response);
|
|
604
|
+
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
605
|
+
}
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
const serverMessage = await readErrorMessage(response);
|
|
608
|
+
return {
|
|
609
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const data = await response.json();
|
|
613
|
+
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
614
|
+
return { agent_id: data.agent_id };
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
618
|
+
};
|
|
619
|
+
} catch (error2) {
|
|
620
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
621
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
625
|
+
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
626
|
+
const apiUrl = getApiUrlConfig();
|
|
627
|
+
try {
|
|
628
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { Authorization: authHeader },
|
|
631
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
632
|
+
});
|
|
633
|
+
if (!response.ok) {
|
|
634
|
+
const serverMessage = await readErrorMessage(response);
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { ok: true };
|
|
641
|
+
} catch (error2) {
|
|
642
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function describeBestEffortError(error2) {
|
|
646
|
+
const name = error2?.name;
|
|
647
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
648
|
+
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
649
|
+
}
|
|
650
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
651
|
+
}
|
|
652
|
+
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
653
|
+
try {
|
|
654
|
+
const apiUrl = getApiUrlConfig();
|
|
655
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
658
|
+
body: JSON.stringify({ microvm_id: microvmId }),
|
|
659
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
660
|
+
});
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
const serverMessage = await readErrorMessage(response);
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return { ok: true };
|
|
669
|
+
} catch (error2) {
|
|
670
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function toReportedWindow(window) {
|
|
674
|
+
if (!window) return null;
|
|
675
|
+
return { utilization: window.utilization, resets_at: window.resetsAt };
|
|
676
|
+
}
|
|
677
|
+
async function reportClaudeUsage(agentId, authHeader, snapshot) {
|
|
678
|
+
try {
|
|
679
|
+
const apiUrl = getApiUrlConfig();
|
|
680
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/claude-usage`, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
five_hour: toReportedWindow(snapshot.fiveHour),
|
|
685
|
+
seven_day: toReportedWindow(snapshot.sevenDay)
|
|
686
|
+
}),
|
|
687
|
+
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
688
|
+
});
|
|
689
|
+
if (!response.ok) {
|
|
690
|
+
const serverMessage = await readErrorMessage(response);
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return { ok: true };
|
|
697
|
+
} catch (error2) {
|
|
698
|
+
return { ok: false, error: describeBestEffortError(error2) };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function getAgentInfo(agentId, authHeader) {
|
|
702
|
+
const apiUrl = getApiUrlConfig();
|
|
703
|
+
try {
|
|
704
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
705
|
+
headers: { Authorization: authHeader }
|
|
706
|
+
});
|
|
707
|
+
if (response.status === 401) {
|
|
708
|
+
const serverMessage = await readErrorMessage(response);
|
|
709
|
+
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
710
|
+
}
|
|
711
|
+
if (response.status === 403) {
|
|
712
|
+
const serverMessage = await readErrorMessage(response);
|
|
713
|
+
return {
|
|
714
|
+
valid: false,
|
|
715
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (response.status === 404) {
|
|
719
|
+
const serverMessage = await readErrorMessage(response);
|
|
720
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
721
|
+
}
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
const serverMessage = await readErrorMessage(response);
|
|
724
|
+
return {
|
|
725
|
+
valid: false,
|
|
726
|
+
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const agent = await response.json();
|
|
730
|
+
if (agent.agent_type !== "local") {
|
|
731
|
+
return {
|
|
732
|
+
valid: false,
|
|
733
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return { valid: true, agent };
|
|
737
|
+
} catch (error2) {
|
|
738
|
+
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
739
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/commands/status.ts
|
|
744
|
+
var STATUS_TIMEOUT_MS = 1e4;
|
|
745
|
+
function authLabelFor(credentials2) {
|
|
746
|
+
if (credentials2.authType === "agent_key") {
|
|
747
|
+
return credentials2.keySource === "agent_key" ? "runner key (EVIDENT_AGENT_KEY)" : "runner key (EVIDENT_RUNNER_KEY)";
|
|
748
|
+
}
|
|
749
|
+
return "user token";
|
|
750
|
+
}
|
|
751
|
+
function describeFetchError(error2) {
|
|
752
|
+
const name = error2?.name;
|
|
753
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
754
|
+
return `timed out after ${STATUS_TIMEOUT_MS}ms waiting for a response`;
|
|
755
|
+
}
|
|
756
|
+
return error2 instanceof Error ? error2.message : String(error2);
|
|
757
|
+
}
|
|
758
|
+
async function checkStatus(jsonMode) {
|
|
759
|
+
const apiUrl = getApiUrlConfig();
|
|
760
|
+
const credentials2 = await getAuthCredentials();
|
|
761
|
+
if (!credentials2) {
|
|
762
|
+
return {
|
|
763
|
+
ok: false,
|
|
764
|
+
endpoint: apiUrl,
|
|
765
|
+
reason: "no_credentials",
|
|
766
|
+
error: "No credentials configured. Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY), or EVIDENT_TOKEN, or run `evident login`.",
|
|
767
|
+
exitCode: 1
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (credentials2.notice && !jsonMode) {
|
|
771
|
+
printWarning(credentials2.notice);
|
|
772
|
+
}
|
|
773
|
+
let response;
|
|
774
|
+
try {
|
|
775
|
+
response = await fetch(`${apiUrl}/me`, {
|
|
776
|
+
headers: { Authorization: getAuthHeader(credentials2) },
|
|
777
|
+
signal: AbortSignal.timeout(STATUS_TIMEOUT_MS)
|
|
778
|
+
});
|
|
779
|
+
} catch (error2) {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
endpoint: apiUrl,
|
|
783
|
+
authLabel: authLabelFor(credentials2),
|
|
784
|
+
reason: "unreachable",
|
|
785
|
+
error: `Could not reach ${apiUrl}: ${describeFetchError(error2)}. The credentials were NOT validated.`,
|
|
786
|
+
exitCode: 75
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (response.status === 401) {
|
|
790
|
+
const serverMessage = await readErrorMessage(response);
|
|
791
|
+
return {
|
|
792
|
+
ok: false,
|
|
793
|
+
endpoint: apiUrl,
|
|
794
|
+
authLabel: authLabelFor(credentials2),
|
|
795
|
+
reason: "unauthorized",
|
|
796
|
+
error: authFailureHint(apiUrl, serverMessage),
|
|
797
|
+
exitCode: 1
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (response.status >= 500) {
|
|
801
|
+
const serverMessage = await readErrorMessage(response);
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
endpoint: apiUrl,
|
|
805
|
+
authLabel: authLabelFor(credentials2),
|
|
806
|
+
reason: "unreachable",
|
|
807
|
+
error: `${apiUrl} returned HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}. The credentials were NOT validated.`,
|
|
808
|
+
exitCode: 75
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (!response.ok) {
|
|
812
|
+
const serverMessage = await readErrorMessage(response);
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
endpoint: apiUrl,
|
|
816
|
+
authLabel: authLabelFor(credentials2),
|
|
817
|
+
reason: "http_error",
|
|
818
|
+
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`,
|
|
819
|
+
exitCode: 1
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const data = await response.json();
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
endpoint: apiUrl,
|
|
826
|
+
authType: data.auth_type,
|
|
827
|
+
authLabel: authLabelFor(credentials2),
|
|
828
|
+
runnerId: data.auth_type === "agent_key" ? data.agent_id : void 0,
|
|
829
|
+
reason: "ok",
|
|
830
|
+
exitCode: 0
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function printJson(result) {
|
|
834
|
+
const payload = {
|
|
835
|
+
ok: result.ok,
|
|
836
|
+
endpoint: result.endpoint
|
|
837
|
+
};
|
|
838
|
+
if (result.authType) payload.auth_type = result.authType;
|
|
839
|
+
if (result.runnerId) payload.runner_id = result.runnerId;
|
|
840
|
+
if (result.reason) payload.reason = result.reason;
|
|
841
|
+
if (result.error) payload.error = result.error;
|
|
842
|
+
console.log(JSON.stringify(payload));
|
|
843
|
+
}
|
|
844
|
+
function printHuman(result) {
|
|
845
|
+
blank();
|
|
846
|
+
console.log(keyValue("Endpoint", result.endpoint));
|
|
847
|
+
if (result.ok) {
|
|
848
|
+
console.log(keyValue("Auth", result.authLabel ?? "\u2014"));
|
|
849
|
+
if (result.runnerId) {
|
|
850
|
+
console.log(keyValue("Runner", result.runnerId));
|
|
851
|
+
}
|
|
852
|
+
console.log(keyValue("Status", "OK \u2014 credentials accepted"));
|
|
853
|
+
blank();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (result.authLabel) {
|
|
857
|
+
console.log(keyValue("Auth", result.authLabel));
|
|
858
|
+
}
|
|
859
|
+
blank();
|
|
860
|
+
printError(result.error ?? "Unknown error");
|
|
861
|
+
}
|
|
862
|
+
async function status(options = {}) {
|
|
863
|
+
const result = await checkStatus(Boolean(options.json));
|
|
864
|
+
if (options.json) {
|
|
865
|
+
printJson(result);
|
|
866
|
+
} else {
|
|
867
|
+
printHuman(result);
|
|
868
|
+
}
|
|
869
|
+
process.exit(result.exitCode);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/lib/claude-usage.ts
|
|
873
|
+
import { execFileSync } from "child_process";
|
|
874
|
+
import { readFileSync } from "fs";
|
|
875
|
+
import { homedir } from "os";
|
|
876
|
+
import { join } from "path";
|
|
877
|
+
var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
878
|
+
var KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
879
|
+
function parseClaudeCliCredentials(raw) {
|
|
880
|
+
let parsed;
|
|
881
|
+
try {
|
|
882
|
+
parsed = JSON.parse(raw);
|
|
883
|
+
} catch {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
const data = parsed.claudeAiOauth ?? parsed;
|
|
887
|
+
const creds = data;
|
|
888
|
+
if (typeof creds.accessToken !== "string" || typeof creds.expiresAt !== "number") {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
return { accessToken: creds.accessToken, expiresAt: creds.expiresAt };
|
|
892
|
+
}
|
|
893
|
+
function readClaudeCliCredentials() {
|
|
894
|
+
if (process.platform === "darwin") {
|
|
895
|
+
try {
|
|
896
|
+
const raw = execFileSync(
|
|
897
|
+
"/usr/bin/security",
|
|
898
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
|
|
899
|
+
{ encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
|
|
900
|
+
);
|
|
901
|
+
return parseClaudeCliCredentials(raw);
|
|
902
|
+
} catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
|
|
908
|
+
return parseClaudeCliCredentials(raw);
|
|
909
|
+
} catch {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
var ClaudeUsageError = class extends Error {
|
|
914
|
+
constructor(message, reason) {
|
|
915
|
+
super(message);
|
|
916
|
+
this.reason = reason;
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
function isLocalCredentialProblem(err) {
|
|
920
|
+
return err instanceof ClaudeUsageError && (err.reason === "no_credentials" || err.reason === "credentials_expired");
|
|
921
|
+
}
|
|
922
|
+
function normalizeResetsAt(value) {
|
|
923
|
+
const ms = Date.parse(value);
|
|
924
|
+
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
|
|
925
|
+
}
|
|
926
|
+
function toWindow(value) {
|
|
927
|
+
if (!value || typeof value !== "object") {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
const window = value;
|
|
931
|
+
if (typeof window.utilization !== "number" || typeof window.resets_at !== "string") {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
const resetsAt = normalizeResetsAt(window.resets_at);
|
|
935
|
+
if (resetsAt === null) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
return { utilization: window.utilization, resetsAt };
|
|
939
|
+
}
|
|
940
|
+
async function getClaudeUsage() {
|
|
941
|
+
const credentials2 = readClaudeCliCredentials();
|
|
942
|
+
if (!credentials2) {
|
|
943
|
+
throw new ClaudeUsageError(
|
|
944
|
+
"No local Claude Code login found. Run `claude` once to sign in with your Claude subscription.",
|
|
945
|
+
"no_credentials"
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
if (credentials2.expiresAt < Date.now()) {
|
|
949
|
+
throw new ClaudeUsageError(
|
|
950
|
+
"Claude Code credentials have expired. Run `claude` to refresh them.",
|
|
951
|
+
"credentials_expired"
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
const res = await fetch(CLAUDE_USAGE_URL, {
|
|
955
|
+
headers: {
|
|
956
|
+
Authorization: `Bearer ${credentials2.accessToken}`,
|
|
957
|
+
"Content-Type": "application/json",
|
|
958
|
+
"anthropic-version": "2023-06-01"
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
if (!res.ok) {
|
|
962
|
+
throw new ClaudeUsageError(`Claude usage request failed: HTTP ${res.status}`, "request_failed");
|
|
963
|
+
}
|
|
964
|
+
const body = await res.json();
|
|
965
|
+
return {
|
|
966
|
+
fiveHour: toWindow(body.five_hour),
|
|
967
|
+
sevenDay: toWindow(body.seven_day)
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/commands/claude-usage.ts
|
|
972
|
+
function formatWindow(label, window) {
|
|
973
|
+
if (!window) {
|
|
974
|
+
return keyValue(label, "not available for this plan");
|
|
975
|
+
}
|
|
976
|
+
const resetsAt = new Date(window.resetsAt);
|
|
977
|
+
return keyValue(label, `${window.utilization}% used, resets ${resetsAt.toLocaleString()}`);
|
|
978
|
+
}
|
|
979
|
+
async function claudeUsage() {
|
|
980
|
+
try {
|
|
981
|
+
const usage = await getClaudeUsage();
|
|
982
|
+
blank();
|
|
983
|
+
console.log(formatWindow("5-hour session", usage.fiveHour));
|
|
984
|
+
console.log(formatWindow("7-day", usage.sevenDay));
|
|
985
|
+
blank();
|
|
986
|
+
} catch (err) {
|
|
987
|
+
if (err instanceof ClaudeUsageError) {
|
|
988
|
+
printError(err.message);
|
|
989
|
+
process.exit(1);
|
|
465
990
|
}
|
|
991
|
+
throw err;
|
|
466
992
|
}
|
|
467
|
-
blank();
|
|
468
993
|
}
|
|
469
994
|
|
|
470
995
|
// src/commands/run.ts
|
|
@@ -479,7 +1004,10 @@ var TelemetryEventTypes = {
|
|
|
479
1004
|
AGENT_DISCONNECTED: "agent.disconnected",
|
|
480
1005
|
AGENT_MESSAGE_PROCESSING: "agent.message_processing",
|
|
481
1006
|
AGENT_MESSAGE_DONE: "agent.message_done",
|
|
482
|
-
AGENT_MESSAGE_FAILED: "agent.message_failed"
|
|
1007
|
+
AGENT_MESSAGE_FAILED: "agent.message_failed",
|
|
1008
|
+
// A `warn`/`error` runner-side log line forwarded server-side for
|
|
1009
|
+
// observability (issue #916) — see `apps/cli/src/lib/runner-activity-telemetry.ts`.
|
|
1010
|
+
RUNNER_ACTIVITY: "runner.activity"
|
|
483
1011
|
};
|
|
484
1012
|
|
|
485
1013
|
// ../../packages/types/src/tunnel/index.ts
|
|
@@ -534,6 +1062,13 @@ var isShuttingDown = false;
|
|
|
534
1062
|
var FLUSH_INTERVAL_MS = 5e3;
|
|
535
1063
|
var MAX_BUFFER_SIZE = 50;
|
|
536
1064
|
var FLUSH_TIMEOUT_MS = 3e3;
|
|
1065
|
+
var authProvider = null;
|
|
1066
|
+
function setTelemetryAuthProvider(provider) {
|
|
1067
|
+
authProvider = provider;
|
|
1068
|
+
}
|
|
1069
|
+
var FLUSH_FAILURE_LOG_INTERVAL_MS = 6e4;
|
|
1070
|
+
var lastFlushFailureLoggedAt = 0;
|
|
1071
|
+
var suppressedFlushFailureCount = 0;
|
|
537
1072
|
function logEvent(eventType, options = {}) {
|
|
538
1073
|
const event = {
|
|
539
1074
|
event_type: eventType,
|
|
@@ -568,9 +1103,16 @@ async function flushEvents() {
|
|
|
568
1103
|
flushTimeout = null;
|
|
569
1104
|
}
|
|
570
1105
|
try {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
1106
|
+
const providerContext = authProvider?.();
|
|
1107
|
+
let authHeader;
|
|
1108
|
+
if (providerContext?.authHeader) {
|
|
1109
|
+
authHeader = providerContext.authHeader;
|
|
1110
|
+
} else {
|
|
1111
|
+
const credentials2 = await getToken();
|
|
1112
|
+
if (!credentials2) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
authHeader = `Bearer ${credentials2.token}`;
|
|
574
1116
|
}
|
|
575
1117
|
const apiUrl = getApiUrlConfig();
|
|
576
1118
|
const controller = new AbortController();
|
|
@@ -585,7 +1127,7 @@ async function flushEvents() {
|
|
|
585
1127
|
method: "POST",
|
|
586
1128
|
headers: {
|
|
587
1129
|
"Content-Type": "application/json",
|
|
588
|
-
Authorization:
|
|
1130
|
+
Authorization: authHeader
|
|
589
1131
|
},
|
|
590
1132
|
body: JSON.stringify(request),
|
|
591
1133
|
signal: controller.signal
|
|
@@ -597,8 +1139,15 @@ async function flushEvents() {
|
|
|
597
1139
|
clearTimeout(timeout);
|
|
598
1140
|
}
|
|
599
1141
|
} catch (error2) {
|
|
600
|
-
|
|
601
|
-
|
|
1142
|
+
const now = Date.now();
|
|
1143
|
+
if (now - lastFlushFailureLoggedAt >= FLUSH_FAILURE_LOG_INTERVAL_MS) {
|
|
1144
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
1145
|
+
const suffix = suppressedFlushFailureCount > 0 ? ` (${suppressedFlushFailureCount} more suppressed in the last ${FLUSH_FAILURE_LOG_INTERVAL_MS / 1e3}s)` : "";
|
|
1146
|
+
console.error(`Telemetry flush error: ${message}${suffix}`);
|
|
1147
|
+
lastFlushFailureLoggedAt = now;
|
|
1148
|
+
suppressedFlushFailureCount = 0;
|
|
1149
|
+
} else {
|
|
1150
|
+
suppressedFlushFailureCount++;
|
|
602
1151
|
}
|
|
603
1152
|
}
|
|
604
1153
|
}
|
|
@@ -667,47 +1216,67 @@ var EventTypes = {
|
|
|
667
1216
|
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
668
1217
|
};
|
|
669
1218
|
|
|
670
|
-
// src/lib/
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1219
|
+
// src/lib/runner-activity-telemetry.ts
|
|
1220
|
+
var FORWARDED_LEVELS = /* @__PURE__ */ new Set(["warn", "error"]);
|
|
1221
|
+
var SEVERITY_BY_LEVEL = {
|
|
1222
|
+
warn: "warning",
|
|
1223
|
+
error: "error"
|
|
1224
|
+
};
|
|
1225
|
+
var MAX_MESSAGE_LENGTH = 500;
|
|
1226
|
+
var TRUNCATION_MARKER = "\u2026";
|
|
1227
|
+
function redact(message) {
|
|
1228
|
+
return message.replace(/esk_[A-Za-z0-9_-]+/g, "esk_***").replace(/ct_[A-Za-z0-9_-]+/g, "ct_***").replace(/https?:\/\/\S+/g, "<url>");
|
|
1229
|
+
}
|
|
1230
|
+
function truncate(message) {
|
|
1231
|
+
if (message.length <= MAX_MESSAGE_LENGTH) return message;
|
|
1232
|
+
return message.slice(0, MAX_MESSAGE_LENGTH - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
1233
|
+
}
|
|
1234
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
1235
|
+
var RATE_LIMIT_MAX_EVENTS = 30;
|
|
1236
|
+
var windowStartedAt = 0;
|
|
1237
|
+
var windowCount = 0;
|
|
1238
|
+
var windowDroppedCount = 0;
|
|
1239
|
+
function admitUnderRateLimit(now) {
|
|
1240
|
+
if (now - windowStartedAt >= RATE_LIMIT_WINDOW_MS) {
|
|
1241
|
+
if (windowDroppedCount > 0) {
|
|
1242
|
+
console.error(
|
|
1243
|
+
`[runner-activity-telemetry] rate cap reached: dropped ${windowDroppedCount} ${windowDroppedCount === 1 ? "entry" : "entries"} in the last ${RATE_LIMIT_WINDOW_MS / 1e3}s (cap ${RATE_LIMIT_MAX_EVENTS}/min)`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
windowStartedAt = now;
|
|
1247
|
+
windowCount = 0;
|
|
1248
|
+
windowDroppedCount = 0;
|
|
688
1249
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
1250
|
+
if (windowCount >= RATE_LIMIT_MAX_EVENTS) {
|
|
1251
|
+
windowDroppedCount++;
|
|
1252
|
+
if (windowDroppedCount === 1) {
|
|
1253
|
+
console.error(
|
|
1254
|
+
`[runner-activity-telemetry] rate cap reached (${RATE_LIMIT_MAX_EVENTS} per ${RATE_LIMIT_WINDOW_MS / 1e3}s) \u2014 dropping further entries this window`
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
return false;
|
|
696
1258
|
}
|
|
697
|
-
|
|
1259
|
+
windowCount++;
|
|
1260
|
+
return true;
|
|
698
1261
|
}
|
|
699
|
-
function
|
|
700
|
-
|
|
701
|
-
|
|
1262
|
+
function forwardRunnerActivity(entry, context) {
|
|
1263
|
+
try {
|
|
1264
|
+
if (!FORWARDED_LEVELS.has(entry.level)) return;
|
|
1265
|
+
if (!context.agentId || !context.authHeader) return;
|
|
1266
|
+
if (!admitUnderRateLimit(Date.now())) return;
|
|
1267
|
+
const rawMessage = entry.error ?? entry.message ?? "";
|
|
1268
|
+
const message = truncate(redact(rawMessage));
|
|
1269
|
+
logEvent(TelemetryEventTypes.RUNNER_ACTIVITY, {
|
|
1270
|
+
severity: SEVERITY_BY_LEVEL[entry.level],
|
|
1271
|
+
message,
|
|
1272
|
+
metadata: { source: "cli.run" },
|
|
1273
|
+
agentId: context.agentId
|
|
1274
|
+
});
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
console.error(
|
|
1277
|
+
`[runner-activity-telemetry] failed to forward runner activity: ${err instanceof Error ? err.message : String(err)}`
|
|
1278
|
+
);
|
|
702
1279
|
}
|
|
703
|
-
return `Bearer ${credentials2.token}`;
|
|
704
|
-
}
|
|
705
|
-
function isInteractive(jsonOutput) {
|
|
706
|
-
if (jsonOutput) return false;
|
|
707
|
-
if (process.env.CI) return false;
|
|
708
|
-
if (process.env.GITHUB_ACTIONS) return false;
|
|
709
|
-
if (!process.stdin.isTTY) return false;
|
|
710
|
-
return true;
|
|
711
1280
|
}
|
|
712
1281
|
|
|
713
1282
|
// src/lib/opencode/health.ts
|
|
@@ -1501,6 +2070,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
|
|
|
1501
2070
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1502
2071
|
return completedOf(reply) != null && finishOf(reply) === "tool-calls";
|
|
1503
2072
|
}
|
|
2073
|
+
function isB2AbandonmentConfirmed(params) {
|
|
2074
|
+
return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
|
|
2075
|
+
}
|
|
1504
2076
|
function messageError(messages, userMessageId) {
|
|
1505
2077
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1506
2078
|
const error2 = errorOf(reply);
|
|
@@ -1514,6 +2086,42 @@ function messageError(messages, userMessageId) {
|
|
|
1514
2086
|
}
|
|
1515
2087
|
return "The agent run failed.";
|
|
1516
2088
|
}
|
|
2089
|
+
function messageFailure(messages, userMessageId) {
|
|
2090
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2091
|
+
const error2 = errorOf(reply);
|
|
2092
|
+
if (error2 == null || typeof error2 !== "object") return null;
|
|
2093
|
+
const e = error2;
|
|
2094
|
+
const replyProviderId = reply?.info?.providerID ?? null;
|
|
2095
|
+
const replyModelId = reply?.info?.modelID ?? null;
|
|
2096
|
+
if (e.name === "ProviderAuthError") {
|
|
2097
|
+
const data = e.data;
|
|
2098
|
+
const providerId = typeof data?.providerID === "string" && data.providerID || replyProviderId;
|
|
2099
|
+
return { kind: "model_auth", providerId, modelId: replyModelId, reason: "missing" };
|
|
2100
|
+
}
|
|
2101
|
+
if (e.name === "APIError") {
|
|
2102
|
+
const data = e.data;
|
|
2103
|
+
const statusCode = data?.statusCode;
|
|
2104
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
2105
|
+
return {
|
|
2106
|
+
kind: "model_auth",
|
|
2107
|
+
providerId: replyProviderId,
|
|
2108
|
+
modelId: replyModelId,
|
|
2109
|
+
reason: "rejected"
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return null;
|
|
2114
|
+
}
|
|
2115
|
+
function applyZeroProviderFallback(classified, hasConfiguredProvider, replyProviderId, replyModelId = null) {
|
|
2116
|
+
if (classified != null) return classified;
|
|
2117
|
+
if (hasConfiguredProvider !== false) return null;
|
|
2118
|
+
return {
|
|
2119
|
+
kind: "model_auth",
|
|
2120
|
+
providerId: replyProviderId,
|
|
2121
|
+
modelId: replyModelId,
|
|
2122
|
+
reason: "missing"
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
1517
2125
|
function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
1518
2126
|
if (!messages || messages.length === 0) return false;
|
|
1519
2127
|
return messages.some(
|
|
@@ -2042,13 +2650,56 @@ var RunnerConnection = class {
|
|
|
2042
2650
|
}
|
|
2043
2651
|
};
|
|
2044
2652
|
|
|
2653
|
+
// src/lib/tunnel/ready-marker.ts
|
|
2654
|
+
import { writeFileSync } from "fs";
|
|
2655
|
+
function writeTunnelReadyMarker(path, agentId) {
|
|
2656
|
+
try {
|
|
2657
|
+
writeFileSync(path, `${agentId}
|
|
2658
|
+
`);
|
|
2659
|
+
return { ok: true };
|
|
2660
|
+
} catch (error2) {
|
|
2661
|
+
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// src/lib/claude-usage-reporting.ts
|
|
2666
|
+
var VALID_MODES = ["auto", "on", "off"];
|
|
2667
|
+
function resolveClaudeUsageReportingMode(flagValue, env) {
|
|
2668
|
+
const raw = flagValue ?? env.EVIDENT_CLAUDE_USAGE_REPORTING;
|
|
2669
|
+
if (raw === void 0 || raw === "") {
|
|
2670
|
+
return { mode: "auto", warnings: [] };
|
|
2671
|
+
}
|
|
2672
|
+
const normalized = raw.trim().toLowerCase();
|
|
2673
|
+
if (VALID_MODES.includes(normalized)) {
|
|
2674
|
+
return { mode: normalized, warnings: [] };
|
|
2675
|
+
}
|
|
2676
|
+
const source = flagValue !== void 0 ? "--claude-usage-reporting" : "EVIDENT_CLAUDE_USAGE_REPORTING";
|
|
2677
|
+
return {
|
|
2678
|
+
mode: "auto",
|
|
2679
|
+
warnings: [
|
|
2680
|
+
`Ignoring invalid ${source} "${raw}": expected one of ${VALID_MODES.join(", ")}; using auto`
|
|
2681
|
+
]
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
var BASE_REPORT_DELAY_MS = 10 * 6e4;
|
|
2685
|
+
var REPORT_DELAY_JITTER_FRACTION = 0.2;
|
|
2686
|
+
function nextReportDelayMs(random = Math.random) {
|
|
2687
|
+
const jitterRangeMs = BASE_REPORT_DELAY_MS * REPORT_DELAY_JITTER_FRACTION;
|
|
2688
|
+
return BASE_REPORT_DELAY_MS - jitterRangeMs + random() * (2 * jitterRangeMs);
|
|
2689
|
+
}
|
|
2690
|
+
var FIRST_REPORT_DELAY_MS = 5e3 + Math.random() * 1e4;
|
|
2691
|
+
var CLAUDE_USAGE_FAILURE_REESCALATION_TICKS = 6;
|
|
2692
|
+
function claudeUsageFailureLogLevel(consecutiveFailures) {
|
|
2693
|
+
return consecutiveFailures === 1 || consecutiveFailures % CLAUDE_USAGE_FAILURE_REESCALATION_TICKS === 0 ? "warn" : "debug";
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2045
2696
|
// src/lib/channels/driver.ts
|
|
2046
2697
|
import { homedir as homedir2 } from "os";
|
|
2047
2698
|
|
|
2048
2699
|
// src/lib/file-push.ts
|
|
2049
2700
|
import { randomUUID } from "crypto";
|
|
2050
2701
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2051
|
-
import { basename, dirname, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
|
|
2702
|
+
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
|
|
2052
2703
|
var FILE_MODE = 384;
|
|
2053
2704
|
var DIRECTORY_MODE = 448;
|
|
2054
2705
|
async function writePushedFile(request) {
|
|
@@ -2079,7 +2730,7 @@ async function writePushedFile(request) {
|
|
|
2079
2730
|
}
|
|
2080
2731
|
try {
|
|
2081
2732
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2082
|
-
|
|
2733
|
+
dirname2(candidate)
|
|
2083
2734
|
);
|
|
2084
2735
|
const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
|
|
2085
2736
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
@@ -2091,8 +2742,8 @@ async function writePushedFile(request) {
|
|
|
2091
2742
|
}
|
|
2092
2743
|
if (missingSegments.length > 0) {
|
|
2093
2744
|
await createMissingDirectories(existingAncestor, missingSegments);
|
|
2094
|
-
const realParent = await realpath(
|
|
2095
|
-
if (realParent !==
|
|
2745
|
+
const realParent = await realpath(dirname2(realTarget));
|
|
2746
|
+
if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
|
|
2096
2747
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
2097
2748
|
path: realTarget,
|
|
2098
2749
|
bytes,
|
|
@@ -2135,7 +2786,7 @@ async function resolveNearestExistingAncestor(directory) {
|
|
|
2135
2786
|
try {
|
|
2136
2787
|
return { existingAncestor: await realpath(current), missingSegments };
|
|
2137
2788
|
} catch (err) {
|
|
2138
|
-
const parent =
|
|
2789
|
+
const parent = dirname2(current);
|
|
2139
2790
|
if (err.code !== "ENOENT" || parent === current) {
|
|
2140
2791
|
throw err;
|
|
2141
2792
|
}
|
|
@@ -2196,7 +2847,7 @@ async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
|
2196
2847
|
}
|
|
2197
2848
|
}
|
|
2198
2849
|
async function writeAtomically(realTarget, content) {
|
|
2199
|
-
const temporaryPath = join2(
|
|
2850
|
+
const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2200
2851
|
let handle;
|
|
2201
2852
|
try {
|
|
2202
2853
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -2363,8 +3014,8 @@ async function applyOne(options, file) {
|
|
|
2363
3014
|
await ack(options, file, "applied");
|
|
2364
3015
|
return true;
|
|
2365
3016
|
}
|
|
2366
|
-
function durableDownloadCode(
|
|
2367
|
-
return
|
|
3017
|
+
function durableDownloadCode(status2) {
|
|
3018
|
+
return status2 === 413 ? "file_too_large" : "write_failed";
|
|
2368
3019
|
}
|
|
2369
3020
|
async function downloadContent(options, file, label) {
|
|
2370
3021
|
try {
|
|
@@ -2397,8 +3048,8 @@ async function downloadContent(options, file, label) {
|
|
|
2397
3048
|
return { ok: false, terminal: false };
|
|
2398
3049
|
}
|
|
2399
3050
|
}
|
|
2400
|
-
async function ack(options, file,
|
|
2401
|
-
const outcome = `${
|
|
3051
|
+
async function ack(options, file, status2, reason) {
|
|
3052
|
+
const outcome = `${status2}${reason ? ` (${reason})` : ""}`;
|
|
2402
3053
|
try {
|
|
2403
3054
|
const res = await options.fetchImpl(
|
|
2404
3055
|
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
@@ -2408,7 +3059,7 @@ async function ack(options, file, status, reason) {
|
|
|
2408
3059
|
Authorization: options.getAuthHeader(),
|
|
2409
3060
|
"Content-Type": "application/json"
|
|
2410
3061
|
},
|
|
2411
|
-
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
3062
|
+
body: JSON.stringify(reason ? { status: status2, reason } : { status: status2 })
|
|
2412
3063
|
}
|
|
2413
3064
|
);
|
|
2414
3065
|
if (!res.ok) {
|
|
@@ -2468,6 +3119,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
|
2468
3119
|
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
2469
3120
|
var HEARTBEAT_MS = 6e4;
|
|
2470
3121
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
3122
|
+
var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
3123
|
+
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
2471
3124
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2472
3125
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
2473
3126
|
var ChannelAuthError = class extends Error {
|
|
@@ -2478,10 +3131,10 @@ var ChannelAuthError = class extends Error {
|
|
|
2478
3131
|
};
|
|
2479
3132
|
var ChannelTerminalError = class extends Error {
|
|
2480
3133
|
status;
|
|
2481
|
-
constructor(message,
|
|
3134
|
+
constructor(message, status2) {
|
|
2482
3135
|
super(message);
|
|
2483
3136
|
this.name = "ChannelTerminalError";
|
|
2484
|
-
this.status =
|
|
3137
|
+
this.status = status2;
|
|
2485
3138
|
}
|
|
2486
3139
|
};
|
|
2487
3140
|
function backoffDelay(attempt, policy) {
|
|
@@ -2489,8 +3142,8 @@ function backoffDelay(attempt, policy) {
|
|
|
2489
3142
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
2490
3143
|
return Math.floor(Math.random() * capped);
|
|
2491
3144
|
}
|
|
2492
|
-
function isRetryableStatus(
|
|
2493
|
-
return
|
|
3145
|
+
function isRetryableStatus(status2) {
|
|
3146
|
+
return status2 === 429 || status2 >= 500 && status2 <= 599;
|
|
2494
3147
|
}
|
|
2495
3148
|
var ChannelDriver = class _ChannelDriver {
|
|
2496
3149
|
agentId;
|
|
@@ -2693,23 +3346,23 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2693
3346
|
* and stops opencode.
|
|
2694
3347
|
*/
|
|
2695
3348
|
stopped = false;
|
|
2696
|
-
constructor(
|
|
2697
|
-
this.agentId =
|
|
2698
|
-
this.port =
|
|
2699
|
-
this.apiUrl =
|
|
2700
|
-
this.getAuthHeader =
|
|
2701
|
-
this.conversationFilter =
|
|
2702
|
-
this.retry = { ...DEFAULT_RETRY_POLICY, ...
|
|
2703
|
-
this.log =
|
|
3349
|
+
constructor(config) {
|
|
3350
|
+
this.agentId = config.agentId;
|
|
3351
|
+
this.port = config.port;
|
|
3352
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, "");
|
|
3353
|
+
this.getAuthHeader = config.getAuthHeader;
|
|
3354
|
+
this.conversationFilter = config.conversationFilter ?? null;
|
|
3355
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config.retry };
|
|
3356
|
+
this.log = config.log ?? (() => {
|
|
2704
3357
|
});
|
|
2705
|
-
this.fetchImpl =
|
|
2706
|
-
this.sleep =
|
|
2707
|
-
this.pausedPollIntervalMs =
|
|
2708
|
-
this.pausedMaxWaitMs =
|
|
2709
|
-
this.stuckQueuedMs =
|
|
2710
|
-
this.now =
|
|
2711
|
-
this.fileSyncDirectories =
|
|
2712
|
-
this.homeDir =
|
|
3358
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
3359
|
+
this.sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3360
|
+
this.pausedPollIntervalMs = config.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
3361
|
+
this.pausedMaxWaitMs = config.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
3362
|
+
this.stuckQueuedMs = config.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
3363
|
+
this.now = config.now ?? (() => Date.now());
|
|
3364
|
+
this.fileSyncDirectories = config.fileSyncDirectories ?? [];
|
|
3365
|
+
this.homeDir = config.homeDir ?? homedir2();
|
|
2713
3366
|
}
|
|
2714
3367
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
2715
3368
|
get opencodeBase() {
|
|
@@ -3094,7 +3747,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3094
3747
|
const directory = await this.resolveOpenCodeDirectory();
|
|
3095
3748
|
const sessionId = await createOpenCodeSession(this.port, directory);
|
|
3096
3749
|
this.sessions.set(conversationId, sessionId);
|
|
3097
|
-
await this.persistSession(conversationId, sessionId).catch(() => {
|
|
3750
|
+
await this.persistSession(conversationId, sessionId).catch((err) => {
|
|
3751
|
+
this.log({
|
|
3752
|
+
level: "warn",
|
|
3753
|
+
message: `Persisting the OpenCode session binding ${sessionId.slice(0, 8)} for conversation ${conversationId.slice(0, 8)} failed (best-effort, not retried) \u2014 the completion PATCH also carries opencode_session_id, so the binding is repaired when the turn finishes: ${err instanceof Error ? err.message : String(err)}`,
|
|
3754
|
+
conversation_id: conversationId
|
|
3755
|
+
});
|
|
3098
3756
|
});
|
|
3099
3757
|
return sessionId;
|
|
3100
3758
|
}
|
|
@@ -3286,12 +3944,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3286
3944
|
stuckReported: false,
|
|
3287
3945
|
lastAliveAt: 0,
|
|
3288
3946
|
aliveInFlight: false,
|
|
3947
|
+
titleSynced: false,
|
|
3948
|
+
titleSyncInFlight: false,
|
|
3289
3949
|
awaitingHumanLatched: false,
|
|
3290
3950
|
pausedOnQuestion: false,
|
|
3291
3951
|
pausedOnPermission: false,
|
|
3292
3952
|
pausedClearConfirmed: false,
|
|
3293
3953
|
pausedInFlight: false,
|
|
3294
|
-
deliveryDeadlineAnchored: false
|
|
3954
|
+
deliveryDeadlineAnchored: false,
|
|
3955
|
+
b2PinnedSinceMs: 0,
|
|
3956
|
+
b2LastDescendantCheckMs: 0,
|
|
3957
|
+
b2AbandonedSignalled: false
|
|
3295
3958
|
});
|
|
3296
3959
|
}
|
|
3297
3960
|
/**
|
|
@@ -3359,12 +4022,17 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3359
4022
|
// with no extra `re_adopted` signal needed (folds old WI-6).
|
|
3360
4023
|
lastAliveAt: 0,
|
|
3361
4024
|
aliveInFlight: false,
|
|
4025
|
+
titleSynced: false,
|
|
4026
|
+
titleSyncInFlight: false,
|
|
3362
4027
|
awaitingHumanLatched: false,
|
|
3363
4028
|
pausedOnQuestion: false,
|
|
3364
4029
|
pausedOnPermission: false,
|
|
3365
4030
|
pausedClearConfirmed: false,
|
|
3366
4031
|
pausedInFlight: false,
|
|
3367
|
-
deliveryDeadlineAnchored: false
|
|
4032
|
+
deliveryDeadlineAnchored: false,
|
|
4033
|
+
b2PinnedSinceMs: 0,
|
|
4034
|
+
b2LastDescendantCheckMs: 0,
|
|
4035
|
+
b2AbandonedSignalled: false
|
|
3368
4036
|
});
|
|
3369
4037
|
}
|
|
3370
4038
|
/**
|
|
@@ -3526,58 +4194,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3526
4194
|
}
|
|
3527
4195
|
}
|
|
3528
4196
|
if (state === "done") {
|
|
3529
|
-
this.
|
|
3530
|
-
if (!inFlight.done) {
|
|
3531
|
-
this.log({
|
|
3532
|
-
level: "info",
|
|
3533
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
3534
|
-
conversation_id: conv.id,
|
|
3535
|
-
message_id: inFlight.evidentMessageId
|
|
3536
|
-
});
|
|
3537
|
-
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3538
|
-
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
3539
|
-
try {
|
|
3540
|
-
await this.markDone(
|
|
3541
|
-
conv.id,
|
|
3542
|
-
inFlight.evidentMessageId,
|
|
3543
|
-
sessionId,
|
|
3544
|
-
inFlight.opencodeMessageId,
|
|
3545
|
-
title,
|
|
3546
|
-
usage
|
|
3547
|
-
);
|
|
3548
|
-
} catch (err) {
|
|
3549
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
3550
|
-
if (err instanceof ChannelTerminalError) {
|
|
3551
|
-
this.log({
|
|
3552
|
-
level: "warn",
|
|
3553
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
3554
|
-
conversation_id: conv.id,
|
|
3555
|
-
message_id: inFlight.evidentMessageId
|
|
3556
|
-
});
|
|
3557
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3558
|
-
return;
|
|
3559
|
-
}
|
|
3560
|
-
if (this.now() >= inFlight.deadline) {
|
|
3561
|
-
this.log({
|
|
3562
|
-
level: "warn",
|
|
3563
|
-
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)}`,
|
|
3564
|
-
conversation_id: conv.id,
|
|
3565
|
-
message_id: inFlight.evidentMessageId
|
|
3566
|
-
});
|
|
3567
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3568
|
-
return;
|
|
3569
|
-
}
|
|
3570
|
-
this.log({
|
|
3571
|
-
level: "warn",
|
|
3572
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
3573
|
-
conversation_id: conv.id,
|
|
3574
|
-
message_id: inFlight.evidentMessageId
|
|
3575
|
-
});
|
|
3576
|
-
return;
|
|
3577
|
-
}
|
|
3578
|
-
inFlight.done = true;
|
|
3579
|
-
}
|
|
3580
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4197
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
3581
4198
|
return;
|
|
3582
4199
|
}
|
|
3583
4200
|
if (state === "failed") {
|
|
@@ -3591,8 +4208,16 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3591
4208
|
message_id: inFlight.evidentMessageId
|
|
3592
4209
|
});
|
|
3593
4210
|
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
4211
|
+
const failure = await this.classifyModelAuthFailure(messages, inFlight.opencodeMessageId);
|
|
3594
4212
|
try {
|
|
3595
|
-
await this.markFailed(
|
|
4213
|
+
await this.markFailed(
|
|
4214
|
+
conv.id,
|
|
4215
|
+
inFlight.evidentMessageId,
|
|
4216
|
+
sessionId,
|
|
4217
|
+
error2,
|
|
4218
|
+
usage,
|
|
4219
|
+
failure
|
|
4220
|
+
);
|
|
3596
4221
|
} catch (err) {
|
|
3597
4222
|
if (err instanceof ChannelAuthError) throw err;
|
|
3598
4223
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -3637,6 +4262,44 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3637
4262
|
});
|
|
3638
4263
|
}
|
|
3639
4264
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
4265
|
+
const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
|
|
4266
|
+
const snapshotReadable = messages != null && messages.length > 0;
|
|
4267
|
+
if (!pinnedNow) {
|
|
4268
|
+
if (snapshotReadable) {
|
|
4269
|
+
inFlight.b2PinnedSinceMs = 0;
|
|
4270
|
+
inFlight.b2LastDescendantCheckMs = 0;
|
|
4271
|
+
inFlight.b2AbandonedSignalled = false;
|
|
4272
|
+
}
|
|
4273
|
+
} else {
|
|
4274
|
+
if (inFlight.b2AbandonedSignalled) {
|
|
4275
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
|
|
4279
|
+
const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
|
|
4280
|
+
if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
|
|
4281
|
+
inFlight.b2LastDescendantCheckMs = this.now();
|
|
4282
|
+
const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
|
|
4283
|
+
if (isB2AbandonmentConfirmed({
|
|
4284
|
+
pinnedForMs,
|
|
4285
|
+
minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
|
|
4286
|
+
descendantOngoing
|
|
4287
|
+
})) {
|
|
4288
|
+
inFlight.b2AbandonedSignalled = true;
|
|
4289
|
+
this.log({
|
|
4290
|
+
level: "warn",
|
|
4291
|
+
message: `Message ${id.slice(0, 8)} b2-pinned for ${Math.round(pinnedForMs / 1e3)}s with no ongoing descendant sub-agent session (status-map confirmed) \u2014 treating the delegated/tool turn as abandoned, resolving done`,
|
|
4292
|
+
conversation_id: conv.id,
|
|
4293
|
+
message_id: id
|
|
4294
|
+
});
|
|
4295
|
+
void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
|
|
4296
|
+
watched_for_ms: pinnedForMs
|
|
4297
|
+
});
|
|
4298
|
+
await this.settleMessageDone(sessionId, watcher, inFlight, messages);
|
|
4299
|
+
return;
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
3640
4303
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
3641
4304
|
this.log({
|
|
3642
4305
|
level: "warn",
|
|
@@ -3656,6 +4319,18 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3656
4319
|
inFlight.aliveInFlight = false;
|
|
3657
4320
|
if (ok) inFlight.lastAliveAt = this.now();
|
|
3658
4321
|
});
|
|
4322
|
+
if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
|
|
4323
|
+
inFlight.titleSyncInFlight = true;
|
|
4324
|
+
void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
|
|
4325
|
+
if (!title) {
|
|
4326
|
+
inFlight.titleSyncInFlight = false;
|
|
4327
|
+
return;
|
|
4328
|
+
}
|
|
4329
|
+
const ok = await this.patchConversationTitle(conv.id, title);
|
|
4330
|
+
inFlight.titleSyncInFlight = false;
|
|
4331
|
+
if (ok) inFlight.titleSynced = true;
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
3659
4334
|
}
|
|
3660
4335
|
if (awaitingHuman) {
|
|
3661
4336
|
if (!inFlight.awaitingHumanLatched) {
|
|
@@ -3693,6 +4368,70 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3693
4368
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
3694
4369
|
}
|
|
3695
4370
|
}
|
|
4371
|
+
/**
|
|
4372
|
+
* Settle a message whose run-state has resolved `'done'` — extracted verbatim
|
|
4373
|
+
* (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
|
|
4374
|
+
* inline `state === 'done'` branch body, so a SECOND caller (the #721
|
|
4375
|
+
* b2-abandonment resolution) can reach the exact same completion behavior
|
|
4376
|
+
* (delivery-deadline anchoring, title resolution, usage extraction, and
|
|
4377
|
+
* `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
|
|
4378
|
+
* and risking the two copies silently drifting apart.
|
|
4379
|
+
*/
|
|
4380
|
+
async settleMessageDone(sessionId, watcher, inFlight, messages) {
|
|
4381
|
+
const conv = watcher.conv;
|
|
4382
|
+
this.anchorDeliveryDeadline(inFlight);
|
|
4383
|
+
if (!inFlight.done) {
|
|
4384
|
+
this.log({
|
|
4385
|
+
level: "info",
|
|
4386
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
4387
|
+
conversation_id: conv.id,
|
|
4388
|
+
message_id: inFlight.evidentMessageId
|
|
4389
|
+
});
|
|
4390
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
4391
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
4392
|
+
try {
|
|
4393
|
+
await this.markDone(
|
|
4394
|
+
conv.id,
|
|
4395
|
+
inFlight.evidentMessageId,
|
|
4396
|
+
sessionId,
|
|
4397
|
+
inFlight.opencodeMessageId,
|
|
4398
|
+
title,
|
|
4399
|
+
usage
|
|
4400
|
+
);
|
|
4401
|
+
} catch (err) {
|
|
4402
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4403
|
+
if (err instanceof ChannelTerminalError) {
|
|
4404
|
+
this.log({
|
|
4405
|
+
level: "warn",
|
|
4406
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
4407
|
+
conversation_id: conv.id,
|
|
4408
|
+
message_id: inFlight.evidentMessageId
|
|
4409
|
+
});
|
|
4410
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4411
|
+
return;
|
|
4412
|
+
}
|
|
4413
|
+
if (this.now() >= inFlight.deadline) {
|
|
4414
|
+
this.log({
|
|
4415
|
+
level: "warn",
|
|
4416
|
+
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)}`,
|
|
4417
|
+
conversation_id: conv.id,
|
|
4418
|
+
message_id: inFlight.evidentMessageId
|
|
4419
|
+
});
|
|
4420
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
this.log({
|
|
4424
|
+
level: "warn",
|
|
4425
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
4426
|
+
conversation_id: conv.id,
|
|
4427
|
+
message_id: inFlight.evidentMessageId
|
|
4428
|
+
});
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
inFlight.done = true;
|
|
4432
|
+
}
|
|
4433
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
4434
|
+
}
|
|
3696
4435
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
3697
4436
|
/**
|
|
3698
4437
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
@@ -3859,6 +4598,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3859
4598
|
if (state === "failed") {
|
|
3860
4599
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3861
4600
|
const usage = messageUsage(messages, ocId ?? "");
|
|
4601
|
+
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
3862
4602
|
this.log({
|
|
3863
4603
|
level: "error",
|
|
3864
4604
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -3866,7 +4606,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3866
4606
|
message_id: row.id
|
|
3867
4607
|
});
|
|
3868
4608
|
try {
|
|
3869
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
4609
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage, failure);
|
|
3870
4610
|
} catch (err) {
|
|
3871
4611
|
if (err instanceof ChannelAuthError) throw err;
|
|
3872
4612
|
if (err instanceof ChannelTerminalError) {
|
|
@@ -4272,6 +5012,47 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4272
5012
|
}
|
|
4273
5013
|
return false;
|
|
4274
5014
|
}
|
|
5015
|
+
/**
|
|
5016
|
+
* Tri-state variant of the upward parentID membership walk (#721), used ONLY
|
|
5017
|
+
* by `isAnyDescendantSessionOngoing`. Walks the SAME cached
|
|
5018
|
+
* `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
|
|
5019
|
+
* `sessionBelongsTo`, which deliberately collapses "confirmed not a
|
|
5020
|
+
* descendant" and "the walk's fetch failed" into the same `false` (safe for
|
|
5021
|
+
* its OTHER callers: interaction attribution and the recovery-path
|
|
5022
|
+
* `isAnyDescendantSessionAlive`, both of which just retry next tick with no
|
|
5023
|
+
* safety consequence either way) — this variant keeps those two outcomes
|
|
5024
|
+
* SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
|
|
5025
|
+
* (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
|
|
5026
|
+
* not ongoing".
|
|
5027
|
+
*
|
|
5028
|
+
* Return contract:
|
|
5029
|
+
* - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
|
|
5030
|
+
* - `false` → the walk reached a definitive, parent-less root session
|
|
5031
|
+
* WITHOUT ever matching `rootSessionId` — `sessionId` is
|
|
5032
|
+
* CONFIRMED NOT a descendant of it.
|
|
5033
|
+
* - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
|
|
5034
|
+
* through the walk (`resolveSessionParent` returned `undefined`),
|
|
5035
|
+
* or the depth cap (32) was hit without a definitive answer (a
|
|
5036
|
+
* pathological/cyclic chain proves nothing either way). NEVER
|
|
5037
|
+
* treat this the same as `false` — see `sessionBelongsTo`'s own
|
|
5038
|
+
* doc comment above for why that collapse is safe THERE but not
|
|
5039
|
+
* here.
|
|
5040
|
+
*
|
|
5041
|
+
* `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
|
|
5042
|
+
* to the live-path descendant check, not a modification of shared code used
|
|
5043
|
+
* by interaction attribution or the recovery path.
|
|
5044
|
+
*/
|
|
5045
|
+
async resolveSessionMembership(sessionId, rootSessionId) {
|
|
5046
|
+
let current = sessionId;
|
|
5047
|
+
for (let depth = 0; current && depth < 32; depth++) {
|
|
5048
|
+
if (current === rootSessionId) return true;
|
|
5049
|
+
const parent = await this.resolveSessionParent(current);
|
|
5050
|
+
if (parent === void 0) return null;
|
|
5051
|
+
if (parent === null) return false;
|
|
5052
|
+
current = parent;
|
|
5053
|
+
}
|
|
5054
|
+
return null;
|
|
5055
|
+
}
|
|
4275
5056
|
/**
|
|
4276
5057
|
* Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
|
|
4277
5058
|
* `null` for a root session (no parent) and `undefined` when opencode is
|
|
@@ -4350,11 +5131,59 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4350
5131
|
} catch (err) {
|
|
4351
5132
|
this.log({
|
|
4352
5133
|
level: "debug",
|
|
4353
|
-
message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
|
|
5134
|
+
message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
|
|
5135
|
+
conversation_id: conversationId
|
|
5136
|
+
});
|
|
5137
|
+
}
|
|
5138
|
+
return null;
|
|
5139
|
+
}
|
|
5140
|
+
/**
|
|
5141
|
+
* Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
|
|
5142
|
+
* session title onto the conversation via the PLAIN conversation-update
|
|
5143
|
+
* endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
|
|
5144
|
+
* message-status endpoint `markProcessing`/`markDone` use. Deliberately a
|
|
5145
|
+
* separate, lighter call: it carries no `status`, so it cannot re-trigger the
|
|
5146
|
+
* `processing`/`done` transition side effects (Slack notices, activity-log
|
|
5147
|
+
* rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
|
|
5148
|
+
* ever touches `conversations.title`. That route (`routes/conversations.ts`)
|
|
5149
|
+
* skips a title write matching the stored value, so a redundant call with the
|
|
5150
|
+
* same title is a real no-op — it does not bump `updated_at`, which the
|
|
5151
|
+
* conversation list sorts and paginates on. (Note this is a DIFFERENT guard
|
|
5152
|
+
* from `threads.ts`'s "non-empty AND changed" one, which only covers the
|
|
5153
|
+
* message-status PATCH; the non-empty half is enforced here instead, by
|
|
5154
|
+
* `resolveSessionTitle` never returning an empty/placeholder title.)
|
|
5155
|
+
*
|
|
5156
|
+
* Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
|
|
5157
|
+
* is logged and the title is simply retried on the next heartbeat tick (the
|
|
5158
|
+
* caller only latches `titleSynced` on `true`).
|
|
5159
|
+
*/
|
|
5160
|
+
async patchConversationTitle(conversationId, title) {
|
|
5161
|
+
try {
|
|
5162
|
+
const res = await this.fetchImpl(
|
|
5163
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
|
|
5164
|
+
{
|
|
5165
|
+
method: "PATCH",
|
|
5166
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
5167
|
+
body: JSON.stringify({ title })
|
|
5168
|
+
}
|
|
5169
|
+
);
|
|
5170
|
+
if (!res.ok) {
|
|
5171
|
+
this.log({
|
|
5172
|
+
level: "debug",
|
|
5173
|
+
message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
|
|
5174
|
+
conversation_id: conversationId
|
|
5175
|
+
});
|
|
5176
|
+
return false;
|
|
5177
|
+
}
|
|
5178
|
+
return true;
|
|
5179
|
+
} catch (err) {
|
|
5180
|
+
this.log({
|
|
5181
|
+
level: "debug",
|
|
5182
|
+
message: `Best-effort mid-turn title sync PATCH failed for conversation ${conversationId.slice(0, 8)} (will retry next heartbeat): ${err instanceof Error ? err.message : String(err)}`,
|
|
4354
5183
|
conversation_id: conversationId
|
|
4355
5184
|
});
|
|
5185
|
+
return false;
|
|
4356
5186
|
}
|
|
4357
|
-
return null;
|
|
4358
5187
|
}
|
|
4359
5188
|
/**
|
|
4360
5189
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
@@ -4414,6 +5243,84 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4414
5243
|
}
|
|
4415
5244
|
return false;
|
|
4416
5245
|
}
|
|
5246
|
+
/**
|
|
5247
|
+
* LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
|
|
5248
|
+
* sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
|
|
5249
|
+
* in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
|
|
5250
|
+
*
|
|
5251
|
+
* Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
|
|
5252
|
+
* cross-check above): that method judges liveness from the child's OWN
|
|
5253
|
+
* TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
|
|
5254
|
+
* on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
|
|
5255
|
+
* path the local opencode server IS running, so its in-memory status map is
|
|
5256
|
+
* live and authoritative — and per ADR-0047 §4a ("the child has its own entry
|
|
5257
|
+
* [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
|
|
5258
|
+
* ENTIRE turn (including any tool call it is itself executing), not a
|
|
5259
|
+
* per-message transcript snapshot. This sidesteps the "child's own tool is
|
|
5260
|
+
* executing, between its step's completion and the next generation step"
|
|
5261
|
+
* transcript gap that a transcript-based check would need a second,
|
|
5262
|
+
* sustained-window bound to guard against — it is simply not derived from
|
|
5263
|
+
* message timestamps at all.
|
|
5264
|
+
*
|
|
5265
|
+
* Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
|
|
5266
|
+
* status, as the recovery path does per §4a)? Because on the LIVE path the
|
|
5267
|
+
* root session can be shared: a SECOND, unrelated user message can land on the
|
|
5268
|
+
* SAME session (issue #721's own root cause) and keep the root `busy` for a
|
|
5269
|
+
* reason that has nothing to do with THIS message's delegation. A `task`
|
|
5270
|
+
* descendant session is spawned for exactly one delegated turn and never
|
|
5271
|
+
* reused, so its OWN status-map entry is unambiguous evidence about that one
|
|
5272
|
+
* delegation — which the root's status is not.
|
|
5273
|
+
*
|
|
5274
|
+
* Why membership is checked via `resolveSessionMembership`, NOT
|
|
5275
|
+
* `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
|
|
5276
|
+
* `GET /session/:id` fetch failure into "not a descendant", which would
|
|
5277
|
+
* silently drop a genuinely-live candidate from consideration on the one
|
|
5278
|
+
* unlucky tick its membership-walk fetch hiccups (#721).
|
|
5279
|
+
* `resolveSessionMembership` keeps that failure mode as a distinct `null`
|
|
5280
|
+
* (indeterminate) so it is folded into THIS method's own `indeterminate` flag
|
|
5281
|
+
* instead.
|
|
5282
|
+
*
|
|
5283
|
+
* Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
|
|
5284
|
+
* - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
|
|
5285
|
+
* - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
|
|
5286
|
+
* confirmed either way (`resolveSessionMembership` never
|
|
5287
|
+
* returned `null`), and every CONFIRMED descendant's status read
|
|
5288
|
+
* succeeded and is not ongoing (includes "no descendant session
|
|
5289
|
+
* exists at all" — e.g. a plain, non-`task` tool call).
|
|
5290
|
+
* - `null` → INDETERMINATE: `listSessions` failed, OR at least one
|
|
5291
|
+
* candidate's MEMBERSHIP could not be confirmed
|
|
5292
|
+
* (`resolveSessionMembership` returned `null` — a fetch failure
|
|
5293
|
+
* or pathological chain partway through the parent walk), OR at
|
|
5294
|
+
* least one CONFIRMED descendant's `isSessionOngoing` read
|
|
5295
|
+
* failed — and no OTHER candidate was already confirmed `true`.
|
|
5296
|
+
* The caller MUST NOT treat `null` the same as `false` here
|
|
5297
|
+
* (unlike the recovery cross-check's contract) — see
|
|
5298
|
+
* `isB2AbandonmentConfirmed`.
|
|
5299
|
+
*/
|
|
5300
|
+
async isAnyDescendantSessionOngoing(rootSessionId) {
|
|
5301
|
+
const sessions = await listSessions(this.port);
|
|
5302
|
+
if (!sessions) {
|
|
5303
|
+
this.log({
|
|
5304
|
+
level: "warn",
|
|
5305
|
+
message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
|
|
5306
|
+
});
|
|
5307
|
+
return null;
|
|
5308
|
+
}
|
|
5309
|
+
let indeterminate = false;
|
|
5310
|
+
for (const candidate of sessions) {
|
|
5311
|
+
if (!candidate?.id || candidate.id === rootSessionId) continue;
|
|
5312
|
+
const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
|
|
5313
|
+
if (membership === null) {
|
|
5314
|
+
indeterminate = true;
|
|
5315
|
+
continue;
|
|
5316
|
+
}
|
|
5317
|
+
if (membership === false) continue;
|
|
5318
|
+
const ongoing = await isSessionOngoing(this.port, candidate.id);
|
|
5319
|
+
if (ongoing === true) return true;
|
|
5320
|
+
if (ongoing === null) indeterminate = true;
|
|
5321
|
+
}
|
|
5322
|
+
return indeterminate ? null : false;
|
|
5323
|
+
}
|
|
4417
5324
|
/**
|
|
4418
5325
|
* Cheap decision-telemetry label for a running row's LAST correlated reply
|
|
4419
5326
|
* (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
|
|
@@ -4558,11 +5465,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4558
5465
|
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
4559
5466
|
* stick.
|
|
4560
5467
|
*/
|
|
4561
|
-
sessionIdBody(sessionId, conversationId, messageId,
|
|
5468
|
+
sessionIdBody(sessionId, conversationId, messageId, status2) {
|
|
4562
5469
|
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
4563
5470
|
this.log({
|
|
4564
5471
|
level: "debug",
|
|
4565
|
-
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${
|
|
5472
|
+
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status2}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
|
|
4566
5473
|
conversation_id: conversationId,
|
|
4567
5474
|
message_id: messageId
|
|
4568
5475
|
});
|
|
@@ -4677,7 +5584,7 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4677
5584
|
* exists but is wedged, so the next attempt must get a fresh one
|
|
4678
5585
|
* instead of reusing it — see WI-1's server-side null-clearing PATCH).
|
|
4679
5586
|
*/
|
|
4680
|
-
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
5587
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage, failure) {
|
|
4681
5588
|
const body = { status: "failed" };
|
|
4682
5589
|
if (sessionId === null) {
|
|
4683
5590
|
body.opencode_session_id = null;
|
|
@@ -4686,6 +5593,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4686
5593
|
}
|
|
4687
5594
|
if (error2 !== void 0) body.error = error2;
|
|
4688
5595
|
if (usage) Object.assign(body, usage);
|
|
5596
|
+
if (failure) {
|
|
5597
|
+
body.failure_kind = failure.kind;
|
|
5598
|
+
body.failure_provider_id = failure.providerId;
|
|
5599
|
+
body.failure_model_id = failure.modelId;
|
|
5600
|
+
body.failure_reason = failure.reason;
|
|
5601
|
+
}
|
|
4689
5602
|
await this.callWithRetry(
|
|
4690
5603
|
"marking message as failed",
|
|
4691
5604
|
() => this.fetchImpl(
|
|
@@ -4698,6 +5611,29 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4698
5611
|
)
|
|
4699
5612
|
);
|
|
4700
5613
|
}
|
|
5614
|
+
/**
|
|
5615
|
+
* Classify an errored turn as a model-auth failure (#736 P1-4), for `markFailed`.
|
|
5616
|
+
*
|
|
5617
|
+
* `messageFailure` alone (structured OpenCode error → `model_auth`) covers
|
|
5618
|
+
* most cases; when it returns `null` on this ALREADY-FAILED turn, fall back
|
|
5619
|
+
* to the P1-2b zero-provider check — one extra loopback call to
|
|
5620
|
+
* `hasAnyConfiguredProvider`, only reached when the structured classifier
|
|
5621
|
+
* couldn't place it. Fails open (never throws): a fallback probe failure
|
|
5622
|
+
* (`null`/indeterminate) leaves the classification `null`, which produces
|
|
5623
|
+
* today's byte-identical PATCH body via `markFailed`'s `if (failure)` guard.
|
|
5624
|
+
*/
|
|
5625
|
+
async classifyModelAuthFailure(messages, userMessageId) {
|
|
5626
|
+
const classified = messageFailure(messages, userMessageId);
|
|
5627
|
+
if (classified != null) return classified;
|
|
5628
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
5629
|
+
const hasProvider = await hasAnyConfiguredProvider(this.port);
|
|
5630
|
+
return applyZeroProviderFallback(
|
|
5631
|
+
classified,
|
|
5632
|
+
hasProvider,
|
|
5633
|
+
reply?.info?.providerID ?? null,
|
|
5634
|
+
reply?.info?.modelID ?? null
|
|
5635
|
+
);
|
|
5636
|
+
}
|
|
4701
5637
|
/**
|
|
4702
5638
|
* Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
|
|
4703
5639
|
* (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
|
|
@@ -4850,10 +5786,16 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4850
5786
|
import chalk5 from "chalk";
|
|
4851
5787
|
import ora2 from "ora";
|
|
4852
5788
|
import { select as select2 } from "@inquirer/prompts";
|
|
5789
|
+
var INTERACTIVE_START_TIMEOUT_MS = 3e4;
|
|
4853
5790
|
async function ensureOpenCodeRunning(ctx) {
|
|
4854
5791
|
const healthCheck = await checkOpenCodeHealth(ctx.port);
|
|
4855
5792
|
if (healthCheck.healthy) {
|
|
4856
|
-
return {
|
|
5793
|
+
return {
|
|
5794
|
+
port: ctx.port,
|
|
5795
|
+
process: null,
|
|
5796
|
+
version: healthCheck.version ?? null,
|
|
5797
|
+
notReadyReason: null
|
|
5798
|
+
};
|
|
4857
5799
|
}
|
|
4858
5800
|
const runningInstances = await findHealthyOpenCodeInstances();
|
|
4859
5801
|
if (runningInstances.length > 0) {
|
|
@@ -4874,7 +5816,7 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
4874
5816
|
console.log(chalk5.yellow("Tip: Run with the correct port:"));
|
|
4875
5817
|
console.log(
|
|
4876
5818
|
chalk5.dim(
|
|
4877
|
-
` ${getCliName()} run --
|
|
5819
|
+
` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
|
|
4878
5820
|
)
|
|
4879
5821
|
);
|
|
4880
5822
|
}
|
|
@@ -4894,14 +5836,22 @@ async function ensureOpenCodeRunning(ctx) {
|
|
|
4894
5836
|
if (!ctx.interactive) {
|
|
4895
5837
|
ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
|
|
4896
5838
|
const proc = await startOpenCode(ctx.port);
|
|
4897
|
-
const health = await waitForOpenCodeHealth(ctx.port,
|
|
5839
|
+
const health = await waitForOpenCodeHealth(ctx.port, ctx.startTimeoutMs);
|
|
4898
5840
|
if (!health.healthy) {
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
5841
|
+
return {
|
|
5842
|
+
port: ctx.port,
|
|
5843
|
+
process: proc,
|
|
5844
|
+
version: null,
|
|
5845
|
+
notReadyReason: `it did not become healthy within ${Math.round(ctx.startTimeoutMs / 1e3)}s`
|
|
5846
|
+
};
|
|
4902
5847
|
}
|
|
4903
5848
|
ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
|
|
4904
|
-
return {
|
|
5849
|
+
return {
|
|
5850
|
+
port: ctx.port,
|
|
5851
|
+
process: proc,
|
|
5852
|
+
version: health.version ?? null,
|
|
5853
|
+
notReadyReason: null
|
|
5854
|
+
};
|
|
4905
5855
|
}
|
|
4906
5856
|
let port = ctx.port;
|
|
4907
5857
|
if (isPortInUse(port)) {
|
|
@@ -4954,122 +5904,15 @@ Port ${port} is already in use.`));
|
|
|
4954
5904
|
if (action === "start") {
|
|
4955
5905
|
const spinner = ora2("Starting OpenCode...").start();
|
|
4956
5906
|
const proc = await startOpenCode(port);
|
|
4957
|
-
const health = await waitForOpenCodeHealth(port,
|
|
5907
|
+
const health = await waitForOpenCodeHealth(port, INTERACTIVE_START_TIMEOUT_MS);
|
|
4958
5908
|
if (!health.healthy) {
|
|
4959
5909
|
spinner.fail("Failed to start OpenCode");
|
|
4960
5910
|
throw new Error("OpenCode failed to start");
|
|
4961
5911
|
}
|
|
4962
5912
|
spinner.stop();
|
|
4963
|
-
return { port, process: proc, version: health.version ?? null };
|
|
4964
|
-
}
|
|
4965
|
-
return { port, process: null, version: null };
|
|
4966
|
-
}
|
|
4967
|
-
|
|
4968
|
-
// src/commands/agent-lookup.ts
|
|
4969
|
-
async function readErrorMessage(response) {
|
|
4970
|
-
const text = await response.text().catch(() => "");
|
|
4971
|
-
if (!text) return response.statusText || void 0;
|
|
4972
|
-
try {
|
|
4973
|
-
const data = JSON.parse(text);
|
|
4974
|
-
const message = data.message ?? data.error;
|
|
4975
|
-
if (typeof message === "string" && message.trim()) {
|
|
4976
|
-
return message;
|
|
4977
|
-
}
|
|
4978
|
-
} catch {
|
|
4979
|
-
}
|
|
4980
|
-
return text.trim() || response.statusText || void 0;
|
|
4981
|
-
}
|
|
4982
|
-
function authFailureHint(apiUrl, serverMessage) {
|
|
4983
|
-
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
4984
|
-
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.`;
|
|
4985
|
-
}
|
|
4986
|
-
async function resolveAgentIdFromKey(authHeader) {
|
|
4987
|
-
const apiUrl = getApiUrlConfig();
|
|
4988
|
-
try {
|
|
4989
|
-
const response = await fetch(`${apiUrl}/me`, {
|
|
4990
|
-
headers: { Authorization: authHeader }
|
|
4991
|
-
});
|
|
4992
|
-
if (response.status === 401) {
|
|
4993
|
-
const serverMessage = await readErrorMessage(response);
|
|
4994
|
-
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
4995
|
-
}
|
|
4996
|
-
if (!response.ok) {
|
|
4997
|
-
const serverMessage = await readErrorMessage(response);
|
|
4998
|
-
return {
|
|
4999
|
-
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5000
|
-
};
|
|
5001
|
-
}
|
|
5002
|
-
const data = await response.json();
|
|
5003
|
-
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
5004
|
-
return { agent_id: data.agent_id };
|
|
5005
|
-
}
|
|
5006
|
-
return {
|
|
5007
|
-
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
5008
|
-
};
|
|
5009
|
-
} catch (error2) {
|
|
5010
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5011
|
-
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5012
|
-
}
|
|
5013
|
-
}
|
|
5014
|
-
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5015
|
-
const apiUrl = getApiUrlConfig();
|
|
5016
|
-
try {
|
|
5017
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5018
|
-
method: "POST",
|
|
5019
|
-
headers: { Authorization: authHeader }
|
|
5020
|
-
});
|
|
5021
|
-
if (!response.ok) {
|
|
5022
|
-
const serverMessage = await readErrorMessage(response);
|
|
5023
|
-
return {
|
|
5024
|
-
ok: false,
|
|
5025
|
-
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5026
|
-
};
|
|
5027
|
-
}
|
|
5028
|
-
return { ok: true };
|
|
5029
|
-
} catch (error2) {
|
|
5030
|
-
return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
|
|
5031
|
-
}
|
|
5032
|
-
}
|
|
5033
|
-
async function getAgentInfo(agentId, authHeader) {
|
|
5034
|
-
const apiUrl = getApiUrlConfig();
|
|
5035
|
-
try {
|
|
5036
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
5037
|
-
headers: { Authorization: authHeader }
|
|
5038
|
-
});
|
|
5039
|
-
if (response.status === 401) {
|
|
5040
|
-
const serverMessage = await readErrorMessage(response);
|
|
5041
|
-
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
5042
|
-
}
|
|
5043
|
-
if (response.status === 403) {
|
|
5044
|
-
const serverMessage = await readErrorMessage(response);
|
|
5045
|
-
return {
|
|
5046
|
-
valid: false,
|
|
5047
|
-
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
5048
|
-
};
|
|
5049
|
-
}
|
|
5050
|
-
if (response.status === 404) {
|
|
5051
|
-
const serverMessage = await readErrorMessage(response);
|
|
5052
|
-
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
5053
|
-
}
|
|
5054
|
-
if (!response.ok) {
|
|
5055
|
-
const serverMessage = await readErrorMessage(response);
|
|
5056
|
-
return {
|
|
5057
|
-
valid: false,
|
|
5058
|
-
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5059
|
-
};
|
|
5060
|
-
}
|
|
5061
|
-
const agent = await response.json();
|
|
5062
|
-
if (agent.agent_type !== "local") {
|
|
5063
|
-
return {
|
|
5064
|
-
valid: false,
|
|
5065
|
-
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
5066
|
-
};
|
|
5067
|
-
}
|
|
5068
|
-
return { valid: true, agent };
|
|
5069
|
-
} catch (error2) {
|
|
5070
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5071
|
-
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
5913
|
+
return { port, process: proc, version: health.version ?? null, notReadyReason: null };
|
|
5072
5914
|
}
|
|
5915
|
+
return { port, process: null, version: null, notReadyReason: "you chose to continue without it" };
|
|
5073
5916
|
}
|
|
5074
5917
|
|
|
5075
5918
|
// src/commands/run.ts
|
|
@@ -5077,6 +5920,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
5077
5920
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
5078
5921
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
5079
5922
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
5923
|
+
var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
5080
5924
|
function resolveLogLevel(options) {
|
|
5081
5925
|
const accepted = Object.keys(LOG_LEVELS);
|
|
5082
5926
|
const validate = (value, source) => {
|
|
@@ -5128,6 +5972,35 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
5128
5972
|
}
|
|
5129
5973
|
return directories;
|
|
5130
5974
|
}
|
|
5975
|
+
var DEFAULT_OPENCODE_START_TIMEOUT_SECONDS = 180;
|
|
5976
|
+
var MAX_OPENCODE_START_TIMEOUT_SECONDS = 3600;
|
|
5977
|
+
var OPENCODE_START_TIMEOUT_ENV = "EVIDENT_OPENCODE_START_TIMEOUT";
|
|
5978
|
+
function resolveOpenCodeStartTimeoutMs(options, env = process.env) {
|
|
5979
|
+
const defaultMs = DEFAULT_OPENCODE_START_TIMEOUT_SECONDS * 1e3;
|
|
5980
|
+
let raw;
|
|
5981
|
+
let source;
|
|
5982
|
+
if (options.opencodeStartTimeout !== void 0) {
|
|
5983
|
+
raw = options.opencodeStartTimeout;
|
|
5984
|
+
source = "--opencode-start-timeout";
|
|
5985
|
+
} else if (env[OPENCODE_START_TIMEOUT_ENV] !== void 0 && env[OPENCODE_START_TIMEOUT_ENV] !== "") {
|
|
5986
|
+
raw = env[OPENCODE_START_TIMEOUT_ENV];
|
|
5987
|
+
source = OPENCODE_START_TIMEOUT_ENV;
|
|
5988
|
+
} else {
|
|
5989
|
+
return { timeoutMs: defaultMs, warnings: [] };
|
|
5990
|
+
}
|
|
5991
|
+
const trimmed = raw.trim();
|
|
5992
|
+
const seconds = Number(trimmed);
|
|
5993
|
+
const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(seconds) && seconds > 0;
|
|
5994
|
+
if (!isPositiveInteger || seconds > MAX_OPENCODE_START_TIMEOUT_SECONDS) {
|
|
5995
|
+
return {
|
|
5996
|
+
timeoutMs: defaultMs,
|
|
5997
|
+
warnings: [
|
|
5998
|
+
`Ignoring invalid ${source} "${raw}": expected a positive integer number of seconds (at most ${MAX_OPENCODE_START_TIMEOUT_SECONDS}); using the default ${DEFAULT_OPENCODE_START_TIMEOUT_SECONDS}s`
|
|
5999
|
+
]
|
|
6000
|
+
};
|
|
6001
|
+
}
|
|
6002
|
+
return { timeoutMs: seconds * 1e3, warnings: [] };
|
|
6003
|
+
}
|
|
5131
6004
|
function meetsThreshold(state, level) {
|
|
5132
6005
|
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
5133
6006
|
}
|
|
@@ -5149,6 +6022,10 @@ function log2(state, message, level = "info") {
|
|
|
5149
6022
|
function logActivity(state, entry) {
|
|
5150
6023
|
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
5151
6024
|
if (!meetsThreshold(state, level)) return;
|
|
6025
|
+
forwardRunnerActivity(
|
|
6026
|
+
{ level, message: entry.message, error: entry.error },
|
|
6027
|
+
{ agentId: state.agentId, authHeader: state.authHeader }
|
|
6028
|
+
);
|
|
5152
6029
|
const fullEntry = {
|
|
5153
6030
|
...entry,
|
|
5154
6031
|
level,
|
|
@@ -5248,6 +6125,7 @@ async function handleAuthError(state, error2) {
|
|
|
5248
6125
|
}
|
|
5249
6126
|
async function driveChannels(state, driver) {
|
|
5250
6127
|
let idlePolls = 0;
|
|
6128
|
+
let consecutiveDrainFailures = 0;
|
|
5251
6129
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5252
6130
|
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5253
6131
|
while (state.running) {
|
|
@@ -5265,6 +6143,7 @@ async function driveChannels(state, driver) {
|
|
|
5265
6143
|
);
|
|
5266
6144
|
try {
|
|
5267
6145
|
const processed = await driver.drainPending();
|
|
6146
|
+
consecutiveDrainFailures = 0;
|
|
5268
6147
|
state.messageCount += processed;
|
|
5269
6148
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
5270
6149
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
@@ -5299,8 +6178,32 @@ async function driveChannels(state, driver) {
|
|
|
5299
6178
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
5300
6179
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
5301
6180
|
if (state.interactive) displayStatus(state);
|
|
6181
|
+
if (driver.hasInFlightWatchers()) {
|
|
6182
|
+
consecutiveDrainFailures = 0;
|
|
6183
|
+
} else if (state.idleTimeout !== null) {
|
|
6184
|
+
consecutiveDrainFailures++;
|
|
6185
|
+
if (consecutiveDrainFailures === 1) {
|
|
6186
|
+
logActivity(state, {
|
|
6187
|
+
type: "info",
|
|
6188
|
+
message: `Cannot reach Evident, will exit if this persists past the idle timeout (timeout: ${state.idleTimeout}s)...`
|
|
6189
|
+
});
|
|
6190
|
+
if (state.interactive) displayStatus(state);
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
5302
6193
|
}
|
|
5303
6194
|
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
6195
|
+
if (state.idleTimeout !== null && consecutiveDrainFailures >= 2) {
|
|
6196
|
+
const unreachableMs = consecutiveDrainFailures * CHANNEL_POLL_INTERVAL_MS;
|
|
6197
|
+
if (unreachableMs > state.idleTimeout * 1e3) {
|
|
6198
|
+
logActivity(state, {
|
|
6199
|
+
type: "info",
|
|
6200
|
+
level: "warn",
|
|
6201
|
+
message: `Exiting: could not reach Evident for ${consecutiveDrainFailures} consecutive polls (${Math.round(unreachableMs / 1e3)}s)`
|
|
6202
|
+
});
|
|
6203
|
+
if (state.interactive) displayStatus(state);
|
|
6204
|
+
break;
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
5304
6207
|
if (state.idleTimeout !== null && idlePolls >= 2) {
|
|
5305
6208
|
const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
|
|
5306
6209
|
if (idleMs > state.idleTimeout * 1e3) {
|
|
@@ -5312,8 +6215,8 @@ async function driveChannels(state, driver) {
|
|
|
5312
6215
|
}
|
|
5313
6216
|
}
|
|
5314
6217
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
5315
|
-
async function runSweep(state, driver,
|
|
5316
|
-
const mode = `age=${
|
|
6218
|
+
async function runSweep(state, driver, config) {
|
|
6219
|
+
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
5317
6220
|
try {
|
|
5318
6221
|
const sessions = await listSessions(state.port);
|
|
5319
6222
|
if (sessions === null) {
|
|
@@ -5326,8 +6229,8 @@ async function runSweep(state, driver, config2) {
|
|
|
5326
6229
|
const toDelete = selectSessionsToDelete(
|
|
5327
6230
|
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
5328
6231
|
{
|
|
5329
|
-
maxAgeMs:
|
|
5330
|
-
maxCount:
|
|
6232
|
+
maxAgeMs: config.maxAgeMs,
|
|
6233
|
+
maxCount: config.maxCount,
|
|
5331
6234
|
nowMs: Date.now(),
|
|
5332
6235
|
protectedIds: driver.protectedSessionIds()
|
|
5333
6236
|
}
|
|
@@ -5363,7 +6266,7 @@ async function runSweep(state, driver, config2) {
|
|
|
5363
6266
|
}
|
|
5364
6267
|
}
|
|
5365
6268
|
function scheduleSessionCleanup(state, driver, options) {
|
|
5366
|
-
const
|
|
6269
|
+
const config = resolveSessionCleanupConfig(
|
|
5367
6270
|
{
|
|
5368
6271
|
maxAge: options.sessionCleanupMaxAge,
|
|
5369
6272
|
maxCount: options.sessionCleanupMaxCount,
|
|
@@ -5371,21 +6274,112 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
5371
6274
|
},
|
|
5372
6275
|
process.env
|
|
5373
6276
|
);
|
|
5374
|
-
for (const warning2 of
|
|
6277
|
+
for (const warning2 of config.warnings) {
|
|
5375
6278
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
5376
6279
|
}
|
|
5377
|
-
if (!
|
|
6280
|
+
if (!config.enabled) return;
|
|
5378
6281
|
logActivity(state, {
|
|
5379
6282
|
type: "info",
|
|
5380
|
-
message: `Session cleanup enabled (age=${
|
|
6283
|
+
message: `Session cleanup enabled (age=${config.maxAgeMs ?? "\u2014"}, count=${config.maxCount ?? "\u2014"}, interval=${config.intervalMs}ms)`
|
|
5381
6284
|
});
|
|
5382
|
-
const interval = setInterval(() => void runSweep(state, driver,
|
|
6285
|
+
const interval = setInterval(() => void runSweep(state, driver, config), config.intervalMs);
|
|
5383
6286
|
const firstSweep = setTimeout(
|
|
5384
|
-
() => void runSweep(state, driver,
|
|
6287
|
+
() => void runSweep(state, driver, config),
|
|
5385
6288
|
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
5386
6289
|
);
|
|
5387
6290
|
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
5388
6291
|
}
|
|
6292
|
+
function claudeUsageFailureStreakSuffix(consecutiveFailures) {
|
|
6293
|
+
return consecutiveFailures > 1 ? ` (${consecutiveFailures} consecutive failures)` : "";
|
|
6294
|
+
}
|
|
6295
|
+
function scheduleClaudeUsageReporting(state, options) {
|
|
6296
|
+
const { mode, warnings } = resolveClaudeUsageReportingMode(
|
|
6297
|
+
options.claudeUsageReporting,
|
|
6298
|
+
process.env
|
|
6299
|
+
);
|
|
6300
|
+
for (const warning2 of warnings) {
|
|
6301
|
+
logActivity(state, {
|
|
6302
|
+
type: "info",
|
|
6303
|
+
level: "warn",
|
|
6304
|
+
message: `Claude usage reporting: ${warning2}`
|
|
6305
|
+
});
|
|
6306
|
+
}
|
|
6307
|
+
if (mode === "off") {
|
|
6308
|
+
logActivity(state, {
|
|
6309
|
+
type: "info",
|
|
6310
|
+
level: "debug",
|
|
6311
|
+
message: "Claude usage reporting is off (--claude-usage-reporting off)"
|
|
6312
|
+
});
|
|
6313
|
+
return;
|
|
6314
|
+
}
|
|
6315
|
+
let consecutiveFailures = 0;
|
|
6316
|
+
const scheduleNextTick = () => {
|
|
6317
|
+
state.claudeUsageTimer = setTimeout(() => void tick(false), nextReportDelayMs());
|
|
6318
|
+
};
|
|
6319
|
+
const tick = async (isFirst) => {
|
|
6320
|
+
try {
|
|
6321
|
+
const usage = await getClaudeUsage();
|
|
6322
|
+
const result = await reportClaudeUsage(state.agentId, state.authHeader, usage);
|
|
6323
|
+
if (result.ok) {
|
|
6324
|
+
if (consecutiveFailures > 0) {
|
|
6325
|
+
logActivity(state, {
|
|
6326
|
+
type: "info",
|
|
6327
|
+
level: "info",
|
|
6328
|
+
message: "Claude usage reporting recovered"
|
|
6329
|
+
});
|
|
6330
|
+
}
|
|
6331
|
+
consecutiveFailures = 0;
|
|
6332
|
+
logActivity(state, {
|
|
6333
|
+
type: "info",
|
|
6334
|
+
level: "debug",
|
|
6335
|
+
message: "Reported Claude usage to Evident"
|
|
6336
|
+
});
|
|
6337
|
+
} else {
|
|
6338
|
+
consecutiveFailures++;
|
|
6339
|
+
logActivity(state, {
|
|
6340
|
+
type: "info",
|
|
6341
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
6342
|
+
message: `Failed to report Claude usage: ${result.error}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
6343
|
+
});
|
|
6344
|
+
}
|
|
6345
|
+
scheduleNextTick();
|
|
6346
|
+
} catch (error2) {
|
|
6347
|
+
if (error2 instanceof ClaudeUsageError && isLocalCredentialProblem(error2)) {
|
|
6348
|
+
if (mode === "on") {
|
|
6349
|
+
logActivity(state, {
|
|
6350
|
+
type: "info",
|
|
6351
|
+
level: "warn",
|
|
6352
|
+
message: "Claude usage reporting is forced on but no usable Claude Code login was found \u2014 run `claude` to sign in; reporting will keep retrying"
|
|
6353
|
+
});
|
|
6354
|
+
scheduleNextTick();
|
|
6355
|
+
} else if (isFirst) {
|
|
6356
|
+
logActivity(state, {
|
|
6357
|
+
type: "info",
|
|
6358
|
+
level: "debug",
|
|
6359
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6360
|
+
});
|
|
6361
|
+
} else {
|
|
6362
|
+
logActivity(state, {
|
|
6363
|
+
type: "info",
|
|
6364
|
+
level: "debug",
|
|
6365
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6366
|
+
});
|
|
6367
|
+
scheduleNextTick();
|
|
6368
|
+
}
|
|
6369
|
+
} else {
|
|
6370
|
+
consecutiveFailures++;
|
|
6371
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
6372
|
+
logActivity(state, {
|
|
6373
|
+
type: "info",
|
|
6374
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
6375
|
+
message: `Claude usage reporting failed: ${message}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
6376
|
+
});
|
|
6377
|
+
scheduleNextTick();
|
|
6378
|
+
}
|
|
6379
|
+
}
|
|
6380
|
+
};
|
|
6381
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
6382
|
+
}
|
|
5389
6383
|
async function notifyOffline(state) {
|
|
5390
6384
|
if (!state.agentId || !state.authHeader) return;
|
|
5391
6385
|
if (!state.connected) {
|
|
@@ -5403,13 +6397,28 @@ async function notifyOffline(state) {
|
|
|
5403
6397
|
if (state.interactive) displayStatus(state);
|
|
5404
6398
|
}
|
|
5405
6399
|
}
|
|
6400
|
+
async function timeShutdownPhase(state, durations, name, run2) {
|
|
6401
|
+
const startedAt = Date.now();
|
|
6402
|
+
try {
|
|
6403
|
+
return await run2();
|
|
6404
|
+
} finally {
|
|
6405
|
+
const elapsedMs = Date.now() - startedAt;
|
|
6406
|
+
durations[name] = elapsedMs;
|
|
6407
|
+
log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
5406
6410
|
async function cleanup(state, opts = {}) {
|
|
6411
|
+
const durations = {};
|
|
5407
6412
|
state.running = false;
|
|
5408
6413
|
for (const timer of state.sessionCleanupTimers) {
|
|
5409
6414
|
clearInterval(timer);
|
|
5410
6415
|
clearTimeout(timer);
|
|
5411
6416
|
}
|
|
5412
6417
|
state.sessionCleanupTimers = [];
|
|
6418
|
+
if (state.claudeUsageTimer) {
|
|
6419
|
+
clearTimeout(state.claudeUsageTimer);
|
|
6420
|
+
state.claudeUsageTimer = null;
|
|
6421
|
+
}
|
|
5413
6422
|
if (opts.graceful && state.channelDriver) {
|
|
5414
6423
|
state.channelDriver.stop();
|
|
5415
6424
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -5417,7 +6426,13 @@ async function cleanup(state, opts = {}) {
|
|
|
5417
6426
|
logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
|
|
5418
6427
|
displayStatus(state);
|
|
5419
6428
|
}
|
|
5420
|
-
const
|
|
6429
|
+
const driver = state.channelDriver;
|
|
6430
|
+
const settled = await timeShutdownPhase(
|
|
6431
|
+
state,
|
|
6432
|
+
durations,
|
|
6433
|
+
"drain",
|
|
6434
|
+
() => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
6435
|
+
);
|
|
5421
6436
|
if (!settled) {
|
|
5422
6437
|
logActivity(state, {
|
|
5423
6438
|
type: "info",
|
|
@@ -5426,13 +6441,15 @@ async function cleanup(state, opts = {}) {
|
|
|
5426
6441
|
if (state.interactive) displayStatus(state);
|
|
5427
6442
|
}
|
|
5428
6443
|
}
|
|
5429
|
-
await notifyOffline(state);
|
|
6444
|
+
await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
|
|
5430
6445
|
if (state.connection) {
|
|
5431
|
-
state.connection
|
|
6446
|
+
const connection = state.connection;
|
|
6447
|
+
await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
|
|
5432
6448
|
state.connection = null;
|
|
5433
6449
|
}
|
|
5434
6450
|
if (state.opencodeProcess) {
|
|
5435
|
-
|
|
6451
|
+
const opencodeProcess = state.opencodeProcess;
|
|
6452
|
+
await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
|
|
5436
6453
|
if (state.interactive) {
|
|
5437
6454
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
5438
6455
|
displayStatus(state);
|
|
@@ -5441,6 +6458,7 @@ async function cleanup(state, opts = {}) {
|
|
|
5441
6458
|
}
|
|
5442
6459
|
state.opencodeProcess = null;
|
|
5443
6460
|
}
|
|
6461
|
+
return durations;
|
|
5444
6462
|
}
|
|
5445
6463
|
async function run(options) {
|
|
5446
6464
|
const interactive = isInteractive(options.json);
|
|
@@ -5481,8 +6499,10 @@ async function run(options) {
|
|
|
5481
6499
|
messageCount: 0,
|
|
5482
6500
|
lastProxiedActivityAt: null,
|
|
5483
6501
|
sessionCleanupTimers: [],
|
|
6502
|
+
claudeUsageTimer: null,
|
|
5484
6503
|
authHeader: ""
|
|
5485
6504
|
};
|
|
6505
|
+
setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
|
|
5486
6506
|
if (fileSyncDirectories.length > 0) {
|
|
5487
6507
|
log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
|
|
5488
6508
|
} else {
|
|
@@ -5511,14 +6531,38 @@ async function run(options) {
|
|
|
5511
6531
|
const handleSignal = async () => {
|
|
5512
6532
|
if (state.shuttingDown) return;
|
|
5513
6533
|
state.shuttingDown = true;
|
|
6534
|
+
const shutdownStartedAt = Date.now();
|
|
5514
6535
|
if (state.interactive) {
|
|
5515
6536
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
5516
6537
|
displayStatus(state);
|
|
5517
6538
|
} else {
|
|
5518
6539
|
log2(state, "Shutting down...");
|
|
5519
6540
|
}
|
|
5520
|
-
await cleanup(state, { graceful: true });
|
|
5521
|
-
|
|
6541
|
+
const durations = await cleanup(state, { graceful: true });
|
|
6542
|
+
const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
|
|
6543
|
+
await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
|
|
6544
|
+
let timer;
|
|
6545
|
+
const flushed = shutdownTelemetry().then(
|
|
6546
|
+
() => true,
|
|
6547
|
+
(error2) => {
|
|
6548
|
+
log2(
|
|
6549
|
+
state,
|
|
6550
|
+
`Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
6551
|
+
"warn"
|
|
6552
|
+
);
|
|
6553
|
+
return true;
|
|
6554
|
+
}
|
|
6555
|
+
);
|
|
6556
|
+
const timedOut = new Promise((resolve3) => {
|
|
6557
|
+
timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
|
|
6558
|
+
});
|
|
6559
|
+
if (!await Promise.race([flushed, timedOut])) {
|
|
6560
|
+
log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
|
|
6561
|
+
}
|
|
6562
|
+
clearTimeout(timer);
|
|
6563
|
+
});
|
|
6564
|
+
const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
|
|
6565
|
+
log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
|
|
5522
6566
|
process.exit(0);
|
|
5523
6567
|
};
|
|
5524
6568
|
process.on("SIGINT", handleSignal);
|
|
@@ -5535,6 +6579,7 @@ async function run(options) {
|
|
|
5535
6579
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
5536
6580
|
blank();
|
|
5537
6581
|
process.exit(1);
|
|
6582
|
+
return;
|
|
5538
6583
|
}
|
|
5539
6584
|
blank();
|
|
5540
6585
|
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
@@ -5579,6 +6624,7 @@ async function run(options) {
|
|
|
5579
6624
|
} else {
|
|
5580
6625
|
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
5581
6626
|
process.exit(1);
|
|
6627
|
+
return;
|
|
5582
6628
|
}
|
|
5583
6629
|
} else {
|
|
5584
6630
|
printError(
|
|
@@ -5592,6 +6638,7 @@ async function run(options) {
|
|
|
5592
6638
|
);
|
|
5593
6639
|
blank();
|
|
5594
6640
|
process.exit(1);
|
|
6641
|
+
return;
|
|
5595
6642
|
}
|
|
5596
6643
|
}
|
|
5597
6644
|
telemetry.info(
|
|
@@ -5632,40 +6679,67 @@ async function run(options) {
|
|
|
5632
6679
|
}
|
|
5633
6680
|
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
5634
6681
|
state.agentName = validation.agent.name;
|
|
6682
|
+
const microvmId = process.env.MICROVM_ID?.trim();
|
|
6683
|
+
if (microvmId) {
|
|
6684
|
+
const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
|
|
6685
|
+
if (reported.ok) {
|
|
6686
|
+
log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
|
|
6687
|
+
} else {
|
|
6688
|
+
const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
|
|
6689
|
+
log2(state, message, "warn");
|
|
6690
|
+
if (state.interactive && !state.json) {
|
|
6691
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
6692
|
+
}
|
|
6693
|
+
}
|
|
6694
|
+
} else {
|
|
6695
|
+
log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
|
|
6696
|
+
}
|
|
6697
|
+
const { timeoutMs: opencodeStartTimeoutMs, warnings: opencodeStartTimeoutWarnings } = resolveOpenCodeStartTimeoutMs(options, process.env);
|
|
6698
|
+
for (const warning2 of opencodeStartTimeoutWarnings) {
|
|
6699
|
+
logActivity(state, { type: "info", level: "warn", message: warning2 });
|
|
6700
|
+
}
|
|
5635
6701
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
5636
6702
|
try {
|
|
5637
6703
|
const oc = await ensureOpenCodeRunning({
|
|
5638
6704
|
port: state.port,
|
|
5639
6705
|
interactive: state.interactive,
|
|
5640
6706
|
agentId: state.agentId,
|
|
5641
|
-
log: (message) => log2(state, message)
|
|
6707
|
+
log: (message) => log2(state, message),
|
|
6708
|
+
startTimeoutMs: opencodeStartTimeoutMs
|
|
5642
6709
|
});
|
|
5643
6710
|
state.port = oc.port;
|
|
5644
6711
|
state.opencodeProcess = oc.process;
|
|
5645
6712
|
state.opencodeVersion = oc.version;
|
|
5646
|
-
state.opencodeConnected = oc.
|
|
6713
|
+
state.opencodeConnected = oc.notReadyReason === null;
|
|
5647
6714
|
const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
5648
6715
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
6716
|
+
if (!state.interactive && oc.notReadyReason !== null) {
|
|
6717
|
+
const message = `OpenCode is not ready on port ${state.port}: ${oc.notReadyReason}. The runner will still come online, but messages will fail until opencode answers \u2014 raise the wait with --opencode-start-timeout <seconds> (env ${OPENCODE_START_TIMEOUT_ENV}).`;
|
|
6718
|
+
logActivity(state, { type: "info", level: "warn", message });
|
|
6719
|
+
} else {
|
|
6720
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
6721
|
+
if (versionWarning) {
|
|
6722
|
+
log2(state, versionWarning, "warn");
|
|
6723
|
+
if (state.interactive && !state.json) {
|
|
6724
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
6725
|
+
}
|
|
5654
6726
|
}
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
6727
|
+
const noProviderWarning = buildNoProviderWarning(
|
|
6728
|
+
await hasAnyConfiguredProvider(state.port)
|
|
6729
|
+
);
|
|
6730
|
+
if (noProviderWarning) {
|
|
6731
|
+
log2(state, noProviderWarning, "warn");
|
|
6732
|
+
if (state.interactive && !state.json) {
|
|
6733
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
6734
|
+
blank();
|
|
6735
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
6736
|
+
console.log(
|
|
6737
|
+
chalk6.dim(
|
|
6738
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
6739
|
+
)
|
|
6740
|
+
);
|
|
6741
|
+
blank();
|
|
6742
|
+
}
|
|
5669
6743
|
}
|
|
5670
6744
|
}
|
|
5671
6745
|
} catch (error2) {
|
|
@@ -5710,6 +6784,18 @@ async function run(options) {
|
|
|
5710
6784
|
type: "info",
|
|
5711
6785
|
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
5712
6786
|
});
|
|
6787
|
+
if (options.tunnelReadyFile) {
|
|
6788
|
+
const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
|
|
6789
|
+
if (marker.ok) {
|
|
6790
|
+
log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
|
|
6791
|
+
} else {
|
|
6792
|
+
log2(
|
|
6793
|
+
state,
|
|
6794
|
+
`Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
|
|
6795
|
+
"error"
|
|
6796
|
+
);
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
5713
6799
|
emitAgentConnected(state.agentId, {
|
|
5714
6800
|
port: state.port,
|
|
5715
6801
|
cli_version: getCliVersion(),
|
|
@@ -5800,6 +6886,7 @@ async function run(options) {
|
|
|
5800
6886
|
throw error2;
|
|
5801
6887
|
}
|
|
5802
6888
|
scheduleSessionCleanup(state, channelDriver, options);
|
|
6889
|
+
scheduleClaudeUsageReporting(state, options);
|
|
5803
6890
|
if (!interactive || state.json) {
|
|
5804
6891
|
log2(state, "Driving channel messages...");
|
|
5805
6892
|
}
|
|
@@ -5854,13 +6941,18 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
5854
6941
|
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);
|
|
5855
6942
|
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 }));
|
|
5856
6943
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
6944
|
+
program.command("status").description("Check whether the configured credentials can reach Evident").option("--json", "Output in JSON format").action((options) => status({ json: options.json }));
|
|
6945
|
+
program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
|
|
5857
6946
|
program.command("run").description("Connect to Evident and process messages").option("--runner [id]", "Runner ID to connect to (optional when EVIDENT_RUNNER_KEY is set)").option(
|
|
5858
6947
|
"-a, --agent [id]",
|
|
5859
6948
|
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
5860
6949
|
).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
5861
6950
|
"--log-level <level>",
|
|
5862
6951
|
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
5863
|
-
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option(
|
|
6952
|
+
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option(
|
|
6953
|
+
"--opencode-start-timeout <seconds>",
|
|
6954
|
+
"Seconds to wait for OpenCode to become healthy when the runner starts it (default: 180). Env: EVIDENT_OPENCODE_START_TIMEOUT"
|
|
6955
|
+
).option("--json", "Output in JSON format").option(
|
|
5864
6956
|
"--session-cleanup-max-age <duration>",
|
|
5865
6957
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
5866
6958
|
).option(
|
|
@@ -5869,11 +6961,17 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
5869
6961
|
).option(
|
|
5870
6962
|
"--session-cleanup-interval <duration>",
|
|
5871
6963
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
6964
|
+
).option(
|
|
6965
|
+
"--claude-usage-reporting <mode>",
|
|
6966
|
+
"Report Claude subscription usage to Evident: auto | on | off (default: auto). Env: EVIDENT_CLAUDE_USAGE_REPORTING"
|
|
5872
6967
|
).option(
|
|
5873
6968
|
"--enable-file-sync-to <dir>",
|
|
5874
6969
|
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
5875
6970
|
(value, previous) => previous.concat([value]),
|
|
5876
6971
|
[]
|
|
6972
|
+
).option(
|
|
6973
|
+
"--tunnel-ready-file <path>",
|
|
6974
|
+
"Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
|
|
5877
6975
|
).action(
|
|
5878
6976
|
(options) => {
|
|
5879
6977
|
run({
|
|
@@ -5886,14 +6984,21 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
5886
6984
|
verbose: options.verbose,
|
|
5887
6985
|
conversation: options.conversation,
|
|
5888
6986
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|
|
6987
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
6988
|
+
// resolveOpenCodeStartTimeoutMs (flag > EVIDENT_OPENCODE_START_TIMEOUT > 180s default).
|
|
6989
|
+
opencodeStartTimeout: options.opencodeStartTimeout,
|
|
5889
6990
|
json: options.json,
|
|
5890
6991
|
// Raw strings — the resolver in run.ts single-sources parsing (M1).
|
|
5891
6992
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
5892
6993
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
5893
6994
|
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
6995
|
+
// Raw string — the resolver in run.ts single-sources parsing
|
|
6996
|
+
// (resolveClaudeUsageReportingMode).
|
|
6997
|
+
claudeUsageReporting: options.claudeUsageReporting,
|
|
5894
6998
|
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
5895
6999
|
// resolveFileSyncDirectories.
|
|
5896
|
-
enableFileSyncTo: options.enableFileSyncTo
|
|
7000
|
+
enableFileSyncTo: options.enableFileSyncTo,
|
|
7001
|
+
tunnelReadyFile: options.tunnelReadyFile
|
|
5897
7002
|
});
|
|
5898
7003
|
}
|
|
5899
7004
|
);
|