@evident-ai/cli 3.1.1-dev.2b250ad → 3.1.1-dev.2b780b5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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: "",
@@ -417,8 +412,10 @@ async function deviceFlowLogin(options) {
417
412
  }
418
413
  async function tokenLogin() {
419
414
  console.log("Token login mode.");
420
- console.log("Run `evident login` on a machine with a browser to get a token.");
421
- console.log("Manage or revoke existing tokens under Settings \u2192 CLI tokens.");
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
+ );
422
419
  blank();
423
420
  process.stdout.write("Paste token: ");
424
421
  const token = await new Promise((resolve3) => {
@@ -442,13 +439,22 @@ async function tokenLogin() {
442
439
  printError("No token provided.");
443
440
  process.exit(1);
444
441
  }
442
+ await validateAndStoreToken(token);
443
+ }
444
+ async function validateAndStoreToken(token) {
445
445
  const spinner = ora("Validating token...").start();
446
446
  try {
447
- const result = await api.post("/auth/token/validate", { token });
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
+ }
448
455
  await storeToken({
449
456
  token,
450
- user: result.user,
451
- expiresAt: result.expires_at
457
+ user: { email: result.user.email }
452
458
  });
453
459
  spinner.stop();
454
460
  printSuccess(`Logged in as ${chalk2.bold(result.user.email)}`);
@@ -508,7 +514,9 @@ async function whoami() {
508
514
  blank();
509
515
  console.log(keyValue("Endpoint", apiUrl));
510
516
  console.log(keyValue("User", chalk3.bold(credentials2.user.email)));
511
- console.log(keyValue("User ID", credentials2.user.id));
517
+ if (credentials2.user.id) {
518
+ console.log(keyValue("User ID", credentials2.user.id));
519
+ }
512
520
  if (credentials2.expiresAt) {
513
521
  const expiresAt = new Date(credentials2.expiresAt);
514
522
  const now = /* @__PURE__ */ new Date();
@@ -524,11 +532,484 @@ async function whoami() {
524
532
  blank();
525
533
  }
526
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 === 404) {
801
+ return {
802
+ ok: false,
803
+ endpoint: apiUrl,
804
+ authLabel: authLabelFor(credentials2),
805
+ reason: "endpoint_not_found",
806
+ error: `${apiUrl}/me returned HTTP 404 \u2014 that endpoint has no /me route, so it is probably missing the /v1 prefix. The credentials were NOT validated.`,
807
+ exitCode: 75
808
+ };
809
+ }
810
+ if (response.status >= 500) {
811
+ const serverMessage = await readErrorMessage(response);
812
+ return {
813
+ ok: false,
814
+ endpoint: apiUrl,
815
+ authLabel: authLabelFor(credentials2),
816
+ reason: "unreachable",
817
+ error: `${apiUrl} returned HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}. The credentials were NOT validated.`,
818
+ exitCode: 75
819
+ };
820
+ }
821
+ if (!response.ok) {
822
+ const serverMessage = await readErrorMessage(response);
823
+ return {
824
+ ok: false,
825
+ endpoint: apiUrl,
826
+ authLabel: authLabelFor(credentials2),
827
+ reason: "http_error",
828
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`,
829
+ exitCode: 1
830
+ };
831
+ }
832
+ const data = await response.json();
833
+ return {
834
+ ok: true,
835
+ endpoint: apiUrl,
836
+ authType: data.auth_type,
837
+ authLabel: authLabelFor(credentials2),
838
+ runnerId: data.auth_type === "agent_key" ? data.agent_id : void 0,
839
+ reason: "ok",
840
+ exitCode: 0
841
+ };
842
+ }
843
+ function printJson(result) {
844
+ const payload = {
845
+ ok: result.ok,
846
+ endpoint: result.endpoint
847
+ };
848
+ if (result.authType) payload.auth_type = result.authType;
849
+ if (result.runnerId) payload.runner_id = result.runnerId;
850
+ if (result.reason) payload.reason = result.reason;
851
+ if (result.error) payload.error = result.error;
852
+ console.log(JSON.stringify(payload));
853
+ }
854
+ function printHuman(result) {
855
+ blank();
856
+ console.log(keyValue("Endpoint", result.endpoint));
857
+ if (result.ok) {
858
+ console.log(keyValue("Auth", result.authLabel ?? "\u2014"));
859
+ if (result.runnerId) {
860
+ console.log(keyValue("Runner", result.runnerId));
861
+ }
862
+ console.log(keyValue("Status", "OK \u2014 credentials accepted"));
863
+ blank();
864
+ return;
865
+ }
866
+ if (result.authLabel) {
867
+ console.log(keyValue("Auth", result.authLabel));
868
+ }
869
+ blank();
870
+ printError(result.error ?? "Unknown error");
871
+ }
872
+ async function status(options = {}) {
873
+ const result = await checkStatus(Boolean(options.json));
874
+ if (options.json) {
875
+ printJson(result);
876
+ } else {
877
+ printHuman(result);
878
+ }
879
+ process.exit(result.exitCode);
880
+ }
881
+
882
+ // src/lib/claude-usage.ts
883
+ import { execFileSync } from "child_process";
884
+ import { readFileSync } from "fs";
885
+ import { homedir } from "os";
886
+ import { join } from "path";
887
+ var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
888
+ var KEYCHAIN_SERVICE = "Claude Code-credentials";
889
+ function parseClaudeCliCredentials(raw) {
890
+ let parsed;
891
+ try {
892
+ parsed = JSON.parse(raw);
893
+ } catch {
894
+ return null;
895
+ }
896
+ const data = parsed.claudeAiOauth ?? parsed;
897
+ const creds = data;
898
+ if (typeof creds.accessToken !== "string" || typeof creds.expiresAt !== "number") {
899
+ return null;
900
+ }
901
+ return { accessToken: creds.accessToken, expiresAt: creds.expiresAt };
902
+ }
903
+ function readClaudeCliCredentials() {
904
+ if (process.platform === "darwin") {
905
+ try {
906
+ const raw = execFileSync(
907
+ "/usr/bin/security",
908
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
909
+ { encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
910
+ );
911
+ return parseClaudeCliCredentials(raw);
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+ try {
917
+ const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
918
+ return parseClaudeCliCredentials(raw);
919
+ } catch {
920
+ return null;
921
+ }
922
+ }
923
+ var ClaudeUsageError = class extends Error {
924
+ constructor(message, reason) {
925
+ super(message);
926
+ this.reason = reason;
927
+ }
928
+ };
929
+ function isLocalCredentialProblem(err) {
930
+ return err instanceof ClaudeUsageError && (err.reason === "no_credentials" || err.reason === "credentials_expired");
931
+ }
932
+ function normalizeResetsAt(value) {
933
+ const ms = Date.parse(value);
934
+ return Number.isNaN(ms) ? null : new Date(ms).toISOString();
935
+ }
936
+ function toWindow(value) {
937
+ if (!value || typeof value !== "object") {
938
+ return null;
939
+ }
940
+ const window = value;
941
+ if (typeof window.utilization !== "number" || typeof window.resets_at !== "string") {
942
+ return null;
943
+ }
944
+ const resetsAt = normalizeResetsAt(window.resets_at);
945
+ if (resetsAt === null) {
946
+ return null;
947
+ }
948
+ return { utilization: window.utilization, resetsAt };
949
+ }
950
+ async function getClaudeUsage() {
951
+ const credentials2 = readClaudeCliCredentials();
952
+ if (!credentials2) {
953
+ throw new ClaudeUsageError(
954
+ "No local Claude Code login found. Run `claude` once to sign in with your Claude subscription.",
955
+ "no_credentials"
956
+ );
957
+ }
958
+ if (credentials2.expiresAt < Date.now()) {
959
+ throw new ClaudeUsageError(
960
+ "Claude Code credentials have expired. Run `claude` to refresh them.",
961
+ "credentials_expired"
962
+ );
963
+ }
964
+ const res = await fetch(CLAUDE_USAGE_URL, {
965
+ headers: {
966
+ Authorization: `Bearer ${credentials2.accessToken}`,
967
+ "Content-Type": "application/json",
968
+ "anthropic-version": "2023-06-01"
969
+ }
970
+ });
971
+ if (!res.ok) {
972
+ throw new ClaudeUsageError(`Claude usage request failed: HTTP ${res.status}`, "request_failed");
973
+ }
974
+ const body = await res.json();
975
+ return {
976
+ fiveHour: toWindow(body.five_hour),
977
+ sevenDay: toWindow(body.seven_day)
978
+ };
979
+ }
980
+
981
+ // src/commands/claude-usage.ts
982
+ function formatWindow(label, window) {
983
+ if (!window) {
984
+ return keyValue(label, "not available for this plan");
985
+ }
986
+ const resetsAt = new Date(window.resetsAt);
987
+ return keyValue(label, `${window.utilization}% used, resets ${resetsAt.toLocaleString()}`);
988
+ }
989
+ async function claudeUsage() {
990
+ try {
991
+ const usage = await getClaudeUsage();
992
+ blank();
993
+ console.log(formatWindow("5-hour session", usage.fiveHour));
994
+ console.log(formatWindow("7-day", usage.sevenDay));
995
+ blank();
996
+ } catch (err) {
997
+ if (err instanceof ClaudeUsageError) {
998
+ printError(err.message);
999
+ process.exit(1);
1000
+ }
1001
+ throw err;
1002
+ }
1003
+ }
1004
+
527
1005
  // src/commands/run.ts
528
- import { homedir as homedir2 } from "os";
529
- import { isAbsolute as isAbsolute2, join as join2, parse, resolve as resolvePath } from "path";
1006
+ import { homedir as homedir3 } from "os";
1007
+ import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
530
1008
  import chalk6 from "chalk";
531
1009
 
1010
+ // ../../packages/types/src/agents/index.ts
1011
+ var MICROVM_MAX_LIFETIME_MS = 8 * 60 * 6e4;
1012
+
532
1013
  // ../../packages/types/src/telemetry/index.ts
533
1014
  var TelemetryEventTypes = {
534
1015
  // Agent activity events (shown in web UI activity log)
@@ -536,7 +1017,10 @@ var TelemetryEventTypes = {
536
1017
  AGENT_DISCONNECTED: "agent.disconnected",
537
1018
  AGENT_MESSAGE_PROCESSING: "agent.message_processing",
538
1019
  AGENT_MESSAGE_DONE: "agent.message_done",
539
- AGENT_MESSAGE_FAILED: "agent.message_failed"
1020
+ AGENT_MESSAGE_FAILED: "agent.message_failed",
1021
+ // A `warn`/`error` runner-side log line forwarded server-side for
1022
+ // observability (issue #916) — see `apps/cli/src/lib/runner-activity-telemetry.ts`.
1023
+ RUNNER_ACTIVITY: "runner.activity"
540
1024
  };
541
1025
 
542
1026
  // ../../packages/types/src/tunnel/index.ts
@@ -591,6 +1075,13 @@ var isShuttingDown = false;
591
1075
  var FLUSH_INTERVAL_MS = 5e3;
592
1076
  var MAX_BUFFER_SIZE = 50;
593
1077
  var FLUSH_TIMEOUT_MS = 3e3;
1078
+ var authProvider = null;
1079
+ function setTelemetryAuthProvider(provider) {
1080
+ authProvider = provider;
1081
+ }
1082
+ var FLUSH_FAILURE_LOG_INTERVAL_MS = 6e4;
1083
+ var lastFlushFailureLoggedAt = 0;
1084
+ var suppressedFlushFailureCount = 0;
594
1085
  function logEvent(eventType, options = {}) {
595
1086
  const event = {
596
1087
  event_type: eventType,
@@ -625,9 +1116,16 @@ async function flushEvents() {
625
1116
  flushTimeout = null;
626
1117
  }
627
1118
  try {
628
- const credentials2 = await getToken();
629
- if (!credentials2) {
630
- return;
1119
+ const providerContext = authProvider?.();
1120
+ let authHeader;
1121
+ if (providerContext?.authHeader) {
1122
+ authHeader = providerContext.authHeader;
1123
+ } else {
1124
+ const credentials2 = await getToken();
1125
+ if (!credentials2) {
1126
+ return;
1127
+ }
1128
+ authHeader = `Bearer ${credentials2.token}`;
631
1129
  }
632
1130
  const apiUrl = getApiUrlConfig();
633
1131
  const controller = new AbortController();
@@ -642,7 +1140,7 @@ async function flushEvents() {
642
1140
  method: "POST",
643
1141
  headers: {
644
1142
  "Content-Type": "application/json",
645
- Authorization: `Bearer ${credentials2.token}`
1143
+ Authorization: authHeader
646
1144
  },
647
1145
  body: JSON.stringify(request),
648
1146
  signal: controller.signal
@@ -654,8 +1152,15 @@ async function flushEvents() {
654
1152
  clearTimeout(timeout);
655
1153
  }
656
1154
  } catch (error2) {
657
- if (process.env.DEBUG) {
658
- console.error("Telemetry error:", error2);
1155
+ const now = Date.now();
1156
+ if (now - lastFlushFailureLoggedAt >= FLUSH_FAILURE_LOG_INTERVAL_MS) {
1157
+ const message = error2 instanceof Error ? error2.message : String(error2);
1158
+ const suffix = suppressedFlushFailureCount > 0 ? ` (${suppressedFlushFailureCount} more suppressed in the last ${FLUSH_FAILURE_LOG_INTERVAL_MS / 1e3}s)` : "";
1159
+ console.error(`Telemetry flush error: ${message}${suffix}`);
1160
+ lastFlushFailureLoggedAt = now;
1161
+ suppressedFlushFailureCount = 0;
1162
+ } else {
1163
+ suppressedFlushFailureCount++;
659
1164
  }
660
1165
  }
661
1166
  }
@@ -724,47 +1229,67 @@ var EventTypes = {
724
1229
  DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
725
1230
  };
726
1231
 
727
- // src/lib/auth.ts
728
- async function getAuthCredentials() {
729
- const runnerKey = process.env.EVIDENT_RUNNER_KEY;
730
- const agentKey = process.env.EVIDENT_AGENT_KEY;
731
- if (runnerKey) {
732
- return {
733
- token: runnerKey,
734
- authType: "agent_key",
735
- keySource: "runner_key",
736
- notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
737
- };
738
- }
739
- if (agentKey) {
740
- return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
741
- }
742
- const userToken = process.env.EVIDENT_TOKEN;
743
- if (userToken) {
744
- return { token: userToken, authType: "bearer" };
1232
+ // src/lib/runner-activity-telemetry.ts
1233
+ var FORWARDED_LEVELS = /* @__PURE__ */ new Set(["warn", "error"]);
1234
+ var SEVERITY_BY_LEVEL = {
1235
+ warn: "warning",
1236
+ error: "error"
1237
+ };
1238
+ var MAX_MESSAGE_LENGTH = 500;
1239
+ var TRUNCATION_MARKER = "\u2026";
1240
+ function redact(message) {
1241
+ return message.replace(/esk_[A-Za-z0-9_-]+/g, "esk_***").replace(/ct_[A-Za-z0-9_-]+/g, "ct_***").replace(/https?:\/\/\S+/g, "<url>");
1242
+ }
1243
+ function truncate(message) {
1244
+ if (message.length <= MAX_MESSAGE_LENGTH) return message;
1245
+ return message.slice(0, MAX_MESSAGE_LENGTH - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
1246
+ }
1247
+ var RATE_LIMIT_WINDOW_MS = 6e4;
1248
+ var RATE_LIMIT_MAX_EVENTS = 30;
1249
+ var windowStartedAt = 0;
1250
+ var windowCount = 0;
1251
+ var windowDroppedCount = 0;
1252
+ function admitUnderRateLimit(now) {
1253
+ if (now - windowStartedAt >= RATE_LIMIT_WINDOW_MS) {
1254
+ if (windowDroppedCount > 0) {
1255
+ console.error(
1256
+ `[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)`
1257
+ );
1258
+ }
1259
+ windowStartedAt = now;
1260
+ windowCount = 0;
1261
+ windowDroppedCount = 0;
745
1262
  }
746
- const keychainCreds = await getToken();
747
- if (keychainCreds) {
748
- return {
749
- token: keychainCreds.token,
750
- authType: "bearer",
751
- user: keychainCreds.user
752
- };
1263
+ if (windowCount >= RATE_LIMIT_MAX_EVENTS) {
1264
+ windowDroppedCount++;
1265
+ if (windowDroppedCount === 1) {
1266
+ console.error(
1267
+ `[runner-activity-telemetry] rate cap reached (${RATE_LIMIT_MAX_EVENTS} per ${RATE_LIMIT_WINDOW_MS / 1e3}s) \u2014 dropping further entries this window`
1268
+ );
1269
+ }
1270
+ return false;
753
1271
  }
754
- return null;
1272
+ windowCount++;
1273
+ return true;
755
1274
  }
756
- function getAuthHeader(credentials2) {
757
- if (credentials2.authType === "agent_key") {
758
- return `SandboxKey ${credentials2.token}`;
1275
+ function forwardRunnerActivity(entry, context) {
1276
+ try {
1277
+ if (!FORWARDED_LEVELS.has(entry.level)) return;
1278
+ if (!context.agentId || !context.authHeader) return;
1279
+ if (!admitUnderRateLimit(Date.now())) return;
1280
+ const rawMessage = entry.error ?? entry.message ?? "";
1281
+ const message = truncate(redact(rawMessage));
1282
+ logEvent(TelemetryEventTypes.RUNNER_ACTIVITY, {
1283
+ severity: SEVERITY_BY_LEVEL[entry.level],
1284
+ message,
1285
+ metadata: { source: "cli.run" },
1286
+ agentId: context.agentId
1287
+ });
1288
+ } catch (err) {
1289
+ console.error(
1290
+ `[runner-activity-telemetry] failed to forward runner activity: ${err instanceof Error ? err.message : String(err)}`
1291
+ );
759
1292
  }
760
- return `Bearer ${credentials2.token}`;
761
- }
762
- function isInteractive(jsonOutput) {
763
- if (jsonOutput) return false;
764
- if (process.env.CI) return false;
765
- if (process.env.GITHUB_ACTIONS) return false;
766
- if (!process.stdin.isTTY) return false;
767
- return true;
768
1293
  }
769
1294
 
770
1295
  // src/lib/opencode/health.ts
@@ -1574,6 +2099,21 @@ function messageError(messages, userMessageId) {
1574
2099
  }
1575
2100
  return "The agent run failed.";
1576
2101
  }
2102
+ function isAbortedTerminalReply(messages, userMessageId) {
2103
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
2104
+ const error2 = errorOf(reply);
2105
+ if (error2 == null) return false;
2106
+ if (typeof error2 === "string") return error2.trim() === "Aborted";
2107
+ if (typeof error2 === "object") {
2108
+ const e = error2;
2109
+ if (e.name === "MessageAbortedError") return true;
2110
+ if (e.name === "AbortError") return true;
2111
+ const dataMessage = e.data?.message;
2112
+ const rendered = typeof dataMessage === "string" ? dataMessage : typeof e.message === "string" ? e.message : null;
2113
+ return rendered != null && rendered.trim() === "Aborted";
2114
+ }
2115
+ return false;
2116
+ }
1577
2117
  function messageFailure(messages, userMessageId) {
1578
2118
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1579
2119
  const error2 = errorOf(reply);
@@ -2150,13 +2690,44 @@ function writeTunnelReadyMarker(path, agentId) {
2150
2690
  }
2151
2691
  }
2152
2692
 
2693
+ // src/lib/claude-usage-reporting.ts
2694
+ var VALID_MODES = ["auto", "on", "off"];
2695
+ function resolveClaudeUsageReportingMode(flagValue, env) {
2696
+ const raw = flagValue ?? env.EVIDENT_CLAUDE_USAGE_REPORTING;
2697
+ if (raw === void 0 || raw === "") {
2698
+ return { mode: "auto", warnings: [] };
2699
+ }
2700
+ const normalized = raw.trim().toLowerCase();
2701
+ if (VALID_MODES.includes(normalized)) {
2702
+ return { mode: normalized, warnings: [] };
2703
+ }
2704
+ const source = flagValue !== void 0 ? "--claude-usage-reporting" : "EVIDENT_CLAUDE_USAGE_REPORTING";
2705
+ return {
2706
+ mode: "auto",
2707
+ warnings: [
2708
+ `Ignoring invalid ${source} "${raw}": expected one of ${VALID_MODES.join(", ")}; using auto`
2709
+ ]
2710
+ };
2711
+ }
2712
+ var BASE_REPORT_DELAY_MS = 10 * 6e4;
2713
+ var REPORT_DELAY_JITTER_FRACTION = 0.2;
2714
+ function nextReportDelayMs(random = Math.random) {
2715
+ const jitterRangeMs = BASE_REPORT_DELAY_MS * REPORT_DELAY_JITTER_FRACTION;
2716
+ return BASE_REPORT_DELAY_MS - jitterRangeMs + random() * (2 * jitterRangeMs);
2717
+ }
2718
+ var FIRST_REPORT_DELAY_MS = 5e3 + Math.random() * 1e4;
2719
+ var CLAUDE_USAGE_FAILURE_REESCALATION_TICKS = 6;
2720
+ function claudeUsageFailureLogLevel(consecutiveFailures) {
2721
+ return consecutiveFailures === 1 || consecutiveFailures % CLAUDE_USAGE_FAILURE_REESCALATION_TICKS === 0 ? "warn" : "debug";
2722
+ }
2723
+
2153
2724
  // src/lib/channels/driver.ts
2154
- import { homedir } from "os";
2725
+ import { homedir as homedir2 } from "os";
2155
2726
 
2156
2727
  // src/lib/file-push.ts
2157
2728
  import { randomUUID } from "crypto";
2158
2729
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2159
- import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
2730
+ import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
2160
2731
  var FILE_MODE = 384;
2161
2732
  var DIRECTORY_MODE = 448;
2162
2733
  async function writePushedFile(request) {
@@ -2189,7 +2760,7 @@ async function writePushedFile(request) {
2189
2760
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2190
2761
  dirname2(candidate)
2191
2762
  );
2192
- const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
2763
+ const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
2193
2764
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
2194
2765
  if (allowedDirectory === null) {
2195
2766
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
@@ -2225,7 +2796,7 @@ function expandAndValidate(requestedPath, homeDir) {
2225
2796
  if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
2226
2797
  return null;
2227
2798
  }
2228
- const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
2799
+ const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
2229
2800
  if (expanded.split(/[/\\]/).includes("..")) {
2230
2801
  return null;
2231
2802
  }
@@ -2298,13 +2869,13 @@ function contains(realDirectory, realTarget) {
2298
2869
  async function createMissingDirectories(existingAncestor, missingSegments) {
2299
2870
  let current = existingAncestor;
2300
2871
  for (const segment of missingSegments) {
2301
- current = join(current, segment);
2872
+ current = join2(current, segment);
2302
2873
  await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
2303
2874
  await chmod(current, DIRECTORY_MODE);
2304
2875
  }
2305
2876
  }
2306
2877
  async function writeAtomically(realTarget, content) {
2307
- const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2878
+ const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2308
2879
  let handle;
2309
2880
  try {
2310
2881
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -2471,8 +3042,8 @@ async function applyOne(options, file) {
2471
3042
  await ack(options, file, "applied");
2472
3043
  return true;
2473
3044
  }
2474
- function durableDownloadCode(status) {
2475
- return status === 413 ? "file_too_large" : "write_failed";
3045
+ function durableDownloadCode(status2) {
3046
+ return status2 === 413 ? "file_too_large" : "write_failed";
2476
3047
  }
2477
3048
  async function downloadContent(options, file, label) {
2478
3049
  try {
@@ -2505,8 +3076,8 @@ async function downloadContent(options, file, label) {
2505
3076
  return { ok: false, terminal: false };
2506
3077
  }
2507
3078
  }
2508
- async function ack(options, file, status, reason) {
2509
- const outcome = `${status}${reason ? ` (${reason})` : ""}`;
3079
+ async function ack(options, file, status2, reason) {
3080
+ const outcome = `${status2}${reason ? ` (${reason})` : ""}`;
2510
3081
  try {
2511
3082
  const res = await options.fetchImpl(
2512
3083
  `${options.apiUrl}/runners/${options.agentId}/files/${file.id}/ack`,
@@ -2516,7 +3087,7 @@ async function ack(options, file, status, reason) {
2516
3087
  Authorization: options.getAuthHeader(),
2517
3088
  "Content-Type": "application/json"
2518
3089
  },
2519
- body: JSON.stringify(reason ? { status, reason } : { status })
3090
+ body: JSON.stringify(reason ? { status: status2, reason } : { status: status2 })
2520
3091
  }
2521
3092
  );
2522
3093
  if (!res.ok) {
@@ -2580,6 +3151,7 @@ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2580
3151
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2581
3152
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2582
3153
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
3154
+ var MAX_IDENTICAL_REDRIVE_POLL_FAILURES = 5;
2583
3155
  var ChannelAuthError = class extends Error {
2584
3156
  constructor(message) {
2585
3157
  super(message);
@@ -2588,10 +3160,10 @@ var ChannelAuthError = class extends Error {
2588
3160
  };
2589
3161
  var ChannelTerminalError = class extends Error {
2590
3162
  status;
2591
- constructor(message, status) {
3163
+ constructor(message, status2) {
2592
3164
  super(message);
2593
3165
  this.name = "ChannelTerminalError";
2594
- this.status = status;
3166
+ this.status = status2;
2595
3167
  }
2596
3168
  };
2597
3169
  function backoffDelay(attempt, policy) {
@@ -2599,8 +3171,12 @@ function backoffDelay(attempt, policy) {
2599
3171
  const capped = Math.min(policy.maxDelayMs, exp);
2600
3172
  return Math.floor(Math.random() * capped);
2601
3173
  }
2602
- function isRetryableStatus(status) {
2603
- return status === 429 || status >= 500 && status <= 599;
3174
+ function isRetryableStatus(status2) {
3175
+ return status2 === 429 || status2 >= 500 && status2 <= 599;
3176
+ }
3177
+ var VOLATILE_BODY_FIELD_PATTERN = /("(?:ref|requestId|request_id|traceId|trace_id)"\s*:\s*)"[^"]*"/gi;
3178
+ function normalizeRedrivePollFailureBody(body) {
3179
+ return body.replace(VOLATILE_BODY_FIELD_PATTERN, '$1"<redacted>"').replace(/\s+/g, " ").trim().slice(0, 200);
2604
3180
  }
2605
3181
  var ChannelDriver = class _ChannelDriver {
2606
3182
  agentId;
@@ -2618,6 +3194,7 @@ var ChannelDriver = class _ChannelDriver {
2618
3194
  now;
2619
3195
  fileSyncDirectories;
2620
3196
  homeDir;
3197
+ maxActiveSessions;
2621
3198
  /** Cache of conversationId → opencode sessionId. */
2622
3199
  sessions = /* @__PURE__ */ new Map();
2623
3200
  /**
@@ -2707,14 +3284,92 @@ var ChannelDriver = class _ChannelDriver {
2707
3284
  */
2708
3285
  doneUndeliverable = /* @__PURE__ */ new Set();
2709
3286
  /**
2710
- * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
2711
- * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
2712
- * every ~2s drain until the status map becomes readable — but the server-visible
2713
- * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
2714
- * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
2715
- * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
3287
+ * "Already emitted `readopt_poll_unresolved` for this row" (#229). The b1 /
3288
+ * unreadable-status re-evaluate leaf leaves the row UN-tracked so it is re-read
3289
+ * every ~2s drain until the status map becomes readable — but the server-visible
3290
+ * signal is an OUTCOME, so it must fire at most ONCE per row, not once per drain
3291
+ * (Bugbot "Re-adopt signals flood every drain"). Cleared when the row leaves the
3292
+ * processing list, exactly like `dontRedispatch`/`doneUndeliverable`.
3293
+ */
3294
+ readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
3295
+ /**
3296
+ * "Already emitted `redrive_unresolved` for this row" (#965). Mirrors
3297
+ * `readoptPollUnresolvedSignalled`: `resolveRedrive`'s `unresolved` leaf recurs
3298
+ * every ~2s drain until opencode's status becomes readable, but the
3299
+ * server-visible signal is an OUTCOME, so it fires at most once per row. Cleared
3300
+ * on any non-`unresolved` outcome so the set cannot grow beyond the currently
3301
+ * unresolvable rows.
3302
+ */
3303
+ redriveUnresolvedSignalled = /* @__PURE__ */ new Set();
3304
+ /**
3305
+ * First `now()` a `pending` row's re-drive was observed `unresolved` (#965). A
3306
+ * `pending` row is invisible to every cron arm (all require `status =
3307
+ * 'processing'`), so an indefinitely-`unresolved` row would be stranded with
3308
+ * nothing driving it. Once `now - since >= pausedMaxWaitMs`, `resolveRedrive`
3309
+ * takes `dispatch` instead of `unresolved` (reusing the existing knob — see
3310
+ * ADR-0047's own "unreachable ⇒ bounded" rule). Cleared on any other outcome.
3311
+ */
3312
+ redriveUnresolvedSince = /* @__PURE__ */ new Map();
3313
+ /**
3314
+ * Consecutive-identical-poll-failure streak for the re-drive fence (#1348),
3315
+ * keyed by Evident **message id** (not session) so `clearRedriveUnresolved`
3316
+ * can drop it with the other two trackers and it cannot leak. `sessionId` is
3317
+ * carried inside the entry, not the key: a session change is a different
3318
+ * situation and resets the streak, which gives the `(sessionId, message.id)`
3319
+ * pairing #1348 asks for without a composite map key.
3320
+ */
3321
+ redrivePollFailures = /* @__PURE__ */ new Map();
3322
+ /**
3323
+ * "Already emitted `redrive_outcome_unreported` for THIS (message, outcome)
3324
+ * streak" (Class B, #1340: the runner DECIDED reattach/settle/fail_permanent
3325
+ * but its own PATCH to record it failed — distinct from Class A's
3326
+ * `redrive_poll_failed`, where opencode itself can't be observed). Keyed by
3327
+ * message id, valued by the outcome currently failing to report, so a
3328
+ * change of outcome starts a fresh signal. Cleared by
3329
+ * `clearRedriveUnresolved` the instant either PATCH succeeds.
3330
+ */
3331
+ redriveOutcomeUnreportedSignalled = /* @__PURE__ */ new Map();
3332
+ /**
3333
+ * First `now()` a Class B outcome PATCH (reattach/settle/fail_permanent) was
3334
+ * observed to fail for this message (#1366's failure-window trip arm,
3335
+ * `boundRedriveOutcome`). Duration, not a tick count — bounded by the
3336
+ * existing `pausedMaxWaitMs` window (reusing the knob, not a new constant).
3337
+ * Cleared by `clearRedriveUnresolved` the instant the original PATCH
3338
+ * succeeds.
3339
+ */
3340
+ redriveOutcomeFailingSince = /* @__PURE__ */ new Map();
3341
+ /**
3342
+ * "Already posted `redrive_outcome_abandoned` with `reported: false` for this
3343
+ * row" (#1366) — the bound tripped but the terminal `markFailed` fallback ALSO
3344
+ * failed (the route-level fault of G2), so every following tick re-attempts
3345
+ * the same terminal PATCH. Guards that quiet retry from re-signalling on
3346
+ * every tick. Cleared by `clearRedriveUnresolved`.
3347
+ */
3348
+ redriveOutcomeAbandonedSignalled = /* @__PURE__ */ new Set();
3349
+ /**
3350
+ * "Already emitted `dispatch_not_started` for THIS (message, branch) streak"
3351
+ * (#1340). Valued by the branch currently firing, so a row that moves between
3352
+ * exits re-signals — the move IS the finding. Cleared only on a CONFIRMED
3353
+ * dispatch, never on the fence's decision to dispatch: `clearRedriveUnresolved`
3354
+ * runs on that decision (`resolveRedriveUnresolved`), so clearing there would
3355
+ * re-signal on every one of the 15h of re-dispatch attempts #1110 made.
3356
+ */
3357
+ dispatchNotStartedSignalled = /* @__PURE__ */ new Map();
3358
+ /**
3359
+ * Consecutive-UNCONFIRMED-dispatch streak for a `pending` row with NO stored
3360
+ * `opencode_message_id` yet — i.e. one that has never even reached the
3361
+ * re-drive fence above. `sendPromptAsync`'s POST may 2xx, but its own
3362
+ * read-back retries can never confirm the assigned id when the session's
3363
+ * message list is PERMANENTLY unreadable (e.g. a corrupted local opencode
3364
+ * SQLite DB, #1345/#1348's exact fault, just hit BEFORE the row is ever
3365
+ * dispatched instead of after). Unlike an already-dispatched row, THIS row has
3366
+ * no other safety net at all: the lifecycle cron only reclaims `status =
3367
+ * 'processing'` rows, and a row stuck here never reaches `processing`. Keyed
3368
+ * by message id, carrying `sessionId` so a session change (a fresh one bound
3369
+ * after abandonment) starts a new streak rather than inheriting the old
3370
+ * session's count — same shape as `redrivePollFailures` above.
2716
3371
  */
2717
- readoptPollUnresolvedSignalled = /* @__PURE__ */ new Set();
3372
+ unconfirmedDispatchFailures = /* @__PURE__ */ new Map();
2718
3373
  /**
2719
3374
  * "A null-id re-adopt re-dispatch is in flight, awaiting its read-back" (WI-5
2720
3375
  * Task 5.4, High-2). Since we no longer send a caller-supplied id, a re-dispatch
@@ -2803,23 +3458,24 @@ var ChannelDriver = class _ChannelDriver {
2803
3458
  * and stops opencode.
2804
3459
  */
2805
3460
  stopped = false;
2806
- constructor(config2) {
2807
- this.agentId = config2.agentId;
2808
- this.port = config2.port;
2809
- this.apiUrl = config2.apiUrl.replace(/\/$/, "");
2810
- this.getAuthHeader = config2.getAuthHeader;
2811
- this.conversationFilter = config2.conversationFilter ?? null;
2812
- this.retry = { ...DEFAULT_RETRY_POLICY, ...config2.retry };
2813
- this.log = config2.log ?? (() => {
3461
+ constructor(config) {
3462
+ this.agentId = config.agentId;
3463
+ this.port = config.port;
3464
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
3465
+ this.getAuthHeader = config.getAuthHeader;
3466
+ this.conversationFilter = config.conversationFilter ?? null;
3467
+ this.retry = { ...DEFAULT_RETRY_POLICY, ...config.retry };
3468
+ this.log = config.log ?? (() => {
2814
3469
  });
2815
- this.fetchImpl = config2.fetchImpl ?? fetch;
2816
- this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2817
- this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
2818
- this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
2819
- this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
2820
- this.now = config2.now ?? (() => Date.now());
2821
- this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
2822
- this.homeDir = config2.homeDir ?? homedir();
3470
+ this.fetchImpl = config.fetchImpl ?? fetch;
3471
+ this.sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
3472
+ this.pausedPollIntervalMs = config.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
3473
+ this.pausedMaxWaitMs = config.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
3474
+ this.stuckQueuedMs = config.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
3475
+ this.now = config.now ?? (() => Date.now());
3476
+ this.fileSyncDirectories = config.fileSyncDirectories ?? [];
3477
+ this.homeDir = config.homeDir ?? homedir2();
3478
+ this.maxActiveSessions = config.maxActiveSessions;
2823
3479
  }
2824
3480
  /** The IPv4-loopback base URL for the local `opencode serve`. */
2825
3481
  get opencodeBase() {
@@ -2899,10 +3555,26 @@ var ChannelDriver = class _ChannelDriver {
2899
3555
  message: `Found ${total} pending message(s) across ${conversations.length} conversation(s) \u2014 draining`
2900
3556
  });
2901
3557
  }
3558
+ let cappedSkips = 0;
2902
3559
  for (const conv of conversations) {
2903
3560
  if (this.stopped) break;
3561
+ if (this.maxActiveSessions !== void 0) {
3562
+ const activeSessionIds = this.activeSessionIdsForCap();
3563
+ const resolvedSessionId = this.sessions.get(conv.id) ?? conv.opencode_session_id;
3564
+ const alreadyActive = resolvedSessionId != null && activeSessionIds.has(resolvedSessionId);
3565
+ if (activeSessionIds.size >= this.maxActiveSessions && !alreadyActive) {
3566
+ cappedSkips++;
3567
+ continue;
3568
+ }
3569
+ }
2904
3570
  dispatched += await this.processConversation(conv);
2905
3571
  }
3572
+ if (cappedSkips > 0) {
3573
+ this.log({
3574
+ level: "warn",
3575
+ message: `max-active-sessions cap (${this.maxActiveSessions}) reached \u2014 skipped ${cappedSkips} pending conversation(s) this tick`
3576
+ });
3577
+ }
2906
3578
  await this.readoptProcessing();
2907
3579
  } finally {
2908
3580
  this.draining = false;
@@ -2921,6 +3593,22 @@ var ChannelDriver = class _ChannelDriver {
2921
3593
  }
2922
3594
  return false;
2923
3595
  }
3596
+ /**
3597
+ * Session ids active *for the `--max-active-sessions` cap*: in-flight work AND
3598
+ * a live watcher loop. Unlike `hasInFlightWatchers()` / `protectedSessionIds()`,
3599
+ * a ZOMBIE watcher (in-flight but `loop === null`, left by a non-auth failure
3600
+ * inside `runWatcherLoop`) does not count here — under a cap it would
3601
+ * permanently consume a slot, whereas cleanup/idle-exit should still treat it
3602
+ * as protected. One call per drain iteration serves both the cap check
3603
+ * (`.size`) and the already-active exemption (`.has`).
3604
+ */
3605
+ activeSessionIdsForCap() {
3606
+ const ids = /* @__PURE__ */ new Set();
3607
+ for (const [sessionId, watcher] of this.watchers) {
3608
+ if (watcher.inFlight.size > 0 && watcher.loop !== null) ids.add(sessionId);
3609
+ }
3610
+ return ids;
3611
+ }
2924
3612
  /**
2925
3613
  * File-pull work, for `run.ts`'s idle accounting (#559).
2926
3614
  *
@@ -3039,7 +3727,7 @@ var ChannelDriver = class _ChannelDriver {
3039
3727
  * @returns the count of messages NEWLY dispatched (not already in-flight).
3040
3728
  */
3041
3729
  async processConversation(conv) {
3042
- const { sessionId, refusedSessionId } = await this.ensureSession(conv);
3730
+ const { sessionId, refusedSessionId, created: sessionCreated } = await this.ensureSession(conv);
3043
3731
  const messages = await this.getPendingMessages(conv.id);
3044
3732
  let dispatched = 0;
3045
3733
  let skippedAlreadyDispatched = 0;
@@ -3054,6 +3742,15 @@ var ChannelDriver = class _ChannelDriver {
3054
3742
  skippedAlreadyDispatched += 1;
3055
3743
  continue;
3056
3744
  }
3745
+ if (message.opencode_message_id) {
3746
+ const outcome = await this.resolveRedrive(conv, sessionId, message, sessionCreated);
3747
+ if (outcome === "abandoned") {
3748
+ continue;
3749
+ }
3750
+ if (outcome !== "dispatch") {
3751
+ break;
3752
+ }
3753
+ }
3057
3754
  const options = {
3058
3755
  agent: message.opencode_agent ?? void 0,
3059
3756
  model: message.opencode_model ?? void 0
@@ -3083,6 +3780,7 @@ var ChannelDriver = class _ChannelDriver {
3083
3780
  conversation_id: conv.id,
3084
3781
  message_id: message.id
3085
3782
  });
3783
+ this.signalDispatchNotStarted(conv, message, "session_deleted_race");
3086
3784
  break;
3087
3785
  }
3088
3786
  if (exists === null) {
@@ -3092,6 +3790,7 @@ var ChannelDriver = class _ChannelDriver {
3092
3790
  conversation_id: conv.id,
3093
3791
  message_id: message.id
3094
3792
  });
3793
+ this.signalDispatchNotStarted(conv, message, "session_existence_unknown");
3095
3794
  break;
3096
3795
  }
3097
3796
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -3110,6 +3809,7 @@ var ChannelDriver = class _ChannelDriver {
3110
3809
  conversation_id: conv.id,
3111
3810
  message_id: message.id
3112
3811
  });
3812
+ this.signalDispatchNotStarted(conv, message, "failure_unreported");
3113
3813
  });
3114
3814
  this.log({
3115
3815
  level: "error",
@@ -3120,14 +3820,40 @@ var ChannelDriver = class _ChannelDriver {
3120
3820
  break;
3121
3821
  }
3122
3822
  if (opencodeMessageId === null) {
3823
+ const streak = this.recordUnconfirmedDispatch(message.id, sessionId);
3824
+ if (streak < MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
3825
+ this.log({
3826
+ level: "warn",
3827
+ message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next tick`,
3828
+ conversation_id: conv.id,
3829
+ message_id: message.id
3830
+ });
3831
+ this.signalDispatchNotStarted(conv, message, "readback_unconfirmed");
3832
+ continue;
3833
+ }
3834
+ this.unconfirmedDispatchFailures.delete(message.id);
3835
+ this.sessions.delete(conv.id);
3836
+ this.supersede(conv.id, sessionId);
3837
+ const errorMessage = `OpenCode accepted this message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
3123
3838
  this.log({
3124
- level: "warn",
3125
- message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
3839
+ level: "error",
3840
+ message: errorMessage,
3126
3841
  conversation_id: conv.id,
3127
3842
  message_id: message.id
3128
3843
  });
3129
- continue;
3844
+ await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
3845
+ this.log({
3846
+ level: "warn",
3847
+ message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
3848
+ conversation_id: conv.id,
3849
+ message_id: message.id
3850
+ });
3851
+ this.signalDispatchNotStarted(conv, message, "abandon_unreported");
3852
+ });
3853
+ break;
3130
3854
  }
3855
+ this.unconfirmedDispatchFailures.delete(message.id);
3856
+ this.dispatchNotStartedSignalled.delete(message.id);
3131
3857
  this.dispatched.add(message.id);
3132
3858
  this.registerInFlight(conv, sessionId, message, opencodeMessageId);
3133
3859
  dispatched += 1;
@@ -3143,6 +3869,451 @@ var ChannelDriver = class _ChannelDriver {
3143
3869
  this.ensureWatcherRunning(sessionId);
3144
3870
  return dispatched;
3145
3871
  }
3872
+ /**
3873
+ * Poll a session's message list for the re-drive fence (#965), via the
3874
+ * INJECTED `fetchImpl` — NOT the imported `getSessionMessages` helper, which
3875
+ * hits the global `fetch` and would bypass the same override every other
3876
+ * opencode poll in this file respects. Mirrors `readoptProcessing`'s own
3877
+ * snapshot fetch (`:3081-3111`).
3878
+ *
3879
+ * Returns `{ ok: true, messages }` on a readable snapshot, or
3880
+ * `{ ok: false, signature }` on failure — `signature` is a string that
3881
+ * repeats across attempts for the SAME underlying fault (used by the
3882
+ * consecutive-identical-failure bound, #1348), or `null` for a thrown
3883
+ * exception, which is NOT countable toward that bound (a network blip / an
3884
+ * opencode restart also throws identically every tick, and must keep
3885
+ * retrying unbounded rather than ever being treated as permanent).
3886
+ */
3887
+ async pollSessionMessagesForRedrive(conv, message, sessionId) {
3888
+ try {
3889
+ const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
3890
+ if (!res.ok) {
3891
+ const rawBody = await res.text();
3892
+ const normalized = normalizeRedrivePollFailureBody(rawBody);
3893
+ this.log({
3894
+ level: "warn",
3895
+ 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`,
3896
+ conversation_id: conv.id,
3897
+ message_id: message.id
3898
+ });
3899
+ return { ok: false, signature: `HTTP ${res.status}${normalized ? `: ${normalized}` : ""}` };
3900
+ }
3901
+ const body = await res.json();
3902
+ if (!Array.isArray(body)) {
3903
+ this.log({
3904
+ level: "warn",
3905
+ 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`,
3906
+ conversation_id: conv.id,
3907
+ message_id: message.id
3908
+ });
3909
+ return { ok: false, signature: "non-array message body" };
3910
+ }
3911
+ return { ok: true, messages: body };
3912
+ } catch (err) {
3913
+ this.log({
3914
+ level: "warn",
3915
+ 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)}`,
3916
+ conversation_id: conv.id,
3917
+ message_id: message.id
3918
+ });
3919
+ return { ok: false, signature: null };
3920
+ }
3921
+ }
3922
+ /**
3923
+ * The re-drive fence for a `pending` row that already carries a stored
3924
+ * `opencode_message_id` (#965) — i.e. it has already been handed to opencode at
3925
+ * least once (see the invariant at `QueuedMessage.opencode_message_id`'s doc).
3926
+ * The lifecycle cron can falsely reclaim a `processing` row back to `pending`
3927
+ * mid-turn (a 5-minute liveness-staleness check racing a still-running turn);
3928
+ * without this fence the drain loop would re-`prompt_async` the SAME turn a
3929
+ * second time against live GitHub state. Mirrors `readoptOne`'s job for the
3930
+ * `processing` re-adopt path, but simpler: no b1/b2 preamble cross-check is
3931
+ * needed here because `sessionCreated` already handles the cases (a #553
3932
+ * abandoned session, a #190 vanished one) that path exists for.
3933
+ *
3934
+ * Only `ChannelAuthError` propagates. A poll that fails identically
3935
+ * `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row reports the message
3936
+ * failed instead of retrying it (#1348) — SEPARATE from, not a replacement
3937
+ * for, `resolveRedriveUnresolved`'s own `pausedMaxWaitMs` bound below. Every
3938
+ * other failure resolves to `unresolved` and is retried whole on the next
3939
+ * ~2s drain tick.
3940
+ */
3941
+ async resolveRedrive(conv, sessionId, message, sessionCreated) {
3942
+ const ocId = message.opencode_message_id ?? null;
3943
+ if (sessionCreated) {
3944
+ this.clearRedriveUnresolved(message.id);
3945
+ void this.postSignal(conv.id, message.id, "redrive_redispatched");
3946
+ return "dispatch";
3947
+ }
3948
+ const polled = await this.pollSessionMessagesForRedrive(conv, message, sessionId);
3949
+ if (!polled.ok) {
3950
+ const streak = this.recordRedrivePollFailure(message.id, sessionId, polled.signature);
3951
+ if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES && polled.signature !== null) {
3952
+ return this.failRedrivePollPermanent(conv, sessionId, message, polled.signature, streak);
3953
+ }
3954
+ return this.resolveRedriveUnresolved(conv, message);
3955
+ }
3956
+ this.redrivePollFailures.delete(message.id);
3957
+ const messages = polled.messages;
3958
+ if (messages.length === 0) {
3959
+ return this.resolveRedriveUnresolved(conv, message);
3960
+ }
3961
+ const state = messageRunState(messages, ocId ?? "");
3962
+ if (state === "failed" && isAbortedTerminalReply(messages, ocId ?? "")) {
3963
+ const ongoing = await isSessionOngoing(this.port, sessionId);
3964
+ if (ongoing === false) {
3965
+ this.log({
3966
+ level: "info",
3967
+ 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`,
3968
+ conversation_id: conv.id,
3969
+ message_id: message.id
3970
+ });
3971
+ this.clearRedriveUnresolved(message.id);
3972
+ void this.postSignal(conv.id, message.id, "redrive_redispatched");
3973
+ return "dispatch";
3974
+ }
3975
+ }
3976
+ if (state === "done" || state === "failed") {
3977
+ return this.settleRedrive(conv, sessionId, message, ocId, messages, state);
3978
+ }
3979
+ if (state === "running" || state === "queued") {
3980
+ const ongoing = await isSessionOngoing(this.port, sessionId);
3981
+ if (ongoing === true) {
3982
+ return this.reattachRedrive(conv, sessionId, message, ocId);
3983
+ }
3984
+ if (ongoing === false) {
3985
+ this.clearRedriveUnresolved(message.id);
3986
+ void this.postSignal(conv.id, message.id, "redrive_redispatched");
3987
+ return "dispatch";
3988
+ }
3989
+ return this.resolveRedriveUnresolved(conv, message);
3990
+ }
3991
+ this.clearRedriveUnresolved(message.id);
3992
+ void this.postSignal(conv.id, message.id, "redrive_redispatched");
3993
+ return "dispatch";
3994
+ }
3995
+ /**
3996
+ * The `reattached` outcome (Task 3.3): the prior turn is STILL ONGOING per
3997
+ * opencode's own status map — undo the false reclaim instead of starting a
3998
+ * second turn.
3999
+ */
4000
+ async reattachRedrive(conv, sessionId, message, ocId) {
4001
+ let anchorMs;
4002
+ const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
4003
+ if (!Number.isNaN(parsed)) {
4004
+ anchorMs = parsed;
4005
+ } else {
4006
+ anchorMs = this.now();
4007
+ this.log({
4008
+ level: "error",
4009
+ 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)`,
4010
+ conversation_id: conv.id,
4011
+ message_id: message.id
4012
+ });
4013
+ }
4014
+ const title = await this.resolveSessionTitle(sessionId, conv.id);
4015
+ try {
4016
+ await this.markProcessing(conv.id, message.id, sessionId, ocId, title);
4017
+ } catch (err) {
4018
+ if (err instanceof ChannelAuthError) throw err;
4019
+ if (err instanceof ChannelTerminalError) {
4020
+ this.log({
4021
+ level: "error",
4022
+ message: `Re-drive: the server definitively refused to restore message ${message.id.slice(0, 8)} to processing (terminal HTTP ${err.status} \u2014 the row is gone or the update was rejected); NOT reporting a re-attach`,
4023
+ conversation_id: conv.id,
4024
+ message_id: message.id
4025
+ });
4026
+ } else {
4027
+ this.log({
4028
+ level: "warn",
4029
+ 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)}`,
4030
+ conversation_id: conv.id,
4031
+ message_id: message.id
4032
+ });
4033
+ }
4034
+ const bound = await this.boundRedriveOutcome(conv, message, "reattach");
4035
+ return bound === "abandoned" ? "abandoned" : "unresolved";
4036
+ }
4037
+ this.clearRedriveUnresolved(message.id);
4038
+ this.registerReadopted(conv, sessionId, message, ocId ?? "", anchorMs);
4039
+ this.dispatched.add(message.id);
4040
+ this.readopted.add(message.id);
4041
+ this.ensureWatcherRunning(sessionId);
4042
+ const watchedForMs = this.now() - anchorMs;
4043
+ void this.postSignal(conv.id, message.id, "redrive_reattached", {
4044
+ watched_for_ms: watchedForMs
4045
+ });
4046
+ this.log({
4047
+ level: "warn",
4048
+ 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`,
4049
+ conversation_id: conv.id,
4050
+ message_id: message.id
4051
+ });
4052
+ return "reattached";
4053
+ }
4054
+ /**
4055
+ * The `settled` outcome (Task 3.2): the prior turn already finished (or
4056
+ * errored) while nobody was watching — deliver/report it instead of re-running.
4057
+ * Mirrors `readoptOne`'s `done`/`failed` branches' error discipline, simplified
4058
+ * (no `doneUndeliverable` park: a terminal PATCH failure here just retries next
4059
+ * drain, same as any other non-auth failure). The restart-abort carve-out that
4060
+ * keeps the two in step for `failed` lives in the caller (`resolveRedrive`, #1310),
4061
+ * so a row reaching this `failed` branch is a GENUINE failure.
4062
+ */
4063
+ async settleRedrive(conv, sessionId, message, ocId, messages, state) {
4064
+ try {
4065
+ if (state === "done") {
4066
+ const title = await this.resolveSessionTitle(sessionId, conv.id);
4067
+ const usage = messageUsage(messages, ocId ?? "");
4068
+ this.log({
4069
+ level: "info",
4070
+ 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`,
4071
+ conversation_id: conv.id,
4072
+ message_id: message.id
4073
+ });
4074
+ await this.markDone(conv.id, message.id, sessionId, ocId, title, usage);
4075
+ } else {
4076
+ const error2 = messageError(messages, ocId ?? "") ?? void 0;
4077
+ const usage = messageUsage(messages, ocId ?? "");
4078
+ const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
4079
+ this.log({
4080
+ level: "error",
4081
+ 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)"}`,
4082
+ conversation_id: conv.id,
4083
+ message_id: message.id
4084
+ });
4085
+ await this.markFailed(conv.id, message.id, sessionId, error2, usage, failure);
4086
+ }
4087
+ } catch (err) {
4088
+ if (err instanceof ChannelAuthError) throw err;
4089
+ this.log({
4090
+ level: "warn",
4091
+ message: `Re-drive: failed to report message ${message.id.slice(0, 8)} ${state} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
4092
+ conversation_id: conv.id,
4093
+ message_id: message.id
4094
+ });
4095
+ const bound = await this.boundRedriveOutcome(conv, message, "settle");
4096
+ return bound === "abandoned" ? "abandoned" : "unresolved";
4097
+ }
4098
+ this.clearRedriveUnresolved(message.id);
4099
+ void this.postSignal(conv.id, message.id, "redrive_settled");
4100
+ return "settled";
4101
+ }
4102
+ /**
4103
+ * The permanent-failure outcome (#1348): the fence's own poll of this session
4104
+ * failed with the SAME opencode-answered signature
4105
+ * `MAX_IDENTICAL_REDRIVE_POLL_FAILURES` times in a row — a transient blip
4106
+ * would have varied or eventually cleared (see `pollSessionMessagesForRedrive`
4107
+ * and `recordRedrivePollFailure`), so this is a durable fault (e.g. #1345's
4108
+ * corrupted opencode session) rather than something worth retrying forever.
4109
+ * Mirrors `settleRedrive`'s error discipline: no `usage`/`failure` args to
4110
+ * `markFailed` (no opencode snapshot to extract them from — this poll never
4111
+ * got a readable one).
4112
+ */
4113
+ async failRedrivePollPermanent(conv, sessionId, message, signature, streak) {
4114
+ this.log({
4115
+ level: "error",
4116
+ 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`,
4117
+ conversation_id: conv.id,
4118
+ message_id: message.id
4119
+ });
4120
+ try {
4121
+ await this.markFailed(
4122
+ conv.id,
4123
+ message.id,
4124
+ sessionId,
4125
+ `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.`
4126
+ );
4127
+ } catch (err) {
4128
+ if (err instanceof ChannelAuthError) throw err;
4129
+ this.log({
4130
+ level: "warn",
4131
+ 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)}`,
4132
+ conversation_id: conv.id,
4133
+ message_id: message.id
4134
+ });
4135
+ const bound = await this.boundRedriveOutcome(conv, message, "fail_permanent");
4136
+ return bound === "abandoned" ? "abandoned" : "unresolved";
4137
+ }
4138
+ this.clearRedriveUnresolved(message.id);
4139
+ void this.postSignal(conv.id, message.id, "redrive_poll_failed");
4140
+ return "settled";
4141
+ }
4142
+ /**
4143
+ * The bounded `unresolved` outcome (Task 3.4): opencode's state could not be
4144
+ * observed (snapshot unreadable/empty, or `isSessionOngoing` returned `null`).
4145
+ * A `pending` row is swept by the server's own `PENDING_MAX_AGE_MS` (24h,
4146
+ * #1368) cron arm, but that is a day-scale backstop — this local bound acts
4147
+ * in minutes so the row (and the conversation it starves, per the ordering
4148
+ * invariant below) isn't left stranded for that long. Bound to the existing
4149
+ * `pausedMaxWaitMs` window (reusing the knob, not a new constant); takes
4150
+ * `dispatch` once elapsed.
4151
+ */
4152
+ resolveRedriveUnresolved(conv, message) {
4153
+ const now = this.now();
4154
+ const since = this.redriveUnresolvedSince.get(message.id);
4155
+ if (since !== void 0 && now - since >= this.pausedMaxWaitMs) {
4156
+ this.clearRedriveUnresolved(message.id);
4157
+ void this.postSignal(conv.id, message.id, "redrive_redispatched");
4158
+ return "dispatch";
4159
+ }
4160
+ if (since === void 0) {
4161
+ this.redriveUnresolvedSince.set(message.id, now);
4162
+ }
4163
+ if (!this.redriveUnresolvedSignalled.has(message.id)) {
4164
+ this.redriveUnresolvedSignalled.add(message.id);
4165
+ void this.postSignal(conv.id, message.id, "redrive_unresolved");
4166
+ }
4167
+ return "unresolved";
4168
+ }
4169
+ /** Clear all `unresolved`/failure-streak trackers for a row (any non-`unresolved` outcome). */
4170
+ clearRedriveUnresolved(messageId) {
4171
+ this.redriveUnresolvedSince.delete(messageId);
4172
+ this.redriveUnresolvedSignalled.delete(messageId);
4173
+ this.redrivePollFailures.delete(messageId);
4174
+ this.redriveOutcomeUnreportedSignalled.delete(messageId);
4175
+ this.redriveOutcomeFailingSince.delete(messageId);
4176
+ this.redriveOutcomeAbandonedSignalled.delete(messageId);
4177
+ }
4178
+ /**
4179
+ * #1340: the dispatch loop reached a message and did NOT start a turn. Fires at
4180
+ * most once per (message, branch) streak — a wedged row is re-tried every tick,
4181
+ * and the per-tick count is already carried by the co-occurring
4182
+ * `redrive_unresolved`/`redrive_redispatched` signals.
4183
+ */
4184
+ signalDispatchNotStarted(conv, message, branch) {
4185
+ if (this.dispatchNotStartedSignalled.get(message.id) === branch) return;
4186
+ this.dispatchNotStartedSignalled.set(message.id, branch);
4187
+ void this.postSignal(conv.id, message.id, "dispatch_not_started", { branch });
4188
+ }
4189
+ /**
4190
+ * Class B (#1340): the runner DECIDED an outcome (reattach/settle/fail_permanent)
4191
+ * but its own PATCH to record it failed. Fires at most once per (message,
4192
+ * outcome) streak, and only while `boundRedriveOutcome` has not yet tripped —
4193
+ * once it trips, `redrive_outcome_abandoned` takes over reporting for the row
4194
+ * (#1366).
4195
+ */
4196
+ signalRedriveOutcomeUnreported(conv, message, outcome) {
4197
+ if (this.redriveOutcomeUnreportedSignalled.get(message.id) === outcome) return;
4198
+ this.redriveOutcomeUnreportedSignalled.set(message.id, outcome);
4199
+ void this.postSignal(conv.id, message.id, "redrive_outcome_unreported", {
4200
+ attempted_outcome: outcome
4201
+ });
4202
+ }
4203
+ /**
4204
+ * The runner-authored, honest error text for the terminal fallback a tripped
4205
+ * `boundRedriveOutcome` sends. Distinguishable per outcome and truthful about
4206
+ * what actually happened — the `settle`/done case must say the turn finished
4207
+ * but its result could not be recorded, never that the runner stopped
4208
+ * responding (that would be a lie for this shape, see #1366's "why this ships").
4209
+ */
4210
+ static REDRIVE_ABANDON_ERROR = {
4211
+ reattach: "your runner could not record that this message had started, so it was given up on",
4212
+ settle: "your runner finished this message but could not record the result, so the reply could not be delivered",
4213
+ fail_permanent: "the runner could not read this conversation's state from OpenCode, and could not record that failure either, so the message was given up on"
4214
+ };
4215
+ /**
4216
+ * Bound for Class B (#1340, #1366): the runner DECIDED an outcome but its own
4217
+ * PATCH to record it failed. Two independent trip arms (either sufficient):
4218
+ * (1) this failure streak has lasted `pausedMaxWaitMs` — DURATION, not a tick
4219
+ * count, reusing the knob `resolveRedriveUnresolved` already established; (2)
4220
+ * the turn's `processing_started_at` age has crossed
4221
+ * `ABSOLUTE_MAX_PROCESSING_MS` — durable and restart-surviving, since arm (1)'s
4222
+ * in-memory streak resets on a scale-to-zero restart.
4223
+ *
4224
+ * INVARIANT — a tripped bound never suppresses the original outcome attempt;
4225
+ * it only adds a fallback after that attempt has failed again. This is only
4226
+ * ever reached from inside the catch of the ORIGINAL outcome PATCH, which is
4227
+ * attempted first on every tick whether or not this bound tripped before —
4228
+ * there is no give-up latch that would short-circuit it. That is what lets a
4229
+ * route-level fault that heals later still deliver the turn's real
4230
+ * `done`/`failed` payload: once the original PATCH succeeds again, this
4231
+ * helper is never entered and the row settles with its real result.
4232
+ */
4233
+ async boundRedriveOutcome(conv, message, outcome) {
4234
+ const now = this.now();
4235
+ const since = this.redriveOutcomeFailingSince.get(message.id);
4236
+ if (since === void 0) this.redriveOutcomeFailingSince.set(message.id, now);
4237
+ const durationTripped = now - (since ?? now) >= this.pausedMaxWaitMs;
4238
+ const parsed = message.processing_started_at ? Date.parse(message.processing_started_at) : NaN;
4239
+ const absoluteAgeTripped = !Number.isNaN(parsed) && now - parsed >= ABSOLUTE_MAX_PROCESSING_MS;
4240
+ if (!durationTripped && !absoluteAgeTripped) {
4241
+ this.signalRedriveOutcomeUnreported(conv, message, outcome);
4242
+ return "retry";
4243
+ }
4244
+ const arm = durationTripped ? "failure_window" : "absolute_age";
4245
+ try {
4246
+ await this.markFailed(
4247
+ conv.id,
4248
+ message.id,
4249
+ void 0,
4250
+ _ChannelDriver.REDRIVE_ABANDON_ERROR[outcome]
4251
+ );
4252
+ } catch (err) {
4253
+ if (err instanceof ChannelAuthError) throw err;
4254
+ this.log({
4255
+ level: "warn",
4256
+ message: `Re-drive bound: fallback markFailed for message ${message.id.slice(0, 8)} also failed (arm ${arm}, will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
4257
+ conversation_id: conv.id,
4258
+ message_id: message.id
4259
+ });
4260
+ if (!this.redriveOutcomeAbandonedSignalled.has(message.id)) {
4261
+ this.redriveOutcomeAbandonedSignalled.add(message.id);
4262
+ void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
4263
+ attempted_outcome: outcome,
4264
+ reported: false,
4265
+ arm
4266
+ });
4267
+ }
4268
+ return "retry";
4269
+ }
4270
+ this.clearRedriveUnresolved(message.id);
4271
+ void this.postSignal(conv.id, message.id, "redrive_outcome_abandoned", {
4272
+ attempted_outcome: outcome,
4273
+ reported: true,
4274
+ arm
4275
+ });
4276
+ return "abandoned";
4277
+ }
4278
+ /**
4279
+ * Record one poll outcome toward the re-drive fence's consecutive-identical-
4280
+ * failure streak (#1348) and return the resulting count. `signature === null`
4281
+ * (a thrown exception, H1) always clears the streak and returns `0` — it is
4282
+ * never countable. Otherwise the streak continues only when BOTH the session
4283
+ * and the signature match the previous failure; anything else (a different
4284
+ * session, or the same session failing a DIFFERENT way) starts a fresh streak
4285
+ * at `1`.
4286
+ */
4287
+ recordRedrivePollFailure(messageId, sessionId, signature) {
4288
+ if (signature === null) {
4289
+ this.redrivePollFailures.delete(messageId);
4290
+ return 0;
4291
+ }
4292
+ const existing = this.redrivePollFailures.get(messageId);
4293
+ if (existing && existing.sessionId === sessionId && existing.signature === signature) {
4294
+ existing.count += 1;
4295
+ return existing.count;
4296
+ }
4297
+ this.redrivePollFailures.set(messageId, { sessionId, signature, count: 1 });
4298
+ return 1;
4299
+ }
4300
+ /**
4301
+ * Record one UNCONFIRMED-dispatch outcome (a `pending` row with no stored
4302
+ * `opencode_message_id` whose `sendPromptAsync` returned `null`) toward the
4303
+ * bound in `processConversation`'s dispatch loop, and return the resulting
4304
+ * count. Mirrors `recordRedrivePollFailure`'s session-scoping: a session
4305
+ * change starts a fresh streak at `1` rather than inheriting the old one's
4306
+ * count, since a new session is a genuinely different attempt.
4307
+ */
4308
+ recordUnconfirmedDispatch(messageId, sessionId) {
4309
+ const existing = this.unconfirmedDispatchFailures.get(messageId);
4310
+ if (existing && existing.sessionId === sessionId) {
4311
+ existing.count += 1;
4312
+ return existing.count;
4313
+ }
4314
+ this.unconfirmedDispatchFailures.set(messageId, { sessionId, count: 1 });
4315
+ return 1;
4316
+ }
3146
4317
  /**
3147
4318
  * Record that `sessionId` is no longer a valid binding for `conversationId`
3148
4319
  * (#553). Keyed by conversation and hard-capped, so it cannot grow with the
@@ -3167,6 +4338,13 @@ var ChannelDriver = class _ChannelDriver {
3167
4338
  * `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
3168
4339
  * binding was an id this runner had abandoned, so a resurrection genuinely
3169
4340
  * happened and a fresh session was bound instead. The caller reports it.
4341
+ *
4342
+ * `created` says the returned session was made JUST NOW, so it provably holds
4343
+ * no prior turn. The re-drive fence needs that as CONTRARY evidence ("nothing
4344
+ * to reconcile against") — distinct from the ambiguous "I polled and saw an
4345
+ * empty transcript", which stays a deferral. Keep it separate from
4346
+ * `refusedSessionId`: only the latter means a #553 resurrection happened, and
4347
+ * only it may drive the `session_superseded` signal.
3170
4348
  */
