@evident-ai/cli 3.1.1-dev.134003c → 3.1.1-dev.1640e9c
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 +41 -0
- package/dist/index.js +1132 -252
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,11 +38,6 @@ 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: "",
|
|
@@ -537,11 +532,474 @@ async function whoami() {
|
|
|
537
532
|
blank();
|
|
538
533
|
}
|
|
539
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);
|
|
990
|
+
}
|
|
991
|
+
throw err;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
540
995
|
// src/commands/run.ts
|
|
541
|
-
import { homedir as
|
|
542
|
-
import { isAbsolute as isAbsolute2, join as
|
|
996
|
+
import { homedir as homedir3 } from "os";
|
|
997
|
+
import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
|
|
543
998
|
import chalk6 from "chalk";
|
|
544
999
|
|
|
1000
|
+
// ../../packages/types/src/agents/index.ts
|
|
1001
|
+
var MICROVM_MAX_LIFETIME_MS = 8 * 60 * 6e4;
|
|
1002
|
+
|
|
545
1003
|
// ../../packages/types/src/telemetry/index.ts
|
|
546
1004
|
var TelemetryEventTypes = {
|
|
547
1005
|
// Agent activity events (shown in web UI activity log)
|
|
@@ -824,49 +1282,6 @@ function forwardRunnerActivity(entry, context) {
|
|
|
824
1282
|
}
|
|
825
1283
|
}
|
|
826
1284
|
|
|
827
|
-
// src/lib/auth.ts
|
|
828
|
-
async function getAuthCredentials() {
|
|
829
|
-
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
830
|
-
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
831
|
-
if (runnerKey) {
|
|
832
|
-
return {
|
|
833
|
-
token: runnerKey,
|
|
834
|
-
authType: "agent_key",
|
|
835
|
-
keySource: "runner_key",
|
|
836
|
-
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
if (agentKey) {
|
|
840
|
-
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
841
|
-
}
|
|
842
|
-
const userToken = process.env.EVIDENT_TOKEN;
|
|
843
|
-
if (userToken) {
|
|
844
|
-
return { token: userToken, authType: "bearer" };
|
|
845
|
-
}
|
|
846
|
-
const keychainCreds = await getToken();
|
|
847
|
-
if (keychainCreds) {
|
|
848
|
-
return {
|
|
849
|
-
token: keychainCreds.token,
|
|
850
|
-
authType: "bearer",
|
|
851
|
-
user: keychainCreds.user
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
return null;
|
|
855
|
-
}
|
|
856
|
-
function getAuthHeader(credentials2) {
|
|
857
|
-
if (credentials2.authType === "agent_key") {
|
|
858
|
-
return `SandboxKey ${credentials2.token}`;
|
|
859
|
-
}
|
|
860
|
-
return `Bearer ${credentials2.token}`;
|
|
861
|
-
}
|
|
862
|
-
function isInteractive(jsonOutput) {
|
|
863
|
-
if (jsonOutput) return false;
|
|
864
|
-
if (process.env.CI) return false;
|
|
865
|
-
if (process.env.GITHUB_ACTIONS) return false;
|
|
866
|
-
if (!process.stdin.isTTY) return false;
|
|
867
|
-
return true;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
1285
|
// src/lib/opencode/health.ts
|
|
871
1286
|
async function checkOpenCodeHealth(port) {
|
|
872
1287
|
try {
|
|
@@ -1674,6 +2089,21 @@ function messageError(messages, userMessageId) {
|
|
|
1674
2089
|
}
|
|
1675
2090
|
return "The agent run failed.";
|
|
1676
2091
|
}
|
|
2092
|
+
function isAbortedTerminalReply(messages, userMessageId) {
|
|
2093
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
2094
|
+
const error2 = errorOf(reply);
|
|
2095
|
+
if (error2 == null) return false;
|
|
2096
|
+
if (typeof error2 === "string") return error2.trim() === "Aborted";
|
|
2097
|
+
if (typeof error2 === "object") {
|
|
2098
|
+
const e = error2;
|
|
2099
|
+
if (e.name === "MessageAbortedError") return true;
|
|
2100
|
+
if (e.name === "AbortError") return true;
|
|
2101
|
+
const dataMessage = e.data?.message;
|
|
2102
|
+
const rendered = typeof dataMessage === "string" ? dataMessage : typeof e.message === "string" ? e.message : null;
|
|
2103
|
+
return rendered != null && rendered.trim() === "Aborted";
|
|
2104
|
+
}
|
|
2105
|
+
return false;
|
|
2106
|
+
}
|
|
1677
2107
|
function messageFailure(messages, userMessageId) {
|
|
1678
2108
|
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1679
2109
|
const error2 = errorOf(reply);
|
|
@@ -2250,13 +2680,44 @@ function writeTunnelReadyMarker(path, agentId) {
|
|
|
2250
2680
|
}
|
|
2251
2681
|
}
|
|
2252
2682
|
|
|
2683
|
+
// src/lib/claude-usage-reporting.ts
|
|
2684
|
+
var VALID_MODES = ["auto", "on", "off"];
|
|
2685
|
+
function resolveClaudeUsageReportingMode(flagValue, env) {
|
|
2686
|
+
const raw = flagValue ?? env.EVIDENT_CLAUDE_USAGE_REPORTING;
|
|
2687
|
+
if (raw === void 0 || raw === "") {
|
|
2688
|
+
return { mode: "auto", warnings: [] };
|
|
2689
|
+
}
|
|
2690
|
+
const normalized = raw.trim().toLowerCase();
|
|
2691
|
+
if (VALID_MODES.includes(normalized)) {
|
|
2692
|
+
return { mode: normalized, warnings: [] };
|
|
2693
|
+
}
|
|
2694
|
+
const source = flagValue !== void 0 ? "--claude-usage-reporting" : "EVIDENT_CLAUDE_USAGE_REPORTING";
|
|
2695
|
+
return {
|
|
2696
|
+
mode: "auto",
|
|
2697
|
+
warnings: [
|
|
2698
|
+
`Ignoring invalid ${source} "${raw}": expected one of ${VALID_MODES.join(", ")}; using auto`
|
|
2699
|
+
]
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
var BASE_REPORT_DELAY_MS = 10 * 6e4;
|
|
2703
|
+
var REPORT_DELAY_JITTER_FRACTION = 0.2;
|
|
2704
|
+
function nextReportDelayMs(random = Math.random) {
|
|
2705
|
+
const jitterRangeMs = BASE_REPORT_DELAY_MS * REPORT_DELAY_JITTER_FRACTION;
|
|
2706
|
+
return BASE_REPORT_DELAY_MS - jitterRangeMs + random() * (2 * jitterRangeMs);
|
|
2707
|
+
}
|
|
2708
|
+
var FIRST_REPORT_DELAY_MS = 5e3 + Math.random() * 1e4;
|
|
2709
|
+
var CLAUDE_USAGE_FAILURE_REESCALATION_TICKS = 6;
|
|
2710
|
+
function claudeUsageFailureLogLevel(consecutiveFailures) {
|
|
2711
|
+
return consecutiveFailures === 1 || consecutiveFailures % CLAUDE_USAGE_FAILURE_REESCALATION_TICKS === 0 ? "warn" : "debug";
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2253
2714
|
// src/lib/channels/driver.ts
|
|
2254
|
-
import { homedir } from "os";
|
|
2715
|
+
import { homedir as homedir2 } from "os";
|
|
2255
2716
|
|
|
2256
2717
|
// src/lib/file-push.ts
|
|
2257
2718
|
import { randomUUID } from "crypto";
|
|
2258
2719
|
import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
|
|
2259
|
-
import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
|
|
2720
|
+
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
|
|
2260
2721
|
var FILE_MODE = 384;
|
|
2261
2722
|
var DIRECTORY_MODE = 448;
|
|
2262
2723
|
async function writePushedFile(request) {
|
|
@@ -2289,7 +2750,7 @@ async function writePushedFile(request) {
|
|
|
2289
2750
|
const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
|
|
2290
2751
|
dirname2(candidate)
|
|
2291
2752
|
);
|
|
2292
|
-
const realTarget =
|
|
2753
|
+
const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
|
|
2293
2754
|
const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
|
|
2294
2755
|
if (allowedDirectory === null) {
|
|
2295
2756
|
return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
|
|
@@ -2325,7 +2786,7 @@ function expandAndValidate(requestedPath, homeDir) {
|
|
|
2325
2786
|
if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
|
|
2326
2787
|
return null;
|
|
2327
2788
|
}
|
|
2328
|
-
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ?
|
|
2789
|
+
const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
|
|
2329
2790
|
if (expanded.split(/[/\\]/).includes("..")) {
|
|
2330
2791
|
return null;
|
|
2331
2792
|
}
|
|
@@ -2398,13 +2859,13 @@ function contains(realDirectory, realTarget) {
|
|
|
2398
2859
|
async function createMissingDirectories(existingAncestor, missingSegments) {
|
|
2399
2860
|
let current = existingAncestor;
|
|
2400
2861
|
for (const segment of missingSegments) {
|
|
2401
|
-
current =
|
|
2862
|
+
current = join2(current, segment);
|
|
2402
2863
|
await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
|
|
2403
2864
|
await chmod(current, DIRECTORY_MODE);
|
|
2404
2865
|
}
|
|
2405
2866
|
}
|
|
2406
2867
|
async function writeAtomically(realTarget, content) {
|
|
2407
|
-
const temporaryPath =
|
|
2868
|
+
const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
|
|
2408
2869
|
let handle;
|
|
2409
2870
|
try {
|
|
2410
2871
|
handle = await open2(temporaryPath, "wx", FILE_MODE);
|
|
@@ -2571,8 +3032,8 @@ async function applyOne(options, file) {
|
|
|
2571
3032
|
await ack(options, file, "applied");
|
|
2572
3033
|
return true;
|
|
2573
3034
|
}
|
|
2574
|
-
function durableDownloadCode(
|
|
2575
|
-
return
|
|
3035
|
+
function durableDownloadCode(status2) {
|
|
3036
|
+
return status2 === 413 ? "file_too_large" : "write_failed";
|
|
2576
3037
|
}
|
|
2577
3038
|
async function downloadContent(options, file, label) {
|
|
2578
3039
|
try {
|
|
@@ -2605,8 +3066,8 @@ async function downloadContent(options, file, label) {
|
|
|
2605
3066
|
return { ok: false, terminal: false };
|
|
2606
3067
|
}
|
|
2607
3068
|
}
|
|
2608
|
-
async function ack(options, file,
|
|
2609
|
-
const outcome = `${
|
|
3069
|
+
async function ack(options, file, status2, reason) {
|
|
3070
|
+
const outcome = `${status2}${reason ? ` (${reason})` : ""}`;
|
|
2610
3071
|
try {
|
|
2611
3072
|
const res = await options.fetchImpl(
|
|
2612
3073
|
`${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
|
|
@@ -2616,7 +3077,7 @@ async function ack(options, file, status, reason) {
|
|
|
2616
3077
|
Authorization: options.getAuthHeader(),
|
|
2617
3078
|
"Content-Type": "application/json"
|
|
2618
3079
|
},
|
|
2619
|
-
body: JSON.stringify(reason ? { status, reason } : { status })
|
|
3080
|
+
body: JSON.stringify(reason ? { status: status2, reason } : { status: status2 })
|
|
2620
3081
|
}
|
|
2621
3082
|
);
|
|
2622
3083
|
if (!res.ok) {
|
|
@@ -2680,6 +3141,7 @@ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
|
|
|
2680
3141
|
var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
|
|
2681
3142
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2682
3143
|
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
3144
|
+
var MAX_IDENTICAL_REDRIVE_POLL_FAILURES = 5;
|
|
2683
3145
|
var ChannelAuthError = class extends Error {
|
|
2684
3146
|
constructor(message) {
|
|
2685
3147
|
super(message);
|
|
@@ -2688,10 +3150,10 @@ var ChannelAuthError = class extends Error {
|
|
|
2688
3150
|
};
|
|
2689
3151
|
var ChannelTerminalError = class extends Error {
|
|
2690
3152
|
status;
|
|
2691
|
-
constructor(message,
|
|
3153
|
+
constructor(message, status2) {
|
|
2692
3154
|
super(message);
|
|
2693
3155
|
this.name = "ChannelTerminalError";
|
|
2694
|
-
this.status =
|
|
3156
|
+
this.status = status2;
|
|
2695
3157
|
}
|
|
2696
3158
|
};
|
|
2697
3159
|
function backoffDelay(attempt, policy) {
|
|
@@ -2699,8 +3161,12 @@ function backoffDelay(attempt, policy) {
|
|
|
2699
3161
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
2700
3162
|
return Math.floor(Math.random() * capped);
|
|
2701
3163
|
}
|
|
2702
|
-
function isRetryableStatus(
|
|
2703
|
-
return
|
|
3164
|
+
function isRetryableStatus(status2) {
|
|
3165
|
+
return status2 === 429 || status2 >= 500 && status2 <= 599;
|
|
3166
|
+
}
|
|
3167
|
+
var VOLATILE_BODY_FIELD_PATTERN = /("(?:ref|requestId|request_id|traceId|trace_id)"\s*:\s*)"[^"]*"/gi;
|
|
3168
|
+
function normalizeRedrivePollFailureBody(body) {
|
|
3169
|
+
return body.replace(VOLATILE_BODY_FIELD_PATTERN, '$1"<redacted>"').replace(/\s+/g, " ").trim().slice(0, 200);
|
|
2704
3170
|
}
|
|
2705
3171
|
var ChannelDriver = class _ChannelDriver {
|
|
2706
3172
|
agentId;
|
|
@@ -2815,6 +3281,43 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2815
3281
|
* processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
|
|
2816
3282
|
*/
|
|
2817
3283
|
readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
3284
|
+
/**
|
|
3285
|
+
* "Already emitted `redrive_unresolved` for this row" (#965). Mirrors
|
|
3286
|
+
* `readoptPollUnresolvedSignalled`: `resolveRedrive`'s `unresolved` leaf recurs
|
|
3287
|
+
* every ~2s drain until opencode's status becomes readable, but the
|
|
3288
|
+
* server-visible signal is an OUTCOME, so it fires at most once per row. Cleared
|
|
3289
|
+
* on any non-`unresolved` outcome so the set cannot grow beyond the currently
|
|
3290
|
+
* unresolvable rows.
|
|
3291
|
+
*/
|
|
3292
|
+
redriveUnresolvedSignalled = /* @__PURE__ */ new Set();
|
|
3293
|
+
/**
|
|
3294
|
+
* First `now()` a `pending` row's re-drive was observed `unresolved` (#965). A
|
|
3295
|
+
* `pending` row is invisible to every cron arm (all require `status =
|
|
3296
|
+
* 'processing'`), so an indefinitely-`unresolved` row would be stranded with
|
|
3297
|
+
* nothing driving it. Once `now - since >= pausedMaxWaitMs`, `resolveRedrive`
|
|
3298
|
+
* takes `dispatch` instead of `unresolved` (reusing the existing knob — see
|
|
3299
|
+
* ADR-0047's own "unreachable ⇒ bounded" rule). Cleared on any other outcome.
|
|
3300
|
+
*/
|
|
3301
|
+
redriveUnresolvedSince = /* @__PURE__ */ new Map();
|
|
3302
|
+
/**
|
|
3303
|
+
* Consecutive-identical-poll-failure streak for the re-drive fence (#1348),
|
|
3304
|
+
* keyed by Evident **message id** (not session) so `clearRedriveUnresolved`
|
|
3305
|
+
* can drop it with the other two trackers and it cannot leak. `sessionId` is
|
|
3306
|
+
* carried inside the entry, not the key: a session change is a different
|
|
3307
|
+
* situation and resets the streak, which gives the `(sessionId, message.id)`
|
|
3308
|
+
* pairing #1348 asks for without a composite map key.
|
|
3309
|
+
*/
|
|
3310
|
+
redrivePollFailures = /* @__PURE__ */ new Map();
|
|
3311
|
+
/**
|
|
3312
|
+
* "Already emitted `redrive_outcome_unreported` for THIS (message, outcome)
|
|
3313
|
+
* streak" (Class B, #1340: the runner DECIDED reattach/settle/fail_permanent
|
|
3314
|
+
* but its own PATCH to record it failed — distinct from Class A's
|
|
3315
|
+
* `redrive_poll_failed`, where opencode itself can't be observed). Keyed by
|
|
3316
|
+
* message id, valued by the outcome currently failing to report, so a
|
|
3317
|
+
* change of outcome starts a fresh signal. Cleared by
|
|
3318
|
+
* `clearRedriveUnresolved` the instant either PATCH succeeds.
|
|
3319
|
+
*/
|
|
3320
|
+
redriveOutcomeUnreportedSignalled = /* @__PURE__ */ new Map();
|
|
2818
3321
|
/**
|
|
2819
3322
|
* "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
|
|
2820
3323
|
* Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
|
|
@@ -2903,23 +3406,23 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
2903
3406
|
* and stops opencode.
|
|
2904
3407
|
*/
|
|
2905
3408
|
stopped = false;
|
|
2906
|
-
constructor(
|
|
2907
|
-
this.agentId =
|
|
2908
|
-
this.port =
|
|
2909
|
-
this.apiUrl =
|
|
2910
|
-
this.getAuthHeader =
|
|
2911
|
-
this.conversationFilter =
|
|
2912
|
-
this.retry = { ...DEFAULT_RETRY_POLICY, ...
|
|
2913
|
-
this.log =
|
|
3409
|
+
constructor(config) {
|
|
3410
|
+
this.agentId = config.agentId;
|
|
3411
|
+
this.port = config.port;
|
|
3412
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, "");
|
|
3413
|
+
this.getAuthHeader = config.getAuthHeader;
|
|
3414
|
+
this.conversationFilter = config.conversationFilter ?? null;
|
|
3415
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...config.retry };
|
|
3416
|
+
this.log = config.log ?? (() => {
|
|
2914
3417
|
});
|
|
2915
|
-
this.fetchImpl =
|
|
2916
|
-
this.sleep =
|
|
2917
|
-
this.pausedPollIntervalMs =
|
|
2918
|
-
this.pausedMaxWaitMs =
|
|
2919
|
-
this.stuckQueuedMs =
|
|
2920
|
-
this.now =
|
|
2921
|
-
this.fileSyncDirectories =
|
|
2922
|
-
this.homeDir =
|
|
3418
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
3419
|
+
this.sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3420
|
+
this.pausedPollIntervalMs = config.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
3421
|
+
this.pausedMaxWaitMs = config.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
3422
|
+
this.stuckQueuedMs = config.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
3423
|
+
this.now = config.now ?? (() => Date.now());
|
|
3424
|
+
this.fileSyncDirectories = config.fileSyncDirectories ?? [];
|
|
3425
|
+
this.homeDir = config.homeDir ?? homedir2();
|
|
2923
3426
|
}
|
|
2924
3427
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
2925
3428
|
get opencodeBase() {
|
|
@@ -3154,6 +3657,12 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3154
3657
|
skippedAlreadyDispatched += 1;
|
|
3155
3658
|
continue;
|
|
3156
3659
|
}
|
|
3660
|
+
if (message.opencode_message_id) {
|
|
3661
|
+
const outcome = await this.resolveRedrive(conv, sessionId, message, refusedSessionId);
|
|
3662
|
+
if (outcome !== "dispatch") {
|
|
3663
|
+
break;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3157
3666
|
const options = {
|
|
3158
3667
|
agent: message.opencode_agent ?? void 0,
|
|
3159
3668
|
model: message.opencode_model ?? void 0
|
|
@@ -3236,12 +3745,344 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3236
3745
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
3237
3746
|
this.log({
|
|
3238
3747
|
level: "warn",
|
|
3239
|
-
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
3240
|
-
conversation_id: conv.id
|
|
3748
|
+
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
3749
|
+
conversation_id: conv.id
|
|
3750
|
+
});
|
|
3751
|
+
}
|
|
3752
|
+
this.ensureWatcherRunning(sessionId);
|
|
3753
|
+
return dispatched;
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Poll a session's message list for the re-drive fence (#965), via the
|
|
3757
|
+
* INJECTED `fetchImpl` — NOT the imported `getSessionMessages` helper, which
|
|
3758
|
+
* hits the global `fetch` and would bypass the same override every other
|
|
3759
|
+
* opencode poll in this file respects. Mirrors `readoptProcessing`'s own
|
|
3760
|
+
* snapshot fetch (`:3081-3111`).
|
|
3761
|
+
*
|
|
3762
|
+
* Returns `{ ok: true, messages }` on a readable snapshot, or
|
|
3763
|
+
* `{ ok: false, signature }` on failure — `signature` is a string that
|
|
3764
|
+
* repeats across attempts for the SAME underlying fault (used by the
|
|
3765
|
+
* consecutive-identical-failure bound, #1348), or `null` for a thrown
|
|
3766
|
+
* exception, which is NOT countable toward that bound (a network blip / an
|
|
3767
|
+
* opencode restart also throws identically every tick, and must keep
|
|
3768
|
+
* retrying unbounded rather than ever being treated as permanent).
|
|
3769
|
+
*/
|
|
3770
|
+
async pollSessionMessagesForRedrive(conv, message, sessionId) {
|
|
3771
|
+
try {
|
|
3772
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
3773
|
+
if (!res.ok) {
|
|
3774
|
+
const rawBody = await res.text();
|
|
3775
|
+
const normalized = normalizeRedrivePollFailureBody(rawBody);
|
|
3776
|
+
this.log({
|
|
3777
|
+
level: "warn",
|
|
3778
|
+
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned HTTP ${res.status}${normalized ? `: ${normalized}` : ""} \u2014 treating as unreadable this tick`,
|
|
3779
|
+
conversation_id: conv.id,
|
|
3780
|
+
message_id: message.id
|
|
3781
|
+
});
|
|
3782
|
+
return { ok: false, signature: `HTTP ${res.status}${normalized ? `: ${normalized}` : ""}` };
|
|
3783
|
+
}
|
|
3784
|
+
const body = await res.json();
|
|
3785
|
+
if (!Array.isArray(body)) {
|
|
3786
|
+
this.log({
|
|
3787
|
+
level: "warn",
|
|
3788
|
+
message: `Re-drive: polling session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} returned a non-array message body \u2014 treating as unreadable this tick`,
|
|
3789
|
+
conversation_id: conv.id,
|
|
3790
|
+
message_id: message.id
|
|
3791
|
+
});
|
|
3792
|
+
return { ok: false, signature: "non-array message body" };
|
|
3793
|
+
}
|
|
3794
|
+
return { ok: true, messages: body };
|
|
3795
|
+
} catch (err) {
|
|
3796
|
+
this.log({
|
|
3797
|
+
level: "warn",
|
|
3798
|
+
message: `Re-drive: failed to poll session ${sessionId.slice(0, 8)} for message ${message.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3799
|
+
conversation_id: conv.id,
|
|
3800
|
+
message_id: message.id
|
|
3801
|
+
});
|
|
3802
|
+
return { ok: false, signature: null };
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
/**
|
|
3806
|
+
* The re-drive fence for a `pending` row that already carries a stored
|
|
3807
|
+
* `opencode_message_id` (#965) — i.e. it has already been handed to opencode at
|
|
3808
|
+
* least once (see the invariant at `QueuedMessage.opencode_message_id`'s doc).
|
|
3809
|
+
* The lifecycle cron can falsely reclaim a `processing` row back to `pending`
|
|
3810
|
+
* mid-turn (a 5-minute liveness-staleness check racing a still-running turn);
|
|
3811
|
+
* without this fence the drain loop would re-`prompt_async` the SAME turn a
|
|
3812
|
+
* second time against live GitHub state. Mirrors `readoptOne`'s job for the
|
|
3813
|
+
* `processing` re-adopt path, but simpler: no b1/b2 preamble cross-check is
|
|
3814
|
+
* needed here because `refusedSessionId` already handles the one case
|
|
3815
|
+
* (#553 abandoned session) that path exists for.
|
|
3816
|
+
*
|
|
3817
|
+
* Only `ChannelAuthError` propagates. A poll that fails identically
|
|
3818
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row reports the message
|
|
3819
|
+
* failed instead of retrying it (#1348) — SEPARATE from, not a replacement
|
|
3820
|
+
* for, `resolveRedriveUnresolved`'s own `pausedMaxWaitMs` bound below. Every
|
|
3821
|
+
* other failure resolves to `unresolved` and is retried whole on the next
|
|
3822
|
+
* ~2s drain tick.
|
|
3823
|
+
*/
|
|
3824
|
+
async resolveRedrive(conv, sessionId, message, refusedSessionId) {
|
|
3825
|
+
const ocId = message.opencode_message_id ?? null;
|
|
3826
|
+
if (refusedSessionId) {
|
|
3827
|
+
this.clearRedriveUnresolved(message.id);
|
|
3828
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3829
|
+
return "dispatch";
|
|
3830
|
+
}
|
|
3831
|
+
const polled = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
|
|
3832
|
+
if (!polled.ok) {
|
|
3833
|
+
const streak = this.recordRedrivePollFailure(message.id, sessionId, polled.signature);
|
|
3834
|
+
if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES && polled.signature !== null) {
|
|
3835
|
+
return this.failRedrivePollPermanent(conv, sessionId, message, polled.signature, streak);
|
|
3836
|
+
}
|
|
3837
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3838
|
+
}
|
|
3839
|
+
this.redrivePollFailures.delete(message.id);
|
|
3840
|
+
const messages = polled.messages;
|
|
3841
|
+
if (messages.length === 0) {
|
|
3842
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3843
|
+
}
|
|
3844
|
+
const state = messageRunState(messages, ocId ?? "");
|
|
3845
|
+
if (state === "failed" && isAbortedTerminalReply(messages, ocId ?? "")) {
|
|
3846
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
3847
|
+
if (ongoing === false) {
|
|
3848
|
+
this.log({
|
|
3849
|
+
level: "info",
|
|
3850
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
3851
|
+
conversation_id: conv.id,
|
|
3852
|
+
message_id: message.id
|
|
3853
|
+
});
|
|
3854
|
+
this.clearRedriveUnresolved(message.id);
|
|
3855
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3856
|
+
return "dispatch";
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
if (state === "done" || state === "failed") {
|
|
3860
|
+
return this.settleRedrive(conv, sessionId, message, ocId, messages, state);
|
|
3861
|
+
}
|
|
3862
|
+
if (state === "running" || state === "queued") {
|
|
3863
|
+
const ongoing = await isSessionOngoing(this.port, sessionId);
|
|
3864
|
+
if (ongoing === true) {
|
|
3865
|
+
return this.reattachRedrive(conv, sessionId, message, ocId);
|
|
3866
|
+
}
|
|
3867
|
+
if (ongoing === false) {
|
|
3868
|
+
this.clearRedriveUnresolved(message.id);
|
|
3869
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3870
|
+
return "dispatch";
|
|
3871
|
+
}
|
|
3872
|
+
return this.resolveRedriveUnresolved(conv, message);
|
|
3873
|
+
}
|
|
3874
|
+
this.clearRedriveUnresolved(message.id);
|
|
3875
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
3876
|
+
return "dispatch";
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* The `reattached` outcome (Task 3.3): the prior turn is STILL ONGOING per
|
|
3880
|
+
* opencode's own status map — undo the false reclaim instead of starting a
|
|
3881
|
+
* second turn.
|
|
3882
|
+
*/
|
|
3883
|
+
async reattachRedrive(conv, sessionId, message, ocId) {
|
|
3884
|
+
let anchorMs;
|
|
3885
|
+
const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
|
|
3886
|
+
if (!Number.isNaN(parsed)) {
|
|
3887
|
+
anchorMs = parsed;
|
|
3888
|
+
} else {
|
|
3889
|
+
anchorMs = this.now();
|
|
3890
|
+
this.log({
|
|
3891
|
+
level: "error",
|
|
3892
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} has null/unparseable processing_started_at (${String(message.processing_started_at)}) \u2014 anchoring the watcher's absolute-age ceiling to now (defensive)`,
|
|
3893
|
+
conversation_id: conv.id,
|
|
3894
|
+
message_id: message.id
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
const title = await this.resolveSessionTitle(sessionId, conv.id);
|
|
3898
|
+
try {
|
|
3899
|
+
await this.markProcessing(conv.id, message.id, sessionId, ocId, title);
|
|
3900
|
+
} catch (err) {
|
|
3901
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
3902
|
+
this.log({
|
|
3903
|
+
level: "warn",
|
|
3904
|
+
message: `Re-drive: failed to restore message ${message.id.slice(0, 8)} to processing (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3905
|
+
conversation_id: conv.id,
|
|
3906
|
+
message_id: message.id
|
|
3907
|
+
});
|
|
3908
|
+
this.signalRedriveOutcomeUnreported(conv, message, "reattach");
|
|
3909
|
+
return "unresolved";
|
|
3910
|
+
}
|
|
3911
|
+
this.clearRedriveUnresolved(message.id);
|
|
3912
|
+
this.registerReadopted(conv, sessionId, message, ocId ?? "", anchorMs);
|
|
3913
|
+
this.dispatched.add(message.id);
|
|
3914
|
+
this.readopted.add(message.id);
|
|
3915
|
+
this.ensureWatcherRunning(sessionId);
|
|
3916
|
+
const watchedForMs = this.now() - anchorMs;
|
|
3917
|
+
void this.postSignal(conv.id, message.id, "redrive_reattached", {
|
|
3918
|
+
watched_for_ms: watchedForMs
|
|
3919
|
+
});
|
|
3920
|
+
this.log({
|
|
3921
|
+
level: "warn",
|
|
3922
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) was wrongly reclaimed to pending while its turn was still running (watched ${watchedForMs}ms) \u2014 restored to processing instead of re-dispatching`,
|
|
3923
|
+
conversation_id: conv.id,
|
|
3924
|
+
message_id: message.id
|
|
3925
|
+
});
|
|
3926
|
+
return "reattached";
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* The `settled` outcome (Task 3.2): the prior turn already finished (or
|
|
3930
|
+
* errored) while nobody was watching — deliver/report it instead of re-running.
|
|
3931
|
+
* Mirrors `readoptOne`'s `done`/`failed` branches' error discipline, simplified
|
|
3932
|
+
* (no `doneUndeliverable` park: a terminal PATCH failure here just retries next
|
|
3933
|
+
* drain, same as any other non-auth failure). The restart-abort carve-out that
|
|
3934
|
+
* keeps the two in step for `failed` lives in the caller (`resolveRedrive`, #1310),
|
|
3935
|
+
* so a row reaching this `failed` branch is a GENUINE failure.
|
|
3936
|
+
*/
|
|
3937
|
+
async settleRedrive(conv, sessionId, message, ocId, messages, state) {
|
|
3938
|
+
try {
|
|
3939
|
+
if (state === "done") {
|
|
3940
|
+
const title = await this.resolveSessionTitle(sessionId, conv.id);
|
|
3941
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3942
|
+
this.log({
|
|
3943
|
+
level: "info",
|
|
3944
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} completed while its row was wrongly reclaimed to pending \u2014 marking done instead of re-dispatching`,
|
|
3945
|
+
conversation_id: conv.id,
|
|
3946
|
+
message_id: message.id
|
|
3947
|
+
});
|
|
3948
|
+
await this.markDone(conv.id, message.id, sessionId, ocId, title, usage);
|
|
3949
|
+
} else {
|
|
3950
|
+
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3951
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3952
|
+
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
3953
|
+
this.log({
|
|
3954
|
+
level: "error",
|
|
3955
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} errored while its row was wrongly reclaimed to pending \u2014 marking failed instead of re-dispatching: ${error2 ?? "(no error text)"}`,
|
|
3956
|
+
conversation_id: conv.id,
|
|
3957
|
+
message_id: message.id
|
|
3958
|
+
});
|
|
3959
|
+
await this.markFailed(conv.id, message.id, sessionId, error2, usage, failure);
|
|
3960
|
+
}
|
|
3961
|
+
} catch (err) {
|
|
3962
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
3963
|
+
this.log({
|
|
3964
|
+
level: "warn",
|
|
3965
|
+
message: `Re-drive: failed to report message ${message.id.slice(0, 8)} ${state} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3966
|
+
conversation_id: conv.id,
|
|
3967
|
+
message_id: message.id
|
|
3968
|
+
});
|
|
3969
|
+
this.signalRedriveOutcomeUnreported(conv, message, "settle");
|
|
3970
|
+
return "unresolved";
|
|
3971
|
+
}
|
|
3972
|
+
this.clearRedriveUnresolved(message.id);
|
|
3973
|
+
void this.postSignal(conv.id, message.id, "redrive_settled");
|
|
3974
|
+
return "settled";
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* The permanent-failure outcome (#1348): the fence's own poll of this session
|
|
3978
|
+
* failed with the SAME opencode-answered signature
|
|
3979
|
+
* `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row — a transient blip
|
|
3980
|
+
* would have varied or eventually cleared (see `pollSessionMessagesForRedrive`
|
|
3981
|
+
* and `recordRedrivePollFailure`), so this is a durable fault (e.g. #1345's
|
|
3982
|
+
* corrupted opencode session) rather than something worth retrying forever.
|
|
3983
|
+
* Mirrors `settleRedrive`'s error discipline: no `usage`/`failure` args to
|
|
3984
|
+
* `markFailed` (no opencode snapshot to extract them from — this poll never
|
|
3985
|
+
* got a readable one).
|
|
3986
|
+
*/
|
|
3987
|
+
async failRedrivePollPermanent(conv, sessionId, message, signature, streak) {
|
|
3988
|
+
this.log({
|
|
3989
|
+
level: "error",
|
|
3990
|
+
message: `Re-drive: message ${message.id.slice(0, 8)} (session ${sessionId.slice(0, 8)}) failed to poll with the identical signature "${signature}" ${streak} times in a row \u2014 reporting the message failed instead of retrying forever`,
|
|
3991
|
+
conversation_id: conv.id,
|
|
3992
|
+
message_id: message.id
|
|
3993
|
+
});
|
|
3994
|
+
try {
|
|
3995
|
+
await this.markFailed(
|
|
3996
|
+
conv.id,
|
|
3997
|
+
message.id,
|
|
3998
|
+
sessionId,
|
|
3999
|
+
`The runner could not read this conversation's state from OpenCode (${signature}). The same failure repeated ${streak} times in a row, so the message was not retried further.`
|
|
4000
|
+
);
|
|
4001
|
+
} catch (err) {
|
|
4002
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
4003
|
+
this.log({
|
|
4004
|
+
level: "warn",
|
|
4005
|
+
message: `Re-drive: failed to report message ${message.id.slice(0, 8)} permanently failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
4006
|
+
conversation_id: conv.id,
|
|
4007
|
+
message_id: message.id
|
|
3241
4008
|
});
|
|
4009
|
+
this.signalRedriveOutcomeUnreported(conv, message, "fail_permanent");
|
|
4010
|
+
return "unresolved";
|
|
3242
4011
|
}
|
|
3243
|
-
this.
|
|
3244
|
-
|
|
4012
|
+
this.clearRedriveUnresolved(message.id);
|
|
4013
|
+
void this.postSignal(conv.id, message.id, "redrive_poll_failed");
|
|
4014
|
+
return "settled";
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* The bounded `unresolved` outcome (Task 3.4): opencode's state could not be
|
|
4018
|
+
* observed (snapshot unreadable/empty, or `isSessionOngoing` returned `null`).
|
|
4019
|
+
* A `pending` row is invisible to every cron arm (all require `status =
|
|
4020
|
+
* 'processing'`), so an indefinitely-`unresolved` row would be stranded with
|
|
4021
|
+
* nothing driving it — bound it to the existing `pausedMaxWaitMs` window
|
|
4022
|
+
* (reusing the knob, not a new constant) and take `dispatch` once elapsed.
|
|
4023
|
+
*/
|
|
4024
|
+
resolveRedriveUnresolved(conv, message) {
|
|
4025
|
+
const now = this.now();
|
|
4026
|
+
const since = this.redriveUnresolvedSince.get(message.id);
|
|
4027
|
+
if (since !== void 0 && now - since >= this.pausedMaxWaitMs) {
|
|
4028
|
+
this.clearRedriveUnresolved(message.id);
|
|
4029
|
+
void this.postSignal(conv.id, message.id, "redrive_redispatched");
|
|
4030
|
+
return "dispatch";
|
|
4031
|
+
}
|
|
4032
|
+
if (since === void 0) {
|
|
4033
|
+
this.redriveUnresolvedSince.set(message.id, now);
|
|
4034
|
+
}
|
|
4035
|
+
if (!this.redriveUnresolvedSignalled.has(message.id)) {
|
|
4036
|
+
this.redriveUnresolvedSignalled.add(message.id);
|
|
4037
|
+
void this.postSignal(conv.id, message.id, "redrive_unresolved");
|
|
4038
|
+
}
|
|
4039
|
+
return "unresolved";
|
|
4040
|
+
}
|
|
4041
|
+
/** Clear all `unresolved`/failure-streak trackers for a row (any non-`unresolved` outcome). */
|
|
4042
|
+
clearRedriveUnresolved(messageId) {
|
|
4043
|
+
this.redriveUnresolvedSince.delete(messageId);
|
|
4044
|
+
this.redriveUnresolvedSignalled.delete(messageId);
|
|
4045
|
+
this.redrivePollFailures.delete(messageId);
|
|
4046
|
+
this.redriveOutcomeUnreportedSignalled.delete(messageId);
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* Class B (#1340): the runner DECIDED an outcome (reattach/settle/fail_permanent)
|
|
4050
|
+
* but its own PATCH to record it failed. Deliberately NOT bounded like
|
|
4051
|
+
* `resolveRedriveUnresolved`, for two load-bearing reasons: (1) the only
|
|
4052
|
+
* remedy is a PATCH to the server — the very call that is failing, so a bound
|
|
4053
|
+
* would act by retrying it; (2) this class is self-clearing by construction
|
|
4054
|
+
* (the next successful PATCH resolves it via `clearRedriveUnresolved`),
|
|
4055
|
+
* unlike #1340's deterministic, permanent shape. Fires at most once per
|
|
4056
|
+
* (message, outcome) streak.
|
|
4057
|
+
*/
|
|
4058
|
+
signalRedriveOutcomeUnreported(conv, message, outcome) {
|
|
4059
|
+
if (this.redriveOutcomeUnreportedSignalled.get(message.id) === outcome) return;
|
|
4060
|
+
this.redriveOutcomeUnreportedSignalled.set(message.id, outcome);
|
|
4061
|
+
void this.postSignal(conv.id, message.id, "redrive_outcome_unreported", {
|
|
4062
|
+
attempted_outcome: outcome
|
|
4063
|
+
});
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Record one poll outcome toward the re-drive fence's consecutive-identical-
|
|
4067
|
+
* failure streak (#1348) and return the resulting count. `signature === null`
|
|
4068
|
+
* (a thrown exception, H1) always clears the streak and returns `0` — it is
|
|
4069
|
+
* never countable. Otherwise the streak continues only when BOTH the session
|
|
4070
|
+
* and the signature match the previous failure; anything else (a different
|
|
4071
|
+
* session, or the same session failing a DIFFERENT way) starts a fresh streak
|
|
4072
|
+
* at `1`.
|
|
4073
|
+
*/
|
|
4074
|
+
recordRedrivePollFailure(messageId, sessionId, signature) {
|
|
4075
|
+
if (signature === null) {
|
|
4076
|
+
this.redrivePollFailures.delete(messageId);
|
|
4077
|
+
return 0;
|
|
4078
|
+
}
|
|
4079
|
+
const existing = this.redrivePollFailures.get(messageId);
|
|
4080
|
+
if (existing && existing.sessionId === sessionId && existing.signature === signature) {
|
|
4081
|
+
existing.count += 1;
|
|
4082
|
+
return existing.count;
|
|
4083
|
+
}
|
|
4084
|
+
this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
|
|
4085
|
+
return 1;
|
|
3245
4086
|
}
|
|
3246
4087
|
/**
|
|
3247
4088
|
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
@@ -3526,9 +4367,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
3526
4367
|
* opencode reports ACTIVELY `running` is watched to completion (its liveness
|
|
3527
4368
|
* heartbeat keeps the cron off its row), while a re-adopted turn that is paused
|
|
3528
4369
|
* awaiting a human — or queued/unreachable — is still bounded by `deadline` and
|
|
3529
|
-
* handed to the cron.
|
|
3530
|
-
*
|
|
3531
|
-
*
|
|
4370
|
+
* handed to the cron. Real invariant (#965): the cron MAY reclaim a row this
|
|
4371
|
+
* runner still holds; a reclaimed row that already ran is never re-dispatched
|
|
4372
|
+
* while opencode reports its turn ongoing (readopt's own gate here, and the
|
|
4373
|
+
* `pending`-row re-drive fence, `resolveRedrive`). `dispatchedAt` stays `now`
|
|
3532
4374
|
* (only the appear-guard uses it).
|
|
3533
4375
|
*
|
|
3534
4376
|
* `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
|
|
@@ -4088,7 +4930,10 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4088
4930
|
* re-dispatched (at most once, see `forceReadoptRun`):
|
|
4089
4931
|
* - `done` → `markDone` now (guarded like the watcher's done branch);
|
|
4090
4932
|
* - `failed` → `markFailed` with the surfaced error (issue #182), so an
|
|
4091
|
-
* errored turn is reported failed on restart, NOT re-dispatched
|
|
4933
|
+
* errored turn is reported failed on restart, NOT re-dispatched —
|
|
4934
|
+
* EXCEPT a restart-ABORTED turn under a not-ongoing session,
|
|
4935
|
+
* which is a restart orphan wearing a terminal error and is
|
|
4936
|
+
* re-dispatched instead (issue #1310, see the branch below);
|
|
4092
4937
|
* - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
|
|
4093
4938
|
* tracking the stored id so the reply correlates by it;
|
|
4094
4939
|
* - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
|
|
@@ -4152,7 +4997,16 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4152
4997
|
void this.postSignal(row.conversation_id, row.id, "readopt_done");
|
|
4153
4998
|
return;
|
|
4154
4999
|
}
|
|
4155
|
-
|
|
5000
|
+
const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
|
|
5001
|
+
if (restartAborted) {
|
|
5002
|
+
this.log({
|
|
5003
|
+
level: "info",
|
|
5004
|
+
message: `Re-adopt: message ${row.id.slice(0, 8)} carries an abort-shaped terminal error and session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status \u2014 restart orphan, re-dispatching instead of marking it permanently failed`,
|
|
5005
|
+
conversation_id: row.conversation_id,
|
|
5006
|
+
message_id: row.id
|
|
5007
|
+
});
|
|
5008
|
+
}
|
|
5009
|
+
if (state === "failed" && !restartAborted) {
|
|
4156
5010
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
4157
5011
|
const usage = messageUsage(messages, ocId ?? "");
|
|
4158
5012
|
const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
|
|
@@ -4430,7 +5284,8 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
4430
5284
|
opencode_model: row.opencode_model,
|
|
4431
5285
|
source_message_id: row.source_message_id,
|
|
4432
5286
|
slack_user_id: row.slack_user_id,
|
|
4433
|
-
attachments: row.attachments ?? null
|
|
5287
|
+
attachments: row.attachments ?? null,
|
|
5288
|
+
opencode_message_id: row.opencode_message_id
|
|
4434
5289
|
};
|
|
4435
5290
|
}
|
|
4436
5291
|
/**
|
|
@@ -5022,11 +5877,11 @@ var ChannelDriver = class _ChannelDriver {
|
|
|
5022
5877
|
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
5023
5878
|
* stick.
|
|
5024
5879
|
*/
|
|
5025
|
-
sessionIdBody(sessionId, conversationId, messageId,
|
|
5880
|
+
sessionIdBody(sessionId, conversationId, messageId, status2) {
|
|
5026
5881
|
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
5027
5882
|
this.log({
|
|
5028
5883
|
level: "debug",
|
|
5029
|
-
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${
|
|
5884
|
+
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)}`,
|
|
5030
5885
|
conversation_id: conversationId,
|
|
5031
5886
|
message_id: messageId
|
|
5032
5887
|
});
|
|
@@ -5472,143 +6327,6 @@ Port ${port} is already in use.`));
|
|
|
5472
6327
|
return { port, process: null, version: null, notReadyReason: "you chose to continue without it" };
|
|
5473
6328
|
}
|
|
5474
6329
|
|
|
5475
|
-
// src/commands/agent-lookup.ts
|
|
5476
|
-
async function readErrorMessage(response) {
|
|
5477
|
-
const text = await response.text().catch(() => "");
|
|
5478
|
-
if (!text) return response.statusText || void 0;
|
|
5479
|
-
try {
|
|
5480
|
-
const data = JSON.parse(text);
|
|
5481
|
-
const message = data.message ?? data.error;
|
|
5482
|
-
if (typeof message === "string" && message.trim()) {
|
|
5483
|
-
return message;
|
|
5484
|
-
}
|
|
5485
|
-
} catch {
|
|
5486
|
-
}
|
|
5487
|
-
return text.trim() || response.statusText || void 0;
|
|
5488
|
-
}
|
|
5489
|
-
function authFailureHint(apiUrl, serverMessage) {
|
|
5490
|
-
const reason = serverMessage ? `: ${serverMessage}` : "";
|
|
5491
|
-
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.`;
|
|
5492
|
-
}
|
|
5493
|
-
async function resolveAgentIdFromKey(authHeader) {
|
|
5494
|
-
const apiUrl = getApiUrlConfig();
|
|
5495
|
-
try {
|
|
5496
|
-
const response = await fetch(`${apiUrl}/me`, {
|
|
5497
|
-
headers: { Authorization: authHeader }
|
|
5498
|
-
});
|
|
5499
|
-
if (response.status === 401) {
|
|
5500
|
-
const serverMessage = await readErrorMessage(response);
|
|
5501
|
-
return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
5502
|
-
}
|
|
5503
|
-
if (!response.ok) {
|
|
5504
|
-
const serverMessage = await readErrorMessage(response);
|
|
5505
|
-
return {
|
|
5506
|
-
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5507
|
-
};
|
|
5508
|
-
}
|
|
5509
|
-
const data = await response.json();
|
|
5510
|
-
if (data.auth_type === "agent_key" && data.agent_id) {
|
|
5511
|
-
return { agent_id: data.agent_id };
|
|
5512
|
-
}
|
|
5513
|
-
return {
|
|
5514
|
-
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
|
|
5515
|
-
};
|
|
5516
|
-
} catch (error2) {
|
|
5517
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5518
|
-
return { error: `Failed to resolve runner from key: ${message}` };
|
|
5519
|
-
}
|
|
5520
|
-
}
|
|
5521
|
-
var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
|
|
5522
|
-
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
5523
|
-
const apiUrl = getApiUrlConfig();
|
|
5524
|
-
try {
|
|
5525
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
5526
|
-
method: "POST",
|
|
5527
|
-
headers: { Authorization: authHeader },
|
|
5528
|
-
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5529
|
-
});
|
|
5530
|
-
if (!response.ok) {
|
|
5531
|
-
const serverMessage = await readErrorMessage(response);
|
|
5532
|
-
return {
|
|
5533
|
-
ok: false,
|
|
5534
|
-
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5535
|
-
};
|
|
5536
|
-
}
|
|
5537
|
-
return { ok: true };
|
|
5538
|
-
} catch (error2) {
|
|
5539
|
-
return { ok: false, error: describeBestEffortError(error2) };
|
|
5540
|
-
}
|
|
5541
|
-
}
|
|
5542
|
-
function describeBestEffortError(error2) {
|
|
5543
|
-
const name = error2?.name;
|
|
5544
|
-
if (name === "TimeoutError" || name === "AbortError") {
|
|
5545
|
-
return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
|
|
5546
|
-
}
|
|
5547
|
-
return error2 instanceof Error ? error2.message : String(error2);
|
|
5548
|
-
}
|
|
5549
|
-
async function reportMicrovmId(agentId, authHeader, microvmId) {
|
|
5550
|
-
try {
|
|
5551
|
-
const apiUrl = getApiUrlConfig();
|
|
5552
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
|
|
5553
|
-
method: "POST",
|
|
5554
|
-
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
5555
|
-
body: JSON.stringify({ microvm_id: microvmId }),
|
|
5556
|
-
signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
|
|
5557
|
-
});
|
|
5558
|
-
if (!response.ok) {
|
|
5559
|
-
const serverMessage = await readErrorMessage(response);
|
|
5560
|
-
return {
|
|
5561
|
-
ok: false,
|
|
5562
|
-
error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5563
|
-
};
|
|
5564
|
-
}
|
|
5565
|
-
return { ok: true };
|
|
5566
|
-
} catch (error2) {
|
|
5567
|
-
return { ok: false, error: describeBestEffortError(error2) };
|
|
5568
|
-
}
|
|
5569
|
-
}
|
|
5570
|
-
async function getAgentInfo(agentId, authHeader) {
|
|
5571
|
-
const apiUrl = getApiUrlConfig();
|
|
5572
|
-
try {
|
|
5573
|
-
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
5574
|
-
headers: { Authorization: authHeader }
|
|
5575
|
-
});
|
|
5576
|
-
if (response.status === 401) {
|
|
5577
|
-
const serverMessage = await readErrorMessage(response);
|
|
5578
|
-
return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
|
|
5579
|
-
}
|
|
5580
|
-
if (response.status === 403) {
|
|
5581
|
-
const serverMessage = await readErrorMessage(response);
|
|
5582
|
-
return {
|
|
5583
|
-
valid: false,
|
|
5584
|
-
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
5585
|
-
};
|
|
5586
|
-
}
|
|
5587
|
-
if (response.status === 404) {
|
|
5588
|
-
const serverMessage = await readErrorMessage(response);
|
|
5589
|
-
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
5590
|
-
}
|
|
5591
|
-
if (!response.ok) {
|
|
5592
|
-
const serverMessage = await readErrorMessage(response);
|
|
5593
|
-
return {
|
|
5594
|
-
valid: false,
|
|
5595
|
-
error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
5596
|
-
};
|
|
5597
|
-
}
|
|
5598
|
-
const agent = await response.json();
|
|
5599
|
-
if (agent.agent_type !== "local") {
|
|
5600
|
-
return {
|
|
5601
|
-
valid: false,
|
|
5602
|
-
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
5603
|
-
};
|
|
5604
|
-
}
|
|
5605
|
-
return { valid: true, agent };
|
|
5606
|
-
} catch (error2) {
|
|
5607
|
-
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
5608
|
-
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
5609
|
-
}
|
|
5610
|
-
}
|
|
5611
|
-
|
|
5612
6330
|
// src/commands/run.ts
|
|
5613
6331
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
5614
6332
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
@@ -5645,7 +6363,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
|
|
|
5645
6363
|
if (trimmed === "") {
|
|
5646
6364
|
throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
|
|
5647
6365
|
}
|
|
5648
|
-
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ?
|
|
6366
|
+
const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
|
|
5649
6367
|
if (!isAbsolute2(expanded)) {
|
|
5650
6368
|
throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
|
|
5651
6369
|
}
|
|
@@ -5819,9 +6537,15 @@ async function handleAuthError(state, error2) {
|
|
|
5819
6537
|
}
|
|
5820
6538
|
async function driveChannels(state, driver) {
|
|
5821
6539
|
let idlePolls = 0;
|
|
6540
|
+
let idleMs = 0;
|
|
6541
|
+
let consecutiveDrainFailures = 0;
|
|
6542
|
+
let unreachableMs = 0;
|
|
5822
6543
|
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5823
6544
|
let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5824
6545
|
while (state.running) {
|
|
6546
|
+
const cycleStartedAtMs = performance.now();
|
|
6547
|
+
let idleThisCycle = false;
|
|
6548
|
+
let unreachableThisCycle = false;
|
|
5825
6549
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
5826
6550
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
5827
6551
|
if (state.interactive) displayStatus(state);
|
|
@@ -5836,17 +6560,23 @@ async function driveChannels(state, driver) {
|
|
|
5836
6560
|
);
|
|
5837
6561
|
try {
|
|
5838
6562
|
const processed = await driver.drainPending();
|
|
6563
|
+
consecutiveDrainFailures = 0;
|
|
6564
|
+
unreachableMs = 0;
|
|
5839
6565
|
state.messageCount += processed;
|
|
5840
6566
|
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
5841
6567
|
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
5842
6568
|
const appliedFiles = driver.fileSyncActivity().appliedFiles;
|
|
5843
|
-
const
|
|
6569
|
+
const filesApplied = appliedFiles !== lastSeenAppliedFiles;
|
|
6570
|
+
const fileActivity = carriedOverFileSync || filesApplied;
|
|
5844
6571
|
lastSeenAppliedFiles = appliedFiles;
|
|
6572
|
+
if (filesApplied) state.claudeUsageRearm?.();
|
|
5845
6573
|
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
|
|
5846
6574
|
idlePolls = 0;
|
|
6575
|
+
idleMs = 0;
|
|
5847
6576
|
if (processed > 0 && state.interactive) displayStatus(state);
|
|
5848
6577
|
} else if (state.idleTimeout !== null) {
|
|
5849
6578
|
idlePolls++;
|
|
6579
|
+
idleThisCycle = true;
|
|
5850
6580
|
if (idlePolls === 1) {
|
|
5851
6581
|
logActivity(state, {
|
|
5852
6582
|
type: "info",
|
|
@@ -5870,21 +6600,44 @@ async function driveChannels(state, driver) {
|
|
|
5870
6600
|
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
5871
6601
|
logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
|
|
5872
6602
|
if (state.interactive) displayStatus(state);
|
|
6603
|
+
if (driver.hasInFlightWatchers()) {
|
|
6604
|
+
consecutiveDrainFailures = 0;
|
|
6605
|
+
unreachableMs = 0;
|
|
6606
|
+
} else if (state.idleTimeout !== null) {
|
|
6607
|
+
consecutiveDrainFailures++;
|
|
6608
|
+
unreachableThisCycle = true;
|
|
6609
|
+
if (consecutiveDrainFailures === 1) {
|
|
6610
|
+
logActivity(state, {
|
|
6611
|
+
type: "info",
|
|
6612
|
+
message: `Cannot reach Evident, will exit if this persists past the idle timeout (timeout: ${state.idleTimeout}s)...`
|
|
6613
|
+
});
|
|
6614
|
+
if (state.interactive) displayStatus(state);
|
|
6615
|
+
}
|
|
6616
|
+
}
|
|
5873
6617
|
}
|
|
5874
6618
|
await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
6619
|
+
const cycleMs = performance.now() - cycleStartedAtMs;
|
|
6620
|
+
if (idleThisCycle) idleMs += cycleMs;
|
|
6621
|
+
if (unreachableThisCycle) unreachableMs += cycleMs;
|
|
6622
|
+
if (state.idleTimeout !== null && consecutiveDrainFailures >= 2 && unreachableMs > state.idleTimeout * 1e3) {
|
|
6623
|
+
logActivity(state, {
|
|
6624
|
+
type: "info",
|
|
6625
|
+
level: "warn",
|
|
6626
|
+
message: `Exiting: could not reach Evident for ${consecutiveDrainFailures} consecutive polls (${Math.round(unreachableMs / 1e3)}s)`
|
|
6627
|
+
});
|
|
6628
|
+
if (state.interactive) displayStatus(state);
|
|
6629
|
+
break;
|
|
6630
|
+
}
|
|
6631
|
+
if (state.idleTimeout !== null && idlePolls >= 2 && idleMs > state.idleTimeout * 1e3) {
|
|
6632
|
+
logActivity(state, { type: "info", message: "Idle timeout reached" });
|
|
6633
|
+
if (state.interactive) displayStatus(state);
|
|
6634
|
+
break;
|
|
5882
6635
|
}
|
|
5883
6636
|
}
|
|
5884
6637
|
}
|
|
5885
6638
|
var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
|
|
5886
|
-
async function runSweep(state, driver,
|
|
5887
|
-
const mode = `age=${
|
|
6639
|
+
async function runSweep(state, driver, config) {
|
|
6640
|
+
const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
|
|
5888
6641
|
try {
|
|
5889
6642
|
const sessions = await listSessions(state.port);
|
|
5890
6643
|
if (sessions === null) {
|
|
@@ -5897,8 +6650,8 @@ async function runSweep(state, driver, config2) {
|
|
|
5897
6650
|
const toDelete = selectSessionsToDelete(
|
|
5898
6651
|
sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
|
|
5899
6652
|
{
|
|
5900
|
-
maxAgeMs:
|
|
5901
|
-
maxCount:
|
|
6653
|
+
maxAgeMs: config.maxAgeMs,
|
|
6654
|
+
maxCount: config.maxCount,
|
|
5902
6655
|
nowMs: Date.now(),
|
|
5903
6656
|
protectedIds: driver.protectedSessionIds()
|
|
5904
6657
|
}
|
|
@@ -5934,7 +6687,7 @@ async function runSweep(state, driver, config2) {
|
|
|
5934
6687
|
}
|
|
5935
6688
|
}
|
|
5936
6689
|
function scheduleSessionCleanup(state, driver, options) {
|
|
5937
|
-
const
|
|
6690
|
+
const config = resolveSessionCleanupConfig(
|
|
5938
6691
|
{
|
|
5939
6692
|
maxAge: options.sessionCleanupMaxAge,
|
|
5940
6693
|
maxCount: options.sessionCleanupMaxCount,
|
|
@@ -5942,21 +6695,129 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
5942
6695
|
},
|
|
5943
6696
|
process.env
|
|
5944
6697
|
);
|
|
5945
|
-
for (const warning2 of
|
|
6698
|
+
for (const warning2 of config.warnings) {
|
|
5946
6699
|
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
5947
6700
|
}
|
|
5948
|
-
if (!
|
|
6701
|
+
if (!config.enabled) return;
|
|
5949
6702
|
logActivity(state, {
|
|
5950
6703
|
type: "info",
|
|
5951
|
-
message: `Session cleanup enabled (age=${
|
|
6704
|
+
message: `Session cleanup enabled (age=${config.maxAgeMs ?? "\u2014"}, count=${config.maxCount ?? "\u2014"}, interval=${config.intervalMs}ms)`
|
|
5952
6705
|
});
|
|
5953
|
-
const interval = setInterval(() => void runSweep(state, driver,
|
|
6706
|
+
const interval = setInterval(() => void runSweep(state, driver, config), config.intervalMs);
|
|
5954
6707
|
const firstSweep = setTimeout(
|
|
5955
|
-
() => void runSweep(state, driver,
|
|
6708
|
+
() => void runSweep(state, driver, config),
|
|
5956
6709
|
SESSION_CLEANUP_FIRST_SWEEP_MS
|
|
5957
6710
|
);
|
|
5958
6711
|
state.sessionCleanupTimers.push(interval, firstSweep);
|
|
5959
6712
|
}
|
|
6713
|
+
function claudeUsageFailureStreakSuffix(consecutiveFailures) {
|
|
6714
|
+
return consecutiveFailures > 1 ? ` (${consecutiveFailures} consecutive failures)` : "";
|
|
6715
|
+
}
|
|
6716
|
+
function scheduleClaudeUsageReporting(state, options) {
|
|
6717
|
+
const { mode, warnings } = resolveClaudeUsageReportingMode(
|
|
6718
|
+
options.claudeUsageReporting,
|
|
6719
|
+
process.env
|
|
6720
|
+
);
|
|
6721
|
+
for (const warning2 of warnings) {
|
|
6722
|
+
logActivity(state, {
|
|
6723
|
+
type: "info",
|
|
6724
|
+
level: "warn",
|
|
6725
|
+
message: `Claude usage reporting: ${warning2}`
|
|
6726
|
+
});
|
|
6727
|
+
}
|
|
6728
|
+
if (mode === "off") {
|
|
6729
|
+
logActivity(state, {
|
|
6730
|
+
type: "info",
|
|
6731
|
+
level: "debug",
|
|
6732
|
+
message: "Claude usage reporting is off (--claude-usage-reporting off)"
|
|
6733
|
+
});
|
|
6734
|
+
return null;
|
|
6735
|
+
}
|
|
6736
|
+
let consecutiveFailures = 0;
|
|
6737
|
+
let armed = false;
|
|
6738
|
+
let rearmRequested = false;
|
|
6739
|
+
const scheduleNextTick = () => {
|
|
6740
|
+
armed = true;
|
|
6741
|
+
rearmRequested = false;
|
|
6742
|
+
state.claudeUsageTimer = setTimeout(() => void tick(false), nextReportDelayMs());
|
|
6743
|
+
};
|
|
6744
|
+
const rearm = () => {
|
|
6745
|
+
if (armed) {
|
|
6746
|
+
rearmRequested = true;
|
|
6747
|
+
return;
|
|
6748
|
+
}
|
|
6749
|
+
rearmRequested = false;
|
|
6750
|
+
armed = true;
|
|
6751
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
6752
|
+
};
|
|
6753
|
+
const tick = async (isProbe) => {
|
|
6754
|
+
try {
|
|
6755
|
+
const usage = await getClaudeUsage();
|
|
6756
|
+
const result = await reportClaudeUsage(state.agentId, state.authHeader, usage);
|
|
6757
|
+
if (result.ok) {
|
|
6758
|
+
if (consecutiveFailures > 0) {
|
|
6759
|
+
logActivity(state, {
|
|
6760
|
+
type: "info",
|
|
6761
|
+
level: "info",
|
|
6762
|
+
message: "Claude usage reporting recovered"
|
|
6763
|
+
});
|
|
6764
|
+
}
|
|
6765
|
+
consecutiveFailures = 0;
|
|
6766
|
+
logActivity(state, {
|
|
6767
|
+
type: "info",
|
|
6768
|
+
level: "debug",
|
|
6769
|
+
message: "Reported Claude usage to Evident"
|
|
6770
|
+
});
|
|
6771
|
+
} else {
|
|
6772
|
+
consecutiveFailures++;
|
|
6773
|
+
logActivity(state, {
|
|
6774
|
+
type: "info",
|
|
6775
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
6776
|
+
message: `Failed to report Claude usage: ${result.error}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
6777
|
+
});
|
|
6778
|
+
}
|
|
6779
|
+
scheduleNextTick();
|
|
6780
|
+
} catch (error2) {
|
|
6781
|
+
if (error2 instanceof ClaudeUsageError && isLocalCredentialProblem(error2)) {
|
|
6782
|
+
if (mode === "on") {
|
|
6783
|
+
logActivity(state, {
|
|
6784
|
+
type: "info",
|
|
6785
|
+
level: "warn",
|
|
6786
|
+
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"
|
|
6787
|
+
});
|
|
6788
|
+
scheduleNextTick();
|
|
6789
|
+
} else if (isProbe) {
|
|
6790
|
+
logActivity(state, {
|
|
6791
|
+
type: "info",
|
|
6792
|
+
level: "debug",
|
|
6793
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6794
|
+
});
|
|
6795
|
+
armed = false;
|
|
6796
|
+
if (rearmRequested) rearm();
|
|
6797
|
+
} else {
|
|
6798
|
+
logActivity(state, {
|
|
6799
|
+
type: "info",
|
|
6800
|
+
level: "debug",
|
|
6801
|
+
message: `Claude usage reporting: ${error2.message}`
|
|
6802
|
+
});
|
|
6803
|
+
scheduleNextTick();
|
|
6804
|
+
}
|
|
6805
|
+
} else {
|
|
6806
|
+
consecutiveFailures++;
|
|
6807
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
6808
|
+
logActivity(state, {
|
|
6809
|
+
type: "info",
|
|
6810
|
+
level: claudeUsageFailureLogLevel(consecutiveFailures),
|
|
6811
|
+
message: `Claude usage reporting failed: ${message}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
|
|
6812
|
+
});
|
|
6813
|
+
scheduleNextTick();
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
};
|
|
6817
|
+
armed = true;
|
|
6818
|
+
state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
|
|
6819
|
+
return rearm;
|
|
6820
|
+
}
|
|
5960
6821
|
async function notifyOffline(state) {
|
|
5961
6822
|
if (!state.agentId || !state.authHeader) return;
|
|
5962
6823
|
if (!state.connected) {
|
|
@@ -5992,6 +6853,11 @@ async function cleanup(state, opts = {}) {
|
|
|
5992
6853
|
clearTimeout(timer);
|
|
5993
6854
|
}
|
|
5994
6855
|
state.sessionCleanupTimers = [];
|
|
6856
|
+
if (state.claudeUsageTimer) {
|
|
6857
|
+
clearTimeout(state.claudeUsageTimer);
|
|
6858
|
+
state.claudeUsageTimer = null;
|
|
6859
|
+
}
|
|
6860
|
+
state.claudeUsageRearm = null;
|
|
5995
6861
|
if (opts.graceful && state.channelDriver) {
|
|
5996
6862
|
state.channelDriver.stop();
|
|
5997
6863
|
log2(state, "Draining in-flight channel work before shutdown...");
|
|
@@ -6039,7 +6905,7 @@ async function run(options) {
|
|
|
6039
6905
|
let fileSyncDirectories;
|
|
6040
6906
|
try {
|
|
6041
6907
|
logLevel = resolveLogLevel(options);
|
|
6042
|
-
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo,
|
|
6908
|
+
fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir3());
|
|
6043
6909
|
} catch (error2) {
|
|
6044
6910
|
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
6045
6911
|
if (options.json) {
|
|
@@ -6072,6 +6938,8 @@ async function run(options) {
|
|
|
6072
6938
|
messageCount: 0,
|
|
6073
6939
|
lastProxiedActivityAt: null,
|
|
6074
6940
|
sessionCleanupTimers: [],
|
|
6941
|
+
claudeUsageTimer: null,
|
|
6942
|
+
claudeUsageRearm: null,
|
|
6075
6943
|
authHeader: ""
|
|
6076
6944
|
};
|
|
6077
6945
|
setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
|
|
@@ -6151,6 +7019,7 @@ async function run(options) {
|
|
|
6151
7019
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
6152
7020
|
blank();
|
|
6153
7021
|
process.exit(1);
|
|
7022
|
+
return;
|
|
6154
7023
|
}
|
|
6155
7024
|
blank();
|
|
6156
7025
|
console.log(chalk6.yellow("You are not logged in to Evident."));
|
|
@@ -6195,6 +7064,7 @@ async function run(options) {
|
|
|
6195
7064
|
} else {
|
|
6196
7065
|
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
6197
7066
|
process.exit(1);
|
|
7067
|
+
return;
|
|
6198
7068
|
}
|
|
6199
7069
|
} else {
|
|
6200
7070
|
printError(
|
|
@@ -6208,6 +7078,7 @@ async function run(options) {
|
|
|
6208
7078
|
);
|
|
6209
7079
|
blank();
|
|
6210
7080
|
process.exit(1);
|
|
7081
|
+
return;
|
|
6211
7082
|
}
|
|
6212
7083
|
}
|
|
6213
7084
|
telemetry.info(
|
|
@@ -6326,7 +7197,7 @@ async function run(options) {
|
|
|
6326
7197
|
// #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
|
|
6327
7198
|
// REJECTED with `file_sync_disabled` on the ack, not silently ignored.
|
|
6328
7199
|
fileSyncDirectories,
|
|
6329
|
-
homeDir:
|
|
7200
|
+
homeDir: homedir3(),
|
|
6330
7201
|
log: (entry) => (
|
|
6331
7202
|
// Thread the driver's real level straight through so `debug`/`warn`
|
|
6332
7203
|
// survive the sink filter (they no longer collapse to info). `type`
|
|
@@ -6455,6 +7326,7 @@ async function run(options) {
|
|
|
6455
7326
|
throw error2;
|
|
6456
7327
|
}
|
|
6457
7328
|
scheduleSessionCleanup(state, channelDriver, options);
|
|
7329
|
+
state.claudeUsageRearm = scheduleClaudeUsageReporting(state, options);
|
|
6458
7330
|
if (!interactive || state.json) {
|
|
6459
7331
|
log2(state, "Driving channel messages...");
|
|
6460
7332
|
}
|
|
@@ -6509,6 +7381,8 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
6509
7381
|
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);
|
|
6510
7382
|
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 }));
|
|
6511
7383
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
7384
|
+
program.command("status").description("Check whether the configured credentials can reach Evident").option("--json", "Output in JSON format").action((options) => status({ json: options.json }));
|
|
7385
|
+
program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
|
|
6512
7386
|
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(
|
|
6513
7387
|
"-a, --agent [id]",
|
|
6514
7388
|
"Deprecated alias for --runner (still supported; --runner wins if both are given)"
|
|
@@ -6527,6 +7401,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
6527
7401
|
).option(
|
|
6528
7402
|
"--session-cleanup-interval <duration>",
|
|
6529
7403
|
"How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
|
|
7404
|
+
).option(
|
|
7405
|
+
"--claude-usage-reporting <mode>",
|
|
7406
|
+
"Report Claude subscription usage to Evident: auto | on | off (default: auto). Env: EVIDENT_CLAUDE_USAGE_REPORTING"
|
|
6530
7407
|
).option(
|
|
6531
7408
|
"--enable-file-sync-to <dir>",
|
|
6532
7409
|
"Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
|
|
@@ -6534,7 +7411,7 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
6534
7411
|
[]
|
|
6535
7412
|
).option(
|
|
6536
7413
|
"--tunnel-ready-file <path>",
|
|
6537
|
-
"Path to write once the tunnel is connected (
|
|
7414
|
+
"Path to write once the tunnel is connected (opt-in; unused unless an operator/image sets it)"
|
|
6538
7415
|
).action(
|
|
6539
7416
|
(options) => {
|
|
6540
7417
|
run({
|
|
@@ -6555,6 +7432,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
6555
7432
|
sessionCleanupMaxAge: options.sessionCleanupMaxAge,
|
|
6556
7433
|
sessionCleanupMaxCount: options.sessionCleanupMaxCount,
|
|
6557
7434
|
sessionCleanupInterval: options.sessionCleanupInterval,
|
|
7435
|
+
// Raw string — the resolver in run.ts single-sources parsing
|
|
7436
|
+
// (resolveClaudeUsageReportingMode).
|
|
7437
|
+
claudeUsageReporting: options.claudeUsageReporting,
|
|
6558
7438
|
// Raw values — expansion/validation is single-sourced in run.ts's
|
|
6559
7439
|
// resolveFileSyncDirectories.
|
|
6560
7440
|
enableFileSyncTo: options.enableFileSyncTo,
|