3171
4349
  async ensureSession(conv) {
3172
4350
  const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
@@ -3177,7 +4355,11 @@ var ChannelDriver = class _ChannelDriver {
3177
4355
  conversation_id: conv.id
3178
4356
  });
3179
4357
  this.sessions.delete(conv.id);
3180
- return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
4358
+ return {
4359
+ sessionId: await this.createAndBindSession(conv.id),
4360
+ refusedSessionId: bound,
4361
+ created: true
4362
+ };
3181
4363
  }
3182
4364
  if (bound) {
3183
4365
  const exists = await sessionExists(this.port, bound);
@@ -3188,12 +4370,12 @@ var ChannelDriver = class _ChannelDriver {
3188
4370
  conversation_id: conv.id
3189
4371
  });
3190
4372
  this.sessions.delete(conv.id);
3191
- return { sessionId: await this.createAndBindSession(conv.id) };
4373
+ return { sessionId: await this.createAndBindSession(conv.id), created: true };
3192
4374
  }
3193
4375
  this.sessions.set(conv.id, bound);
3194
- return { sessionId: bound };
4376
+ return { sessionId: bound, created: false };
3195
4377
  }
3196
- return { sessionId: await this.createAndBindSession(conv.id) };
4378
+ return { sessionId: await this.createAndBindSession(conv.id), created: true };
3197
4379
  }
3198
4380
  /**
3199
4381
  * Create a fresh OpenCode session for a conversation, cache the binding, and
@@ -3204,7 +4386,12 @@ var ChannelDriver = class _ChannelDriver {
3204
4386
  const directory = await this.resolveOpenCodeDirectory();
3205
4387
  const sessionId = await createOpenCodeSession(this.port, directory);
3206
4388
  this.sessions.set(conversationId, sessionId);
3207
- await this.persistSession(conversationId, sessionId).catch(() => {
4389
+ await this.persistSession(conversationId, sessionId).catch((err) => {
4390
+ this.log({
4391
+ level: "warn",
4392
+ 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)}`,
4393
+ conversation_id: conversationId
4394
+ });
3208
4395
  });
3209
4396
  return sessionId;
3210
4397
  }
@@ -3421,9 +4608,10 @@ var ChannelDriver = class _ChannelDriver {
3421
4608
  * opencode reports ACTIVELY `running` is watched to completion (its liveness
3422
4609
  * heartbeat keeps the cron off its row), while a re-adopted turn that is paused
3423
4610
  * awaiting a human — or queued/unreachable — is still bounded by `deadline` and
3424
- * handed to the cron. The old "the `deadline` must settle before the ~15-min
3425
- * cron or they double-drive" reasoning is superseded: liveness now settles the
3426
- * actively-running case; `deadline` settles the rest. `dispatchedAt` stays `now`
4611
+ * handed to the cron. Real invariant (#965): the cron MAY reclaim a row this
4612
+ * runner still holds; a reclaimed row that already ran is never re-dispatched
4613
+ * while opencode reports its turn ongoing (readopt's own gate here, and the
4614
+ * `pending`-row re-drive fence, `resolveRedrive`). `dispatchedAt` stays `now`
3427
4615
  * (only the appear-guard uses it).
3428
4616
  *
3429
4617
  * `evidentMessageId` addresses the SERVER row (for markProcessing/markDone);
@@ -3616,9 +4804,8 @@ var ChannelDriver = class _ChannelDriver {
3616
4804
  const awaitingHuman = observedOpen || latchedPaused;
3617
4805
  if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
3618
4806
  const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3619
- let claimed;
3620
4807
  try {
3621
- claimed = await this.markProcessing(
4808
+ await this.markProcessing(
3622
4809
  conv.id,
3623
4810
  inFlight.evidentMessageId,
3624
4811
  sessionId,
@@ -3627,23 +4814,24 @@ var ChannelDriver = class _ChannelDriver {
3627
4814
  );
3628
4815
  } catch (err) {
3629
4816
  if (err instanceof ChannelAuthError) throw err;
3630
- this.log({
3631
- level: "warn",
3632
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3633
- conversation_id: conv.id,
3634
- message_id: inFlight.evidentMessageId
3635
- });
3636
- return;
4817
+ if (err instanceof ChannelTerminalError) {
4818
+ this.log({
4819
+ level: "error",
4820
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (terminal HTTP ${err.status}) \u2014 the server definitively refused the swap`,
4821
+ conversation_id: conv.id,
4822
+ message_id: inFlight.evidentMessageId
4823
+ });
4824
+ } else {
4825
+ this.log({
4826
+ level: "warn",
4827
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
4828
+ conversation_id: conv.id,
4829
+ message_id: inFlight.evidentMessageId
4830
+ });
4831
+ return;
4832
+ }
3637
4833
  }
3638
4834
  inFlight.started = true;
3639
- if (!claimed) {
3640
- this.log({
3641
- level: "debug",
3642
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
3643
- conversation_id: conv.id,
3644
- message_id: inFlight.evidentMessageId
3645
- });
3646
- }
3647
4835
  }
3648
4836
  if (state === "done") {
3649
4837
  await this.settleMessageDone(sessionId, watcher, inFlight, messages);
@@ -3983,7 +5171,10 @@ var ChannelDriver = class _ChannelDriver {
3983
5171
  * re-dispatched (at most once, see `forceReadoptRun`):
3984
5172
  * - `done` → `markDone` now (guarded like the watcher's done branch);
3985
5173
  * - `failed` → `markFailed` with the surfaced error (issue #182), so an
3986
- * errored turn is reported failed on restart, NOT re-dispatched;
5174
+ * errored turn is reported failed on restart, NOT re-dispatched
5175
+ * EXCEPT a restart-ABORTED turn under a not-ongoing session,
5176
+ * which is a restart orphan wearing a terminal error and is
5177
+ * re-dispatched instead (issue #1310, see the branch below);
3987
5178
  * - `running`/`queued` → re-attach a watcher via `registerReadopted` (no re-dispatch),
3988
5179
  * tracking the stored id so the reply correlates by it;
3989
5180
  * - `unknown`/null id → re-dispatch (opencode assigns a fresh id) + attach a watcher.
@@ -4047,7 +5238,16 @@ var ChannelDriver = class _ChannelDriver {
4047
5238
  void this.postSignal(row.conversation_id, row.id, "readopt_done");
4048
5239
  return;
4049
5240
  }
4050
- if (state === "failed") {
5241
+ const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
5242
+ if (restartAborted) {
5243
+ this.log({
5244
+ level: "info",
5245
+ 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`,
5246
+ conversation_id: row.conversation_id,
5247
+ message_id: row.id
5248
+ });
5249
+ }
5250
+ if (state === "failed" && !restartAborted) {
4051
5251
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
4052
5252
  const usage = messageUsage(messages, ocId ?? "");
4053
5253
  const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
@@ -4261,15 +5461,39 @@ var ChannelDriver = class _ChannelDriver {
4261
5461
  }
4262
5462
  if (ocId === null) {
4263
5463
  this.awaitingReadopt.delete(row.id);
5464
+ const streak = this.recordUnconfirmedDispatch(row.id, sessionId);
5465
+ if (streak >= MAX_IDENTICAL_REDRIVE_POLL_FAILURES) {
5466
+ this.unconfirmedDispatchFailures.delete(row.id);
5467
+ this.sessions.delete(readoptConv.id);
5468
+ this.supersede(readoptConv.id, sessionId);
5469
+ const errorMessage = `OpenCode accepted this re-dispatched message ${streak} times in a row but never confirmed its assigned id (session ${sessionId.slice(0, 8)}'s message list could not be read back) \u2014 the session was abandoned; a fresh one is used for further messages.`;
5470
+ this.log({
5471
+ level: "error",
5472
+ message: errorMessage,
5473
+ conversation_id: row.conversation_id,
5474
+ message_id: row.id
5475
+ });
5476
+ await this.markFailed(row.conversation_id, row.id, null, errorMessage).catch((markErr) => {
5477
+ this.log({
5478
+ level: "warn",
5479
+ message: `markFailed PATCH for message ${row.id.slice(0, 8)} (conversation ${row.conversation_id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
5480
+ conversation_id: row.conversation_id,
5481
+ message_id: row.id
5482
+ });
5483
+ });
5484
+ void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
5485
+ return;
5486
+ }
4264
5487
  this.log({
4265
5488
  level: "warn",
4266
- message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
5489
+ message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back (${streak}/${MAX_IDENTICAL_REDRIVE_POLL_FAILURES}) \u2014 leaving un-tracked to retry next drain`,
4267
5490
  conversation_id: row.conversation_id,
4268
5491
  message_id: row.id
4269
5492
  });
4270
5493
  void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
4271
5494
  return;
4272
5495
  }
5496
+ this.unconfirmedDispatchFailures.delete(row.id);
4273
5497
  this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
4274
5498
  this.dispatched.add(row.id);
4275
5499
  this.readopted.add(row.id);
@@ -4325,7 +5549,8 @@ var ChannelDriver = class _ChannelDriver {
4325
5549
  opencode_model: row.opencode_model,
4326
5550
  source_message_id: row.source_message_id,
4327
5551
  slack_user_id: row.slack_user_id,
4328
- attachments: row.attachments ?? null
5552
+ attachments: row.attachments ?? null,
5553
+ opencode_message_id: row.opencode_message_id
4329
5554
  };
4330
5555
  }
4331
5556
  /**
@@ -4917,11 +6142,11 @@ var ChannelDriver = class _ChannelDriver {
4917
6142
  * `ensureSession` guard, not this suppression, is what makes the self-heal
4918
6143
  * stick.
4919
6144
  */
4920
- sessionIdBody(sessionId, conversationId, messageId, status) {
6145
+ sessionIdBody(sessionId, conversationId, messageId, status2) {
4921
6146
  if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
4922
6147
  this.log({
4923
6148
  level: "debug",
4924
- message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
6149
+ 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)}`,
4925
6150
  conversation_id: conversationId,
4926
6151
  message_id: messageId
4927
6152
  });
@@ -4933,18 +6158,22 @@ var ChannelDriver = class _ChannelDriver {
4933
6158
  * opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
4934
6159
  * deep-linked "View in Evident" notice).
4935
6160
  *
4936
- * Return/throw contract (consumed by the watcher's swap-to-running guard):
4937
- * - returns `true` → the server transitioned the row to processing;
4938
- * - returns `false` → the server gave a DEFINITIVE "already-processing"
4939
- * answer (a non-retryable, non-auth status e.g. a
4940
- * conflict because a duplicate already transitioned it),
4941
- * so the caller treats it as already-started and does NOT
4942
- * retry;
4943
- * - throws `ChannelAuthError` on 401/403 (terminal auth failure);
4944
- * - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
4945
- * network-level error from `fetch`) — i.e. NO definitive server response —
4946
- * so the caller leaves the message un-started and retries the swap on the
4947
- * next tick.
6161
+ * Outcome contract (consumed by the watcher's swap-to-running guard):
6162
+ * - resolves (`void`) → the server transitioned the row to
6163
+ * processing (or idempotently confirmed
6164
+ * already-processing that answer is
6165
+ * still a 200, never a refusal);
6166
+ * - throws `ChannelAuthError` → 401/403 (terminal auth failure);
6167
+ * - throws `ChannelTerminalError` → a definitive non-retryable, non-auth 4xx
6168
+ * (404 the row or its conversation is
6169
+ * gone, 400 the update was rejected).
6170
+ * Retrying cannot help;
6171
+ * - throws a plain `Error` → a TRANSIENT failure (retryable 5xx/429
6172
+ * status, or a network-level error from
6173
+ * `fetch`) — i.e. NO definitive server
6174
+ * response — so the caller leaves the
6175
+ * message un-started and retries the swap
6176
+ * on the next tick.
4948
6177
  * A single attempt (no internal retry): the watcher's per-tick loop is the
4949
6178
  * retry vehicle for the swap-to-running.
4950
6179
  */
@@ -4963,11 +6192,11 @@ var ChannelDriver = class _ChannelDriver {
4963
6192
  }
4964
6193
  );
4965
6194
  this.assertAuth(res, "marking message as processing");
4966
- if (res.ok) return true;
6195
+ if (res.ok) return;
4967
6196
  if (isRetryableStatus(res.status)) {
4968
6197
  throw new Error(`marking message as processing: HTTP ${res.status}`);
4969
6198
  }
4970
- return false;
6199
+ throw new ChannelTerminalError(`marking message as processing: HTTP ${res.status}`, res.status);
4971
6200
  }
4972
6201
  /**
4973
6202
  * EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
@@ -5238,10 +6467,16 @@ var ChannelDriver = class _ChannelDriver {
5238
6467
  import chalk5 from "chalk";
5239
6468
  import ora2 from "ora";
5240
6469
  import { select as select2 } from "@inquirer/prompts";
6470
+ var INTERACTIVE_START_TIMEOUT_MS = 3e4;
5241
6471
  async function ensureOpenCodeRunning(ctx) {
5242
6472
  const healthCheck = await checkOpenCodeHealth(ctx.port);
5243
6473
  if (healthCheck.healthy) {
5244
- return { port: ctx.port, process: null, version: healthCheck.version ?? null };
6474
+ return {
6475
+ port: ctx.port,
6476
+ process: null,
6477
+ version: healthCheck.version ?? null,
6478
+ notReadyReason: null
6479
+ };
5245
6480
  }
5246
6481
  const runningInstances = await findHealthyOpenCodeInstances();
5247
6482
  if (runningInstances.length > 0) {
@@ -5282,14 +6517,22 @@ async function ensureOpenCodeRunning(ctx) {
5282
6517
  if (!ctx.interactive) {
5283
6518
  ctx.log(`OpenCode is not running on port ${ctx.port}. Starting it automatically...`);
5284
6519
  const proc = await startOpenCode(ctx.port);
5285
- const health = await waitForOpenCodeHealth(ctx.port, 3e4);
6520
+ const health = await waitForOpenCodeHealth(ctx.port, ctx.startTimeoutMs);
5286
6521
  if (!health.healthy) {
5287
- throw new Error(
5288
- `OpenCode failed to start on port ${ctx.port}. Install with: npm install -g opencode-ai`
5289
- );
6522
+ return {
6523
+ port: ctx.port,
6524
+ process: proc,
6525
+ version: null,
6526
+ notReadyReason: `it did not become healthy within ${Math.round(ctx.startTimeoutMs / 1e3)}s`
6527
+ };
5290
6528
  }
5291
6529
  ctx.log(`OpenCode started on port ${ctx.port}${health.version ? ` (v${health.version})` : ""}`);
5292
- return { port: ctx.port, process: proc, version: health.version ?? null };
6530
+ return {
6531
+ port: ctx.port,
6532
+ process: proc,
6533
+ version: health.version ?? null,
6534
+ notReadyReason: null
6535
+ };
5293
6536
  }
5294
6537
  let port = ctx.port;
5295
6538
  if (isPortInUse(port)) {
@@ -5342,152 +6585,15 @@ Port ${port} is already in use.`));
5342
6585
  if (action === "start") {
5343
6586
  const spinner = ora2("Starting OpenCode...").start();
5344
6587
  const proc = await startOpenCode(port);
5345
- const health = await waitForOpenCodeHealth(port, 3e4);
6588
+ const health = await waitForOpenCodeHealth(port, INTERACTIVE_START_TIMEOUT_MS);
5346
6589
  if (!health.healthy) {
5347
6590
  spinner.fail("Failed to start OpenCode");
5348
6591
  throw new Error("OpenCode failed to start");
5349
6592
  }
5350
6593
  spinner.stop();
5351
- return { port, process: proc, version: health.version ?? null };
5352
- }
5353
- return { port, process: null, version: null };
5354
- }
5355
-
5356
- // src/commands/agent-lookup.ts
5357
- async function readErrorMessage(response) {
5358
- const text = await response.text().catch(() => "");
5359
- if (!text) return response.statusText || void 0;
5360
- try {
5361
- const data = JSON.parse(text);
5362
- const message = data.message ?? data.error;
5363
- if (typeof message === "string" && message.trim()) {
5364
- return message;
5365
- }
5366
- } catch {
5367
- }
5368
- return text.trim() || response.statusText || void 0;
5369
- }
5370
- function authFailureHint(apiUrl, serverMessage) {
5371
- const reason = serverMessage ? `: ${serverMessage}` : "";
5372
- 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.`;
5373
- }
5374
- async function resolveAgentIdFromKey(authHeader) {
5375
- const apiUrl = getApiUrlConfig();
5376
- try {
5377
- const response = await fetch(`${apiUrl}/me`, {
5378
- headers: { Authorization: authHeader }
5379
- });
5380
- if (response.status === 401) {
5381
- const serverMessage = await readErrorMessage(response);
5382
- return { error: authFailureHint(apiUrl, serverMessage), authFailed: true };
5383
- }
5384
- if (!response.ok) {
5385
- const serverMessage = await readErrorMessage(response);
5386
- return {
5387
- error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
5388
- };
5389
- }
5390
- const data = await response.json();
5391
- if (data.auth_type === "agent_key" && data.agent_id) {
5392
- return { agent_id: data.agent_id };
5393
- }
5394
- return {
5395
- error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
5396
- };
5397
- } catch (error2) {
5398
- const message = error2 instanceof Error ? error2.message : "Unknown error";
5399
- return { error: `Failed to resolve runner from key: ${message}` };
5400
- }
5401
- }
5402
- var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
5403
- async function notifyAgentDisconnected(agentId, authHeader) {
5404
- const apiUrl = getApiUrlConfig();
5405
- try {
5406
- const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
5407
- method: "POST",
5408
- headers: { Authorization: authHeader },
5409
- signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5410
- });
5411
- if (!response.ok) {
5412
- const serverMessage = await readErrorMessage(response);
5413
- return {
5414
- ok: false,
5415
- error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5416
- };
5417
- }
5418
- return { ok: true };
5419
- } catch (error2) {
5420
- return { ok: false, error: describeBestEffortError(error2) };
5421
- }
5422
- }
5423
- function describeBestEffortError(error2) {
5424
- const name = error2?.name;
5425
- if (name === "TimeoutError" || name === "AbortError") {
5426
- return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5427
- }
5428
- return error2 instanceof Error ? error2.message : String(error2);
5429
- }
5430
- async function reportMicrovmId(agentId, authHeader, microvmId) {
5431
- try {
5432
- const apiUrl = getApiUrlConfig();
5433
- const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5434
- method: "POST",
5435
- headers: { Authorization: authHeader, "Content-Type": "application/json" },
5436
- body: JSON.stringify({ microvm_id: microvmId }),
5437
- signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5438
- });
5439
- if (!response.ok) {
5440
- const serverMessage = await readErrorMessage(response);
5441
- return {
5442
- ok: false,
5443
- error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5444
- };
5445
- }
5446
- return { ok: true };
5447
- } catch (error2) {
5448
- return { ok: false, error: describeBestEffortError(error2) };
5449
- }
5450
- }
5451
- async function getAgentInfo(agentId, authHeader) {
5452
- const apiUrl = getApiUrlConfig();
5453
- try {
5454
- const response = await fetch(`${apiUrl}/runners/${agentId}`, {
5455
- headers: { Authorization: authHeader }
5456
- });
5457
- if (response.status === 401) {
5458
- const serverMessage = await readErrorMessage(response);
5459
- return { valid: false, error: authFailureHint(apiUrl, serverMessage), authFailed: true };
5460
- }
5461
- if (response.status === 403) {
5462
- const serverMessage = await readErrorMessage(response);
5463
- return {
5464
- valid: false,
5465
- error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
5466
- };
5467
- }
5468
- if (response.status === 404) {
5469
- const serverMessage = await readErrorMessage(response);
5470
- return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
5471
- }
5472
- if (!response.ok) {
5473
- const serverMessage = await readErrorMessage(response);
5474
- return {
5475
- valid: false,
5476
- error: `API error (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
5477
- };
5478
- }
5479
- const agent = await response.json();
5480
- if (agent.agent_type !== "local") {
5481
- return {
5482
- valid: false,
5483
- error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
5484
- };
5485
- }
5486
- return { valid: true, agent };
5487
- } catch (error2) {
5488
- const message = error2 instanceof Error ? error2.message : "Unknown error";
5489
- return { valid: false, error: `Failed to validate runner: ${message}` };
6594
+ return { port, process: proc, version: health.version ?? null, notReadyReason: null };
5490
6595
  }
6596
+ return { port, process: null, version: null, notReadyReason: "you chose to continue without it" };
5491
6597
  }
5492
6598
 
5493
6599
  // src/commands/run.ts
@@ -5526,7 +6632,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
5526
6632
  if (trimmed === "") {
5527
6633
  throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
5528
6634
  }
5529
- const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
6635
+ const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
5530
6636
  if (!isAbsolute2(expanded)) {
5531
6637
  throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
5532
6638
  }
@@ -5547,6 +6653,61 @@ function resolveFileSyncDirectories(raw, homeDir) {
5547
6653
  }
5548
6654
  return directories;
5549
6655
  }
6656
+ var DEFAULT_OPENCODE_START_TIMEOUT_SECONDS = 180;
6657
+ var MAX_OPENCODE_START_TIMEOUT_SECONDS = 3600;
6658
+ var OPENCODE_START_TIMEOUT_ENV = "EVIDENT_OPENCODE_START_TIMEOUT";
6659
+ function resolveOpenCodeStartTimeoutMs(options, env = process.env) {
6660
+ const defaultMs = DEFAULT_OPENCODE_START_TIMEOUT_SECONDS * 1e3;
6661
+ let raw;
6662
+ let source;
6663
+ if (options.opencodeStartTimeout !== void 0) {
6664
+ raw = options.opencodeStartTimeout;
6665
+ source = "--opencode-start-timeout";
6666
+ } else if (env[OPENCODE_START_TIMEOUT_ENV] !== void 0 && env[OPENCODE_START_TIMEOUT_ENV] !== "") {
6667
+ raw = env[OPENCODE_START_TIMEOUT_ENV];
6668
+ source = OPENCODE_START_TIMEOUT_ENV;
6669
+ } else {
6670
+ return { timeoutMs: defaultMs, warnings: [] };
6671
+ }
6672
+ const trimmed = raw.trim();
6673
+ const seconds = Number(trimmed);
6674
+ const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(seconds) && seconds > 0;
6675
+ if (!isPositiveInteger || seconds > MAX_OPENCODE_START_TIMEOUT_SECONDS) {
6676
+ return {
6677
+ timeoutMs: defaultMs,
6678
+ warnings: [
6679
+ `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`
6680
+ ]
6681
+ };
6682
+ }
6683
+ return { timeoutMs: seconds * 1e3, warnings: [] };
6684
+ }
6685
+ var MAX_ACTIVE_SESSIONS_ENV = "EVIDENT_MAX_ACTIVE_SESSIONS";
6686
+ function resolveMaxActiveSessions(options, env = process.env) {
6687
+ let raw;
6688
+ let source;
6689
+ if (options.maxActiveSessions !== void 0) {
6690
+ raw = options.maxActiveSessions;
6691
+ source = "--max-active-sessions";
6692
+ } else if (env[MAX_ACTIVE_SESSIONS_ENV] !== void 0 && env[MAX_ACTIVE_SESSIONS_ENV] !== "") {
6693
+ raw = env[MAX_ACTIVE_SESSIONS_ENV];
6694
+ source = MAX_ACTIVE_SESSIONS_ENV;
6695
+ } else {
6696
+ return { value: void 0, warnings: [] };
6697
+ }
6698
+ const trimmed = raw.trim();
6699
+ const count = Number(trimmed);
6700
+ const isPositiveInteger = /^\d+$/.test(trimmed) && Number.isInteger(count) && count > 0;
6701
+ if (!isPositiveInteger) {
6702
+ return {
6703
+ value: void 0,
6704
+ warnings: [
6705
+ `Ignoring invalid ${source} "${raw}": expected a positive integer; using unlimited`
6706
+ ]
6707
+ };
6708
+ }
6709
+ return { value: count, warnings: [] };
6710
+ }
5550
6711
  function meetsThreshold(state, level) {
5551
6712
  return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
5552
6713
  }
@@ -5568,6 +6729,10 @@ function log2(state, message, level = "info") {
5568
6729
  function logActivity(state, entry) {
5569
6730
  const level = entry.level ?? (entry.type === "error" ? "error" : "info");
5570
6731
  if (!meetsThreshold(state, level)) return;
6732
+ forwardRunnerActivity(
6733
+ { level, message: entry.message, error: entry.error },
6734
+ { agentId: state.agentId, authHeader: state.authHeader }
6735
+ );
5571
6736
  const fullEntry = {
5572
6737
  ...entry,
5573
6738
  level,
@@ -5667,9 +6832,15 @@ async function handleAuthError(state, error2) {
5667
6832
  }
5668
6833
  async function driveChannels(state, driver) {
5669
6834
  let idlePolls = 0;
6835
+ let idleMs = 0;
6836
+ let consecutiveDrainFailures = 0;
6837
+ let unreachableMs = 0;
5670
6838
  let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
5671
6839
  let lastSeenAppliedFiles = driver.fileSyncActivity().appliedFiles;
5672
6840
  while (state.running) {
6841
+ const cycleStartedAtMs = performance.now();
6842
+ let idleThisCycle = false;
6843
+ let unreachableThisCycle = false;
5673
6844
  if (state.connection?.reconnecting && state.connection.reconnectPromise) {
5674
6845
  logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
5675
6846
  if (state.interactive) displayStatus(state);
@@ -5684,17 +6855,23 @@ async function driveChannels(state, driver) {
5684
6855
  );
5685
6856
  try {
5686
6857
  const processed = await driver.drainPending();
6858
+ consecutiveDrainFailures = 0;
6859
+ unreachableMs = 0;
5687
6860
  state.messageCount += processed;
5688
6861
  const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
5689
6862
  lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
5690
6863
  const appliedFiles = driver.fileSyncActivity().appliedFiles;
5691
- const fileActivity = carriedOverFileSync || appliedFiles !== lastSeenAppliedFiles;
6864
+ const filesApplied = appliedFiles !== lastSeenAppliedFiles;
6865
+ const fileActivity = carriedOverFileSync || filesApplied;
5692
6866
  lastSeenAppliedFiles = appliedFiles;
6867
+ if (filesApplied) state.claudeUsageRearm?.();
5693
6868
  if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity || fileActivity) {
5694
6869
  idlePolls = 0;
6870
+ idleMs = 0;
5695
6871
  if (processed > 0 && state.interactive) displayStatus(state);
5696
6872
  } else if (state.idleTimeout !== null) {
5697
6873
  idlePolls++;
6874
+ idleThisCycle = true;
5698
6875
  if (idlePolls === 1) {
5699
6876
  logActivity(state, {
5700
6877
  type: "info",
@@ -5718,21 +6895,44 @@ async function driveChannels(state, driver) {
5718
6895
  const errorMessage = error2 instanceof Error ? error2.message : String(error2);
5719
6896
  logActivity(state, { type: "error", error: `Channel processing error: ${errorMessage}` });
5720
6897
  if (state.interactive) displayStatus(state);
6898
+ if (driver.hasInFlightWatchers()) {
6899
+ consecutiveDrainFailures = 0;
6900
+ unreachableMs = 0;
6901
+ } else if (state.idleTimeout !== null) {
6902
+ consecutiveDrainFailures++;
6903
+ unreachableThisCycle = true;
6904
+ if (consecutiveDrainFailures === 1) {
6905
+ logActivity(state, {
6906
+ type: "info",
6907
+ message: `Cannot reach Evident, will exit if this persists past the idle timeout (timeout: ${state.idleTimeout}s)...`
6908
+ });
6909
+ if (state.interactive) displayStatus(state);
6910
+ }
6911
+ }
5721
6912
  }
5722
6913
  await new Promise((resolve3) => setTimeout(resolve3, CHANNEL_POLL_INTERVAL_MS));
5723
- if (state.idleTimeout !== null && idlePolls >= 2) {
5724
- const idleMs = idlePolls * CHANNEL_POLL_INTERVAL_MS;
5725
- if (idleMs > state.idleTimeout * 1e3) {
5726
- logActivity(state, { type: "info", message: "Idle timeout reached" });
5727
- if (state.interactive) displayStatus(state);
5728
- break;
5729
- }
6914
+ const cycleMs = performance.now() - cycleStartedAtMs;
6915
+ if (idleThisCycle) idleMs += cycleMs;
6916
+ if (unreachableThisCycle) unreachableMs += cycleMs;
6917
+ if (state.idleTimeout !== null && consecutiveDrainFailures >= 2 && unreachableMs > state.idleTimeout * 1e3) {
6918
+ logActivity(state, {
6919
+ type: "info",
6920
+ level: "warn",
6921
+ message: `Exiting: could not reach Evident for ${consecutiveDrainFailures} consecutive polls (${Math.round(unreachableMs / 1e3)}s)`
6922
+ });
6923
+ if (state.interactive) displayStatus(state);
6924
+ break;
6925
+ }
6926
+ if (state.idleTimeout !== null && idlePolls >= 2 && idleMs > state.idleTimeout * 1e3) {
6927
+ logActivity(state, { type: "info", message: "Idle timeout reached" });
6928
+ if (state.interactive) displayStatus(state);
6929
+ break;
5730
6930
  }
5731
6931
  }
5732
6932
  }
5733
6933
  var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
5734
- async function runSweep(state, driver, config2) {
5735
- const mode = `age=${config2.maxAgeMs ?? "\u2014"} count=${config2.maxCount ?? "\u2014"}`;
6934
+ async function runSweep(state, driver, config) {
6935
+ const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
5736
6936
  try {
5737
6937
  const sessions = await listSessions(state.port);
5738
6938
  if (sessions === null) {
@@ -5745,8 +6945,8 @@ async function runSweep(state, driver, config2) {
5745
6945
  const toDelete = selectSessionsToDelete(
5746
6946
  sessions.map((s) => ({ id: s.id, lastActivityMs: sessionLastActivityMs(s) })),
5747
6947
  {
5748
- maxAgeMs: config2.maxAgeMs,
5749
- maxCount: config2.maxCount,
6948
+ maxAgeMs: config.maxAgeMs,
6949
+ maxCount: config.maxCount,
5750
6950
  nowMs: Date.now(),
5751
6951
  protectedIds: driver.protectedSessionIds()
5752
6952
  }
@@ -5782,7 +6982,7 @@ async function runSweep(state, driver, config2) {
5782
6982
  }
5783
6983
  }
5784
6984
  function scheduleSessionCleanup(state, driver, options) {
5785
- const config2 = resolveSessionCleanupConfig(
6985
+ const config = resolveSessionCleanupConfig(
5786
6986
  {
5787
6987
  maxAge: options.sessionCleanupMaxAge,
5788
6988
  maxCount: options.sessionCleanupMaxCount,
@@ -5790,21 +6990,129 @@ function scheduleSessionCleanup(state, driver, options) {
5790
6990
  },
5791
6991
  process.env
5792
6992
  );
5793
- for (const warning2 of config2.warnings) {
6993
+ for (const warning2 of config.warnings) {
5794
6994
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
5795
6995
  }
5796
- if (!config2.enabled) return;
6996
+ if (!config.enabled) return;
5797
6997
  logActivity(state, {
5798
6998
  type: "info",
5799
- message: `Session cleanup enabled (age=${config2.maxAgeMs ?? "\u2014"}, count=${config2.maxCount ?? "\u2014"}, interval=${config2.intervalMs}ms)`
6999
+ message: `Session cleanup enabled (age=${config.maxAgeMs ?? "\u2014"}, count=${config.maxCount ?? "\u2014"}, interval=${config.intervalMs}ms)`
5800
7000
  });
5801
- const interval = setInterval(() => void runSweep(state, driver, config2), config2.intervalMs);
7001
+ const interval = setInterval(() => void runSweep(state, driver, config), config.intervalMs);
5802
7002
  const firstSweep = setTimeout(
5803
- () => void runSweep(state, driver, config2),
7003
+ () => void runSweep(state, driver, config),
5804
7004
  SESSION_CLEANUP_FIRST_SWEEP_MS
5805
7005
  );
5806
7006
  state.sessionCleanupTimers.push(interval, firstSweep);
5807
7007
  }
7008
+ function claudeUsageFailureStreakSuffix(consecutiveFailures) {
7009
+ return consecutiveFailures > 1 ? ` (${consecutiveFailures} consecutive failures)` : "";
7010
+ }
7011
+ function scheduleClaudeUsageReporting(state, options) {
7012
+ const { mode, warnings } = resolveClaudeUsageReportingMode(
7013
+ options.claudeUsageReporting,
7014
+ process.env
7015
+ );
7016
+ for (const warning2 of warnings) {
7017
+ logActivity(state, {
7018
+ type: "info",
7019
+ level: "warn",
7020
+ message: `Claude usage reporting: ${warning2}`
7021
+ });
7022
+ }
7023
+ if (mode === "off") {
7024
+ logActivity(state, {
7025
+ type: "info",
7026
+ level: "debug",
7027
+ message: "Claude usage reporting is off (--claude-usage-reporting off)"
7028
+ });
7029
+ return null;
7030
+ }
7031
+ let consecutiveFailures = 0;
7032
+ let armed = false;
7033
+ let rearmRequested = false;
7034
+ const scheduleNextTick = () => {
7035
+ armed = true;
7036
+ rearmRequested = false;
7037
+ state.claudeUsageTimer = setTimeout(() => void tick(false), nextReportDelayMs());
7038
+ };
7039
+ const rearm = () => {
7040
+ if (armed) {
7041
+ rearmRequested = true;
7042
+ return;
7043
+ }
7044
+ rearmRequested = false;
7045
+ armed = true;
7046
+ state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
7047
+ };
7048
+ const tick = async (isProbe) => {
7049
+ try {
7050
+ const usage = await getClaudeUsage();
7051
+ const result = await reportClaudeUsage(state.agentId, state.authHeader, usage);
7052
+ if (result.ok) {
7053
+ if (consecutiveFailures > 0) {
7054
+ logActivity(state, {
7055
+ type: "info",
7056
+ level: "info",
7057
+ message: "Claude usage reporting recovered"
7058
+ });
7059
+ }
7060
+ consecutiveFailures = 0;
7061
+ logActivity(state, {
7062
+ type: "info",
7063
+ level: "debug",
7064
+ message: "Reported Claude usage to Evident"
7065
+ });
7066
+ } else {
7067
+ consecutiveFailures++;
7068
+ logActivity(state, {
7069
+ type: "info",
7070
+ level: claudeUsageFailureLogLevel(consecutiveFailures),
7071
+ message: `Failed to report Claude usage: ${result.error}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
7072
+ });
7073
+ }
7074
+ scheduleNextTick();
7075
+ } catch (error2) {
7076
+ if (error2 instanceof ClaudeUsageError && isLocalCredentialProblem(error2)) {
7077
+ if (mode === "on") {
7078
+ logActivity(state, {
7079
+ type: "info",
7080
+ level: "warn",
7081
+ 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"
7082
+ });
7083
+ scheduleNextTick();
7084
+ } else if (isProbe) {
7085
+ logActivity(state, {
7086
+ type: "info",
7087
+ level: "debug",
7088
+ message: `Claude usage reporting: ${error2.message}`
7089
+ });
7090
+ armed = false;
7091
+ if (rearmRequested) rearm();
7092
+ } else {
7093
+ logActivity(state, {
7094
+ type: "info",
7095
+ level: "debug",
7096
+ message: `Claude usage reporting: ${error2.message}`
7097
+ });
7098
+ scheduleNextTick();
7099
+ }
7100
+ } else {
7101
+ consecutiveFailures++;
7102
+ const message = error2 instanceof Error ? error2.message : String(error2);
7103
+ logActivity(state, {
7104
+ type: "info",
7105
+ level: claudeUsageFailureLogLevel(consecutiveFailures),
7106
+ message: `Claude usage reporting failed: ${message}${claudeUsageFailureStreakSuffix(consecutiveFailures)}`
7107
+ });
7108
+ scheduleNextTick();
7109
+ }
7110
+ }
7111
+ };
7112
+ armed = true;
7113
+ state.claudeUsageTimer = setTimeout(() => void tick(true), FIRST_REPORT_DELAY_MS);
7114
+ return rearm;
7115
+ }
5808
7116
  async function notifyOffline(state) {
5809
7117
  if (!state.agentId || !state.authHeader) return;
5810
7118
  if (!state.connected) {
@@ -5840,6 +7148,11 @@ async function cleanup(state, opts = {}) {
5840
7148
  clearTimeout(timer);
5841
7149
  }
5842
7150
  state.sessionCleanupTimers = [];
7151
+ if (state.claudeUsageTimer) {
7152
+ clearTimeout(state.claudeUsageTimer);
7153
+ state.claudeUsageTimer = null;
7154
+ }
7155
+ state.claudeUsageRearm = null;
5843
7156
  if (opts.graceful && state.channelDriver) {
5844
7157
  state.channelDriver.stop();
5845
7158
  log2(state, "Draining in-flight channel work before shutdown...");
@@ -5887,7 +7200,7 @@ async function run(options) {
5887
7200
  let fileSyncDirectories;
5888
7201
  try {
5889
7202
  logLevel = resolveLogLevel(options);
5890
- fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
7203
+ fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir3());
5891
7204
  } catch (error2) {
5892
7205
  const message = error2 instanceof Error ? error2.message : String(error2);
5893
7206
  if (options.json) {
@@ -5920,8 +7233,11 @@ async function run(options) {
5920
7233
  messageCount: 0,
5921
7234
  lastProxiedActivityAt: null,
5922
7235
  sessionCleanupTimers: [],
7236
+ claudeUsageTimer: null,
7237
+ claudeUsageRearm: null,
5923
7238
  authHeader: ""
5924
7239
  };
7240
+ setTelemetryAuthProvider(() => ({ authHeader: state.authHeader, agentId: state.agentId }));
5925
7241
  if (fileSyncDirectories.length > 0) {
5926
7242
  log2(state, `File sync enabled for: ${fileSyncDirectories.join(", ")}`);
5927
7243
  } else {
@@ -5998,6 +7314,7 @@ async function run(options) {
5998
7314
  console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
5999
7315
  blank();
6000
7316
  process.exit(1);
7317
+ return;
6001
7318
  }
6002
7319
  blank();
6003
7320
  console.log(chalk6.yellow("You are not logged in to Evident."));
@@ -6042,6 +7359,7 @@ async function run(options) {
6042
7359
  } else {
6043
7360
  printError(resolved.error || "Failed to resolve runner ID from key");
6044
7361
  process.exit(1);
7362
+ return;
6045
7363
  }
6046
7364
  } else {
6047
7365
  printError(
@@ -6055,6 +7373,7 @@ async function run(options) {
6055
7373
  );
6056
7374
  blank();
6057
7375
  process.exit(1);
7376
+ return;
6058
7377
  }
6059
7378
  }
6060
7379
  telemetry.info(
@@ -6110,40 +7429,56 @@ async function run(options) {
6110
7429
  } else {
6111
7430
  log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
6112
7431
  }
7432
+ const { timeoutMs: opencodeStartTimeoutMs, warnings: opencodeStartTimeoutWarnings } = resolveOpenCodeStartTimeoutMs(options, process.env);
7433
+ for (const warning2 of opencodeStartTimeoutWarnings) {
7434
+ logActivity(state, { type: "info", level: "warn", message: warning2 });
7435
+ }
7436
+ const { value: maxActiveSessions, warnings: maxActiveSessionsWarnings } = resolveMaxActiveSessions(options, process.env);
7437
+ for (const warning2 of maxActiveSessionsWarnings) {
7438
+ logActivity(state, { type: "info", level: "warn", message: warning2 });
7439
+ }
6113
7440
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
6114
7441
  try {
6115
7442
  const oc = await ensureOpenCodeRunning({
6116
7443
  port: state.port,
6117
7444
  interactive: state.interactive,
6118
7445
  agentId: state.agentId,
6119
- log: (message) => log2(state, message)
7446
+ log: (message) => log2(state, message),
7447
+ startTimeoutMs: opencodeStartTimeoutMs
6120
7448
  });
6121
7449
  state.port = oc.port;
6122
7450
  state.opencodeProcess = oc.process;
6123
7451
  state.opencodeVersion = oc.version;
6124
- state.opencodeConnected = oc.process !== null || oc.version !== null;
7452
+ state.opencodeConnected = oc.notReadyReason === null;
6125
7453
  const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
6126
7454
  ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
6127
- const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
6128
- if (versionWarning) {
6129
- log2(state, versionWarning, "warn");
6130
- if (state.interactive && !state.json) {
6131
- logActivity(state, { type: "info", level: "warn", message: versionWarning });
7455
+ if (!state.interactive && oc.notReadyReason !== null) {
7456
+ 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}).`;
7457
+ logActivity(state, { type: "info", level: "warn", message });
7458
+ } else {
7459
+ const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
7460
+ if (versionWarning) {
7461
+ log2(state, versionWarning, "warn");
7462
+ if (state.interactive && !state.json) {
7463
+ logActivity(state, { type: "info", level: "warn", message: versionWarning });
7464
+ }
6132
7465
  }
6133
- }
6134
- const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
6135
- if (noProviderWarning) {
6136
- log2(state, noProviderWarning, "warn");
6137
- if (state.interactive && !state.json) {
6138
- logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
6139
- blank();
6140
- console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
6141
- console.log(
6142
- chalk6.dim(
6143
- `Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
6144
- )
6145
- );
6146
- blank();
7466
+ const noProviderWarning = buildNoProviderWarning(
7467
+ await hasAnyConfiguredProvider(state.port)
7468
+ );
7469
+ if (noProviderWarning) {
7470
+ log2(state, noProviderWarning, "warn");
7471
+ if (state.interactive && !state.json) {
7472
+ logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
7473
+ blank();
7474
+ console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
7475
+ console.log(
7476
+ chalk6.dim(
7477
+ `Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
7478
+ )
7479
+ );
7480
+ blank();
7481
+ }
6147
7482
  }
6148
7483
  }
6149
7484
  } catch (error2) {
@@ -6161,7 +7496,8 @@ async function run(options) {
6161
7496
  // #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
6162
7497
  // REJECTED with `file_sync_disabled` on the ack, not silently ignored.
6163
7498
  fileSyncDirectories,
6164
- homeDir: homedir2(),
7499
+ homeDir: homedir3(),
7500
+ maxActiveSessions,
6165
7501
  log: (entry) => (
6166
7502
  // Thread the driver's real level straight through so `debug`/`warn`
6167
7503
  // survive the sink filter (they no longer collapse to info). `type`
@@ -6290,6 +7626,7 @@ async function run(options) {
6290
7626
  throw error2;
6291
7627
  }
6292
7628
  scheduleSessionCleanup(state, channelDriver, options);
7629
+ state.claudeUsageRearm = scheduleClaudeUsageReporting(state, options);
6293
7630
  if (!interactive || state.json) {
6294
7631
  log2(state, "Driving channel messages...");
6295
7632
  }
@@ -6344,21 +7681,32 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
6344
7681
  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);
6345
7682
  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 }));
6346
7683
  program.command("whoami").description("Show the currently logged in user").action(whoami);
7684
+ program.command("status").description("Check whether the configured credentials can reach Evident").option("--json", "Output in JSON format").action((options) => status({ json: options.json }));
7685
+ program.command("claude-usage").description("[spike] Show Claude subscription usage (requires a local `claude login`)").action(claudeUsage);
6347
7686
  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(
6348
7687
  "-a, --agent [id]",
6349
7688
  "Deprecated alias for --runner (still supported; --runner wins if both are given)"
6350
7689
  ).option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
6351
7690
  "--log-level <level>",
6352
7691
  "Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
6353
- ).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("--json", "Output in JSON format").option(
7692
+ ).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(
7693
+ "--opencode-start-timeout <seconds>",
7694
+ "Seconds to wait for OpenCode to become healthy when the runner starts it (default: 180). Env: EVIDENT_OPENCODE_START_TIMEOUT"
7695
+ ).option("--json", "Output in JSON format").option(
6354
7696
  "--session-cleanup-max-age <duration>",
6355
7697
  "Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
6356
7698
  ).option(
6357
7699
  "--session-cleanup-max-count <n>",
6358
7700
  "Keep only the newest N OpenCode sessions. Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_COUNT"
7701
+ ).option(
7702
+ "--max-active-sessions <n>",
7703
+ "Cap how many sessions this runner works on at once (default: unlimited). Env: EVIDENT_MAX_ACTIVE_SESSIONS"
6359
7704
  ).option(
6360
7705
  "--session-cleanup-interval <duration>",
6361
7706
  "How often the cleanup sweep runs (default: 1h). Env: EVIDENT_SESSION_CLEANUP_INTERVAL"
7707
+ ).option(
7708
+ "--claude-usage-reporting <mode>",
7709
+ "Report Claude subscription usage to Evident: auto | on | off (default: auto). Env: EVIDENT_CLAUDE_USAGE_REPORTING"
6362
7710
  ).option(
6363
7711
  "--enable-file-sync-to <dir>",
6364
7712
  "Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
@@ -6366,7 +7714,7 @@ program.command("run").description("Connect to Evident and process messages").op
6366
7714
  []
6367
7715
  ).option(
6368
7716
  "--tunnel-ready-file <path>",
6369
- "Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
7717
+ "Path to write once the tunnel is connected (opt-in; unused unless an operator/image sets it)"
6370
7718
  ).action(
6371
7719
  (options) => {
6372
7720
  run({
@@ -6379,11 +7727,18 @@ program.command("run").description("Connect to Evident and process messages").op
6379
7727
  verbose: options.verbose,
6380
7728
  conversation: options.conversation,
6381
7729
  idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
7730
+ // Raw string — validation/precedence is single-sourced in run.ts's
7731
+ // resolveOpenCodeStartTimeoutMs (flag > EVIDENT_OPENCODE_START_TIMEOUT > 180s default).
7732
+ opencodeStartTimeout: options.opencodeStartTimeout,
6382
7733
  json: options.json,
6383
7734
  // Raw strings — the resolver in run.ts single-sources parsing (M1).
6384
7735
  sessionCleanupMaxAge: options.sessionCleanupMaxAge,
6385
7736
  sessionCleanupMaxCount: options.sessionCleanupMaxCount,
7737
+ maxActiveSessions: options.maxActiveSessions,
6386
7738
  sessionCleanupInterval: options.sessionCleanupInterval,
7739
+ // Raw string — the resolver in run.ts single-sources parsing
7740
+ // (resolveClaudeUsageReportingMode).
7741
+ claudeUsageReporting: options.claudeUsageReporting,
6387
7742
  // Raw values — expansion/validation is single-sourced in run.ts's
6388
7743
  // resolveFileSyncDirectories.
6389
7744
  enableFileSyncTo: options.enableFileSyncTo,