gh-glance 0.11.1 → 0.12.0

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +36 -13
  3. package/index.mjs +1592 -232
  4. package/package.json +2 -2
package/index.mjs CHANGED
@@ -23,6 +23,7 @@
23
23
  process.env.NODE_ENV ??= "production";
24
24
 
25
25
  import { execFile, spawn } from "node:child_process";
26
+ import { AsyncLocalStorage } from "node:async_hooks";
26
27
  import { createHash, randomUUID } from "node:crypto";
27
28
  import {
28
29
  chmodSync,
@@ -43,6 +44,7 @@ import { fileURLToPath } from "node:url";
43
44
  import { promisify } from "node:util";
44
45
 
45
46
  const execFileAsync = promisify(execFile);
47
+ const requestIdentityStorage = new AsyncLocalStorage();
46
48
 
47
49
  // Running as the CLI vs. being imported by a test. Everything with a side
48
50
  // effect -- argv handling, the TTY guard, the preflight, entering the alternate
@@ -616,6 +618,7 @@ function registerLiveAbort(controller) {
616
618
  liveAbort = controller;
617
619
  }
618
620
  function abortLiveRequests() {
621
+ runtimeIdentityCoordinator?.close();
619
622
  liveAbort?.abort();
620
623
  liveAbort = null;
621
624
  }
@@ -627,24 +630,133 @@ function forwardSignalToChild(child, signal) {
627
630
  return child.kill(signal);
628
631
  }
629
632
 
630
- async function runGh(args, { signal, operation } = {}) {
633
+ function inspectAdmittedHttpStart(scope, operation, now = Date.now) {
634
+ const costs = operationCost(operation);
635
+ if (!costs) return { ok: false, reason: "undeclared" };
636
+ if (RATE_RESOURCES.every((resource) => costs[resource] === 0)) return { ok: true };
637
+ const readNow = typeof now === "function" ? now : () => now;
638
+ const snapshot = inspectGovernor(scope, readNow());
639
+ if (!snapshot.ok || snapshot.missing) return snapshot.ok ? { ok: false, reason: "corrupt" } : snapshot;
640
+ const checkedAt = readNow();
641
+ for (const resource of RATE_RESOURCES) {
642
+ if (costs[resource] <= 0) continue;
643
+ const budget = snapshot.value.budgets[resource];
644
+ if (!budget) return { ok: false, reason: "budget-unknown", resource };
645
+ if (budget.blockUntil > checkedAt) return { ok: false, reason: "blocked", resource, retryAt: budget.blockUntil };
646
+ if (budget.resetMs <= checkedAt) return { ok: false, reason: "budget-reset", resource, retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
647
+ }
648
+ return { ok: true };
649
+ }
650
+
651
+ // Re-checked either side of every wait a request can sit in, because admission,
652
+ // the transport permit and the subprocess boundary are each far enough apart
653
+ // that the account can change in between. One definition so the three checks
654
+ // cannot drift, and so a request costs one identity snapshot instead of three.
655
+ function assertBoundCredential(bound) {
656
+ if (bound?.accessKey && bound.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) {
657
+ throw new Error("Credential changed before request start");
658
+ }
659
+ }
660
+
661
+ // GraphQL documents are supplied on stdin rather than as an argument, so a
662
+ // query never lands in argv where `ps` or an error string could carry it, and
663
+ // so its size is bounded by a pipe rather than by the platform argument limit.
664
+ // promisify(execFile) exposes the child, which is what lets stdin be written
665
+ // without giving up the timeout, kill signal, maxBuffer, abort and redaction
666
+ // contracts every other call depends on.
667
+ // The control plane cannot be gated on the capacity it exists to establish.
668
+ // Both observers are exempt from the data-admission recheck for that reason:
669
+ // a resource whose budget is unknown would otherwise refuse the one request
670
+ // that could learn it, and the tab would wait for a number nothing can fetch.
671
+ // They remain bounded elsewhere -- by the rolling attempt allowance, the shared
672
+ // transport permit and the secondary cooldown -- so this is an exemption from
673
+ // data admission, not from accounting.
674
+ const CONTROL_OPERATIONS = ["budget-core-observer", "graphql-observer"];
675
+
676
+ // An operation whose actual cost exceeded its declared bound is paused until its
677
+ // resource resets. Continuing to spend against a bound already known to be wrong
678
+ // is how a reserve gets crossed with every individual request looking admissible.
679
+ // Process-local on purpose: the bound is a property of this build's query text,
680
+ // so a differently-built pane's bound is not this one's to suspend.
681
+ const pausedOperations = new Map();
682
+
683
+ function pauseOperation(operation, untilMs) {
684
+ if (!Number.isFinite(untilMs)) return;
685
+ pausedOperations.set(operation, Math.max(pausedOperations.get(operation) ?? 0, untilMs));
686
+ }
687
+
688
+ function operationPausedUntil(operation, now = Date.now()) {
689
+ const until = pausedOperations.get(operation);
690
+ if (until === undefined) return null;
691
+ if (until <= now) { pausedOperations.delete(operation); return null; }
692
+ return until;
693
+ }
694
+
695
+ async function runGh(args, { signal, operation, input = null } = {}) {
631
696
  if (operationCost(operation) === null) {
632
697
  throw new Error(`undeclared gh operation: ${operation ?? "missing"}`);
633
698
  }
699
+ const bound = requestIdentityStorage.getStore();
700
+ assertBoundCredential(bound);
701
+ const local = ["version", "local-git"].includes(operation);
702
+ let control = null;
703
+ const permit = runtimeIdentityCoordinator && !local
704
+ ? await acquireIdentityHttpPermit(runtimeIdentityCoordinator, { signal }) : null;
634
705
  const startedAt = Date.now();
706
+ let requestError = null;
707
+ let requestStdout = null;
635
708
  try {
636
- const { stdout } = await execFileAsync("gh", args, {
709
+ assertBoundCredential(bound);
710
+ if (runtimeIdentityCoordinator && operation === "budget-core-observer") {
711
+ control = startIdentityControl(runtimeIdentityCoordinator);
712
+ if (!control.ok) throw new Error(identityCoordinationMessage(control.reason));
713
+ }
714
+ // Admission can precede this per-call transport slot by many seconds.
715
+ // A sibling request may publish a shared hold while this call is queued.
716
+ // Keep the started envelope charged, but recheck resource validity at the
717
+ // actual subprocess boundary without reserving the same work again.
718
+ const pausedUntil = operationPausedUntil(operation);
719
+ if (pausedUntil !== null) {
720
+ throw new Error(`Operation paused after an unbounded cost (retry after ${new Date(pausedUntil).toISOString()})`);
721
+ }
722
+ if (bound && !local && !CONTROL_OPERATIONS.includes(operation)) {
723
+ const ready = inspectAdmittedHttpStart(bound, operation);
724
+ if (!ready.ok) throw new Error(`API budget paused (${ready.reason})`);
725
+ }
726
+ assertBoundCredential(bound);
727
+ const pending = execFileAsync("gh", args, {
637
728
  timeout: GH_TIMEOUT_MS,
638
729
  killSignal: "SIGKILL",
639
730
  maxBuffer: GH_MAX_BUFFER,
640
731
  env: { ...process.env, ...GH_ENV_OVERRIDES },
641
732
  signal,
642
733
  });
734
+ if (typeof input === "string") {
735
+ // A child killed by the timeout or the abort signal closes stdin under
736
+ // us; that EPIPE is the kill's consequence, not the failure worth
737
+ // reporting, so let the awaited result below carry the real error.
738
+ pending.child.stdin?.on("error", () => {});
739
+ pending.child.stdin?.end(input);
740
+ }
741
+ const { stdout } = await pending;
742
+ requestStdout = stdout;
643
743
  logGh(args, startedAt, `ok ${stdout.length}B`);
644
744
  return stdout;
645
745
  } catch (err) {
746
+ requestError = err;
646
747
  logGh(args, startedAt, `FAILED ${shortErr(err)}`);
647
748
  throw err;
749
+ } finally {
750
+ if (permit) {
751
+ const release = () => releaseIdentityHttpPermit(runtimeIdentityCoordinator, permit, requestError);
752
+ const released = await retryIdentityCompletion(release, { timeoutMs: IDENTITY_RELEASE_WAIT_MS, shouldRetry: () => !runtimeIdentityCoordinator.isClosed() });
753
+ if (!released.ok) runtimeIdentityCoordinator.deferCompletion(`permit:${permit.nonce}`, release);
754
+ }
755
+ if (control?.ok) {
756
+ const settle = () => settleIdentityControl(runtimeIdentityCoordinator, control.value, requestStdout ?? requestError?.stdout);
757
+ const settled = await retryIdentityCompletion(settle, { timeoutMs: IDENTITY_RELEASE_WAIT_MS, shouldRetry: () => !runtimeIdentityCoordinator.isClosed() });
758
+ if (!settled.ok) runtimeIdentityCoordinator.deferCompletion(`control:${control.value.id}`, settle);
759
+ }
648
760
  }
649
761
  }
650
762
 
@@ -734,14 +846,141 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
734
846
  }
735
847
  }
736
848
 
737
- // The target as `gh` spells it: host-qualified when a host was given, the bare
738
- // slug otherwise -- which is exactly the [HOST/]OWNER/REPO form `gh --repo`
739
- // documents.
740
- function qualifiedRepo() {
741
- const host = runtime.repoExplicit ? effectiveRuntimeHost() : runtime.host;
742
- return host ? `${host}/${runtime.repo}` : runtime.repo;
849
+ // ---------- Explicit GraphQL ----------
850
+
851
+ // The version travels in the query key, so a changed document invalidates its
852
+ // cursors instead of paging a new shape with an old cursor.
853
+ const GRAPHQL_QUERY_VERSION = 1;
854
+ // GitHub prices a connection by the pages it could return, and the row's single
855
+ // label is a nested connection of its own. Two points is the conservative bound
856
+ // for one page; the observer selects nothing but the meter and costs one.
857
+ const GRAPHQL_PAGE_POINTS = 2;
858
+ const GRAPHQL_OBSERVER_POINTS = 1;
859
+ // 50 rows per page and no more than three pages preserves the existing
860
+ // LIST_LIMIT of 150 while making every page a separately admitted request.
861
+ const GRAPHQL_PAGE_SIZE = 50;
862
+ // Two hours: comfortably past any real primary window, and short enough that a
863
+ // nonsense timestamp cannot park the budget in a reset that never arrives.
864
+ const GRAPHQL_MAX_RESET_MS = 2 * 60 * 60 * 1000;
865
+
866
+ // Issues and pull requests are deliberately two documents, not one with two
867
+ // connections: a combined query cannot publish either tab until both halves
868
+ // are complete, which is exactly the completion barrier this phase removes.
869
+ // Both select only what a row renders, which is also what keeps the cost at
870
+ // its declared bound.
871
+ const ISSUE_PAGE_QUERY = `query($owner:String!,$name:String!,$first:Int!,$after:String){
872
+ repository(owner:$owner,name:$name){
873
+ id
874
+ name
875
+ nameWithOwner
876
+ url
877
+ issues(first:$first,after:$after,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){
878
+ totalCount
879
+ pageInfo{hasNextPage endCursor}
880
+ nodes{id number title url updatedAt author{login} labels(first:1){nodes{name}}}
881
+ }
882
+ }
883
+ rateLimit{cost limit used remaining resetAt}
884
+ }`;
885
+
886
+ const PULL_PAGE_QUERY = `query($owner:String!,$name:String!,$first:Int!,$after:String){
887
+ repository(owner:$owner,name:$name){
888
+ id
889
+ name
890
+ nameWithOwner
891
+ url
892
+ pullRequests(first:$first,after:$after,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){
893
+ totalCount
894
+ pageInfo{hasNextPage endCursor}
895
+ nodes{id number title url updatedAt author{login} headRefName isDraft reviewDecision}
896
+ }
897
+ }
898
+ rateLimit{cost limit used remaining resetAt}
899
+ }`;
900
+
901
+ // Selects the meter and nothing else. This is the only claimed GraphQL
902
+ // observer; a data page's counters constrain the same epoch but never open a
903
+ // new one.
904
+ const GRAPHQL_OBSERVER_QUERY = `query{rateLimit{cost limit used remaining resetAt}}`;
905
+
906
+ // Replaces `gh repo view --json`, whose GraphQL cost was real but undeclared.
907
+ const REPOSITORY_QUERY = `query($owner:String!,$name:String!){
908
+ repository(owner:$owner,name:$name){
909
+ id
910
+ nameWithOwner
911
+ url
912
+ viewerPermission
913
+ }
914
+ rateLimit{cost limit used remaining resetAt}
915
+ }`;
916
+
917
+ const GRAPHQL_QUERIES = Object.freeze({
918
+ repository: { query: REPOSITORY_QUERY, connection: null, points: GRAPHQL_OBSERVER_POINTS },
919
+ issues: { query: ISSUE_PAGE_QUERY, connection: "issues", points: GRAPHQL_PAGE_POINTS },
920
+ prs: { query: PULL_PAGE_QUERY, connection: "pullRequests", points: GRAPHQL_PAGE_POINTS },
921
+ observer: { query: GRAPHQL_OBSERVER_QUERY, connection: null, points: GRAPHQL_OBSERVER_POINTS },
922
+ });
923
+
924
+ // `--input -` keeps the document and its variables on stdin as one typed JSON
925
+ // object, so variables stay typed rather than becoming the strings that
926
+ // `-f`/`-F` would produce, and nothing reaches argv.
927
+ function graphqlArgs(host = effectiveRuntimeHost()) {
928
+ return ["api", "-i", "graphql", "--input", "-", ...apiHostArgs(host)];
743
929
  }
744
930
 
931
+ function graphqlInput(kind, variables) {
932
+ const declared = pick(GRAPHQL_QUERIES, kind, null);
933
+ if (!declared) throw new Error(`undeclared graphql query: ${kind}`);
934
+ if (variables === null) throw new Error(UNRESOLVED_REPOSITORY);
935
+ return JSON.stringify({ query: declared.query, variables });
936
+ }
937
+
938
+ // `gh` resolves an omitted repository from the working directory. GraphQL has
939
+ // no such inference, so the slug must be resolved here or the document cannot
940
+ // be built at all -- and running without `--repo` is the documented default.
941
+ //
942
+ // Refusing is deliberate. A half-filled variable set is worse than no request:
943
+ // GitHub answers a missing `String!` with HTTP 200 and an errors array, which
944
+ // is indistinguishable from a denied query, so the tab would report a
945
+ // permission problem for what is really an unresolved target.
946
+ function graphqlRepositoryVariables(repository = effectiveRuntimeRepository()) {
947
+ if (!repository) return null;
948
+ const [owner, name] = repository.split("/");
949
+ return { owner, name };
950
+ }
951
+
952
+ function graphqlPageVariables(after = null, repository = effectiveRuntimeRepository()) {
953
+ const target = graphqlRepositoryVariables(repository);
954
+ return target && { ...target, first: GRAPHQL_PAGE_SIZE, after };
955
+ }
956
+
957
+ // GitHub answers a rejected query with HTTP 200 and an `errors` array, and can
958
+ // answer a partly-resolvable one with both `data` and `errors`. Headers and
959
+ // envelope are therefore read separately: budget evidence stays usable even
960
+ // when the data is not, which is the only way a failed page can still be paid
961
+ // for honestly.
962
+ function parseGraphqlEnvelope(body) {
963
+ let envelope;
964
+ try { envelope = JSON.parse(body); } catch { return { ok: false, reason: "unparseable", errors: [], data: null, rateLimit: null }; }
965
+ if (!isRecord(envelope)) return { ok: false, reason: "unparseable", errors: [], data: null, rateLimit: null };
966
+ const errors = Array.isArray(envelope.errors) ? envelope.errors : [];
967
+ const data = isRecord(envelope.data) ? envelope.data : null;
968
+ const meter = isRecord(data?.rateLimit) ? data.rateLimit : null;
969
+ const number = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : null);
970
+ // A primary window is an hour; anything beyond a generous ceiling is not a
971
+ // reset this app can wait for, and accepting it would pin an epoch that
972
+ // `budget.resetMs <= now` never clears.
973
+ const parsedReset = typeof meter?.resetAt === "string" ? Date.parse(meter.resetAt) : Number.NaN;
974
+ const resetMs = Number.isFinite(parsedReset) && parsedReset <= Date.now() + GRAPHQL_MAX_RESET_MS
975
+ ? parsedReset
976
+ : Number.NaN;
977
+ const rateLimit = meter && [number(meter.limit), number(meter.used), number(meter.remaining)].every((value) => value !== null) && Number.isFinite(resetMs)
978
+ ? { resource: "graphql", limit: meter.limit, used: meter.used, remaining: meter.remaining, resetMs, cost: number(meter.cost) }
979
+ : null;
980
+ return { ok: errors.length === 0 && data !== null, reason: errors.length > 0 ? "graphql-errors" : data === null ? "no-data" : null, errors, data, rateLimit };
981
+ }
982
+
983
+
745
984
  function normalizeHost(value) {
746
985
  if (typeof value !== "string") return null;
747
986
  const host = value.trim().toLowerCase();
@@ -787,6 +1026,51 @@ function resolveEffectiveHost({
787
1026
  return hosts.size === 1 ? [...hosts][0] : null;
788
1027
  }
789
1028
 
1029
+ const UNRESOLVED_REPOSITORY = "Repository could not be resolved; pass --repo owner/name";
1030
+
1031
+ // The owner/name half of the same question resolveEffectiveHost answers for the
1032
+ // host, and in the same precedence: an explicit --repo, then GH_REPO, then an
1033
+ // unambiguous git remote. Ambiguity resolves to null rather than to a guess.
1034
+ function remoteSlug(value) {
1035
+ if (typeof value !== "string" || value.length === 0) return null;
1036
+ let path;
1037
+ if (value.includes("://")) {
1038
+ try { path = new URL(value).pathname; } catch { return null; }
1039
+ } else {
1040
+ path = /^(?:[^@\s]+@)?[^:/\s]+:([^\s]+)$/.exec(value)?.[1] ?? null;
1041
+ }
1042
+ if (path === null) return null;
1043
+ // Trimmed by index rather than with /\/+$/. An end-anchored quantifier is
1044
+ // retried from every position, so a remote URL carrying a long run of slashes
1045
+ // costs quadratic time to reject (CodeQL js/polynomial-redos, high). Remote
1046
+ // URLs are library input: `git remote -v` reports whatever is configured.
1047
+ let start = 0;
1048
+ let end = path.length;
1049
+ while (start < end && path[start] === "/") start += 1;
1050
+ while (end > start && path[end - 1] === "/") end -= 1;
1051
+ const trimmed = path.slice(start, end);
1052
+ const slug = trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
1053
+ return REPO_PATTERN.test(slug) ? slug : null;
1054
+ }
1055
+
1056
+ function resolveEffectiveRepository({ runtimeRepo = null, ghRepo = null, remoteUrls = [] } = {}) {
1057
+ if (runtimeRepo) return runtimeRepo;
1058
+ if (ghRepo) {
1059
+ try { return parseRepoTarget(ghRepo).slug; } catch { return null; }
1060
+ }
1061
+ const slugs = new Set(remoteUrls.map(remoteSlug).filter(Boolean));
1062
+ return slugs.size === 1 ? [...slugs][0] : null;
1063
+ }
1064
+
1065
+ function effectiveRuntimeRepository(options = {}) {
1066
+ return resolveEffectiveRepository({
1067
+ runtimeRepo: runtime.repo,
1068
+ ghRepo: process.env.GH_REPO,
1069
+ remoteUrls: runtimeRemoteUrls,
1070
+ ...options,
1071
+ });
1072
+ }
1073
+
790
1074
  function effectiveRuntimeHost(options = {}) {
791
1075
  return resolveEffectiveHost({
792
1076
  runtimeHost: runtime.host,
@@ -799,11 +1083,6 @@ function effectiveRuntimeHost(options = {}) {
799
1083
  });
800
1084
  }
801
1085
 
802
- // `--repo` for the list subcommands. Empty when unset, so the argv vector stays
803
- // byte-identical to what shipped before.
804
- function repoArgs() {
805
- return runtime.repo ? ["--repo", qualifiedRepo()] : [];
806
- }
807
1086
 
808
1087
  // `gh api` has no --repo, so a host-qualified target has nowhere to put its
809
1088
  // host: GH_REPO=host/owner/repo supplies the owner and repo and *ignores* the
@@ -831,32 +1110,12 @@ function apiPath(path) {
831
1110
  // inline, because `--doctor` reports these vectors and a second copy of them
832
1111
  // would be a report that drifts away from what the dashboard actually sends --
833
1112
  // which is the failure mode the whole diagnostics command exists to rule out.
834
- function repoContextArgs() {
835
- const target = runtime.repo ? qualifiedRepo() : null;
836
- return [
837
- "repo",
838
- "view",
839
- ...(target ? [target] : []),
840
- "--json",
841
- "nameWithOwner,url,viewerPermission",
842
- ];
843
- }
844
-
845
- function authContextArgs() {
846
- return [
847
- "auth",
848
- "status",
849
- "--active",
850
- "--json",
851
- "hosts",
852
- "--jq",
853
- ".hosts | to_entries | map(.key as $host | .value[] | select(.active == true) | {host: $host, login: .login})",
854
- ];
855
- }
856
-
1113
+ // Parses the explicit repository query's envelope. The shape the rest of the
1114
+ // failure context consumes is unchanged; only how it was obtained is.
857
1115
  function parseRepoContext(raw) {
858
1116
  try {
859
- const value = JSON.parse(raw);
1117
+ const envelope = JSON.parse(raw);
1118
+ const value = isRecord(envelope?.data?.repository) ? envelope.data.repository : envelope;
860
1119
  if (
861
1120
  value == null ||
862
1121
  Array.isArray(value) ||
@@ -927,15 +1186,17 @@ async function resolveFailureContext(signal, governor = null, { run = runGh } =
927
1186
  ...governor,
928
1187
  operation: "failure-context:repository",
929
1188
  signal,
930
- run: (admittedSignal) => run(repoContextArgs(), {
1189
+ run: (admittedSignal) => run(graphqlArgs(), {
931
1190
  signal: admittedSignal,
932
1191
  operation: "failure-context:repository",
933
- }),
1192
+ input: graphqlInput("repository", graphqlRepositoryVariables()),
1193
+ }).then((stdout) => parseGhApiResponse(stdout).body),
934
1194
  }).then((result) => result.ok ? result.value : Promise.reject(result.error))
935
1195
  : Promise.reject(new Error("API budget unavailable"));
1196
+ const identity = runtimeIdentityCoordinator?.current();
936
1197
  const [repo, auth] = await Promise.allSettled([
937
1198
  repoCall,
938
- run(authContextArgs(), { signal, operation: "failure-context:auth" }),
1199
+ identity ? Promise.resolve(JSON.stringify([{ host: identity.host, login: identity.login }])) : Promise.reject(new Error("Verified identity unavailable")),
939
1200
  ]);
940
1201
  return buildFailureContext(repo, auth);
941
1202
  }
@@ -987,7 +1248,12 @@ function actionsRunsArgs(limit) {
987
1248
  apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${limit}`),
988
1249
  ...apiHostArgs(),
989
1250
  "--jq",
990
- "[.workflow_runs[] | {databaseId: .id, displayTitle: .display_title, number: .run_number, headBranch: .head_branch, status, conclusion, startedAt: .run_started_at, updatedAt: .updated_at, workflowId: .workflow_id}]",
1251
+ // html_url is projected because a run's page must come from the row itself.
1252
+ // Deriving it from the repository slug only works when --repo was given;
1253
+ // with an inferred repository the app never learns the slug, and the row is
1254
+ // the one place the answer is always present. It costs nothing extra: the
1255
+ // field is already in the response being parsed.
1256
+ "[.workflow_runs[] | {databaseId: .id, displayTitle: .display_title, number: .run_number, headBranch: .head_branch, status, conclusion, startedAt: .run_started_at, updatedAt: .updated_at, workflowId: .workflow_id, url: .html_url}]",
991
1257
  ];
992
1258
  }
993
1259
 
@@ -1015,6 +1281,7 @@ function parseActionsBodies(runsBody, workflowsBody) {
1015
1281
  conclusion: run.conclusion,
1016
1282
  startedAt: run.startedAt,
1017
1283
  updatedAt: run.updatedAt,
1284
+ url: safe(run.url ?? ""),
1018
1285
  }));
1019
1286
  }
1020
1287
 
@@ -1140,85 +1407,188 @@ async function fetchActions(limit, signal, {
1140
1407
  };
1141
1408
  }
1142
1409
 
1143
- // Both list endpoints return created-descending by default, while the row
1144
- // renders updatedAt under a column headed AGE -- so the visible column was
1145
- // non-monotonic (verified against cli/cli: a PR updated yesterday sat below one
1146
- // updated three days earlier) and, worse, truncating to pane height dropped the
1147
- // oldest-*created* rows. A PR opened six months ago and reviewed five minutes
1148
- // ago was invisible. Sorting server-side fixes the ordering and the truncation
1149
- // criterion together; verified that --search composes with --state open and
1150
- // returns the same result count.
1151
- const SORT_RECENT = ["--search", "sort:updated-desc"];
1410
+ // Ordering is part of the query now (`orderBy: UPDATED_AT DESC`) rather than a
1411
+ // `--search sort:` flag. The AGE column renders updatedAt, so a created-order
1412
+ // list made that column non-monotonic and, worse, truncating to pane height
1413
+ // dropped the oldest-*created* rows -- a PR opened six months ago and reviewed
1414
+ // five minutes ago was invisible. The old fix routed `gh issue list` through
1415
+ // GraphQL implicitly and at an unobservable cost; asking for the order directly
1416
+ // keeps the behaviour and makes the price visible.
1417
+
1418
+ // One page of an explicit connection. Headers and envelope are settled
1419
+ // separately so a rejected query still pays honestly: GitHub answers those with
1420
+ // HTTP 200 and an `errors` array, and its counters are trustworthy even when
1421
+ // its data is not.
1422
+ async function fetchGraphqlPage(kind, { signal, after = null, run = runGh, operation, host = effectiveRuntimeHost(), variables = null } = {}) {
1423
+ const declared = pick(GRAPHQL_QUERIES, kind, null);
1424
+ if (!declared) throw new Error(`undeclared graphql query: ${kind}`);
1425
+ let stdout;
1426
+ let failure = null;
1427
+ try {
1428
+ stdout = await run(graphqlArgs(host), { signal, operation, input: graphqlInput(kind, variables ?? graphqlPageVariables(after)) });
1429
+ } catch (error) {
1430
+ failure = error;
1431
+ // A failed call can still carry a complete response body: `gh` exits
1432
+ // non-zero on 4xx/5xx but has already written the headers and envelope,
1433
+ // and that is where trustworthy budget evidence lives.
1434
+ stdout = typeof error?.stdout === "string" ? error.stdout : "";
1435
+ }
1436
+ const parsed = parseGhApiResponse(stdout);
1437
+ const envelope = parseGraphqlEnvelope(parsed.body);
1438
+ // Prefer the envelope's meter: it carries this query's actual `cost`, which
1439
+ // the headers do not. Headers remain the fallback when the body is unusable.
1440
+ const meter = envelope.rateLimit ?? pickRateLimit(parsed.headers);
1441
+ // Absent evidence never refunds. An unobserved page keeps its conservative
1442
+ // reservation rather than being treated as free.
1443
+ const observedCost = Number.isSafeInteger(envelope.rateLimit?.cost)
1444
+ ? envelope.rateLimit.cost
1445
+ : declared.points;
1446
+ const observation = meter && meter.resource === "graphql"
1447
+ ? responseHeaderObservation(meter, observedCost)
1448
+ : null;
1449
+ return {
1450
+ kind,
1451
+ status: parsed.status,
1452
+ ok: failure === null && parsed.status === 200 && envelope.ok,
1453
+ reason: failure ? "transport" : parsed.status !== 200 ? "http" : envelope.reason,
1454
+ failure,
1455
+ data: envelope.data,
1456
+ // Recorded in full even when it exceeds the declared bound: an overrun is
1457
+ // evidence to reconcile, not a bookkeeping error to discard.
1458
+ observedCost,
1459
+ overrun: observedCost > declared.points,
1460
+ observations: observation ? [observation] : [],
1461
+ };
1462
+ }
1152
1463
 
1153
- function issuesArgs() {
1154
- return [
1155
- "issue",
1156
- "list",
1157
- ...repoArgs(),
1158
- "--state",
1159
- "open",
1160
- "--limit",
1161
- String(LIST_LIMIT),
1162
- ...SORT_RECENT,
1163
- "--json",
1164
- "number,title,author,labels,createdAt,updatedAt",
1165
- ];
1464
+ function graphqlConnection(page, connection) {
1465
+ const repository = isRecord(page.data?.repository) ? page.data.repository : null;
1466
+ const value = isRecord(repository?.[connection]) ? repository[connection] : null;
1467
+ return value && Array.isArray(value.nodes) ? value : null;
1166
1468
  }
1167
1469
 
1168
- async function fetchIssues(signal) {
1169
- const raw = await runGh(issuesArgs(), { signal, operation: "tab:issues" });
1470
+ const ISSUE_ROW = (node) => ({
1471
+ number: node.number,
1472
+ title: safe(node.title),
1473
+ author: safe(node.author?.login ?? ""),
1474
+ label: safe(node.labels?.nodes?.[0]?.name ?? ""),
1475
+ updatedAt: node.updatedAt,
1476
+ url: safe(node.url ?? ""),
1477
+ });
1478
+
1479
+ const PR_ROW = (node) => ({
1480
+ number: node.number,
1481
+ title: safe(node.title),
1482
+ author: safe(node.author?.login ?? ""),
1483
+ headRefName: safe(node.headRefName),
1484
+ isDraft: node.isDraft,
1485
+ reviewDecision: node.reviewDecision,
1486
+ updatedAt: node.updatedAt,
1487
+ url: safe(node.url ?? ""),
1488
+ });
1489
+
1490
+ // Walks the connection one explicitly admitted page at a time. The first page
1491
+ // is covered by the tab's own reservation; each later page reserves its own
1492
+ // envelope, so a denial there is an ordinary scheduling outcome rather than a
1493
+ // hidden overspend. Rows already collected survive that denial and the result
1494
+ // is marked incomplete rather than being published as a complete tab.
1495
+ async function fetchGraphqlList(kind, mapRow, {
1496
+ signal,
1497
+ governor = null,
1498
+ previousRaw = null,
1499
+ // Seams, so the paging loop can be exercised without a live governor: the
1500
+ // settlement figure it produces was wrong for months of repositories with
1501
+ // more than one page, and nothing could see it.
1502
+ fetchPage = fetchGraphqlPage,
1503
+ admit = runAdmittedOperation,
1504
+ } = {}) {
1505
+ const connection = GRAPHQL_QUERIES[kind].connection;
1506
+ const operation = `tab:${kind}`;
1507
+ const first = await fetchPage(kind, { signal, operation });
1508
+ if (!first.ok) {
1509
+ const error = first.failure ?? new Error(`GraphQL ${kind} page unavailable (${first.reason})`);
1510
+ error.budgetObservations = first.observations;
1511
+ throw error;
1512
+ }
1513
+ const rows = [];
1514
+ const observations = [...first.observations];
1515
+ // Only the first page. Pages past it opened their own reservation inside
1516
+ // runAdmittedOperation and settle against it, so adding them here charges the
1517
+ // same work twice -- and a settlement above its reservation is rejected as
1518
+ // corrupt, which silently leaks the tab's reservation and discards every
1519
+ // budget observation the fetch gathered.
1520
+ const envelopeSpent = first.observedCost;
1521
+ let spent = first.observedCost;
1522
+ let page = graphqlConnection(first, connection);
1523
+ let incomplete = false;
1524
+ let overrun = first.overrun;
1525
+ let totalCount = page?.totalCount ?? null;
1526
+ for (const node of page?.nodes ?? []) rows.push(mapRow(node));
1527
+ while (page?.pageInfo?.hasNextPage && rows.length < LIST_LIMIT) {
1528
+ const cursor = page.pageInfo.endCursor;
1529
+ if (typeof cursor !== "string" || cursor.length === 0) { incomplete = true; break; }
1530
+ if (!governor) { incomplete = true; break; }
1531
+ const admitted = await admit({
1532
+ ...governor,
1533
+ operation: `page:${kind}`,
1534
+ priority: "background",
1535
+ signal,
1536
+ run: (admittedSignal) => fetchPage(kind, { signal: admittedSignal, after: cursor, operation: `page:${kind}` }),
1537
+ });
1538
+ // A later page that is denied, fails, or returns errors leaves the rows
1539
+ // already gathered exactly as they are. Losing page one because page two
1540
+ // was refused would turn a budget decision into data loss.
1541
+ //
1542
+ // Note that runAdmittedOperation settles these at their *declared* cost, not
1543
+ // their observed one -- conservative, so never an under-charge. A later
1544
+ // page's real meter reaches the ledger through the observations forwarded to
1545
+ // the tab settlement rather than through its own reservation.
1546
+ // Evidence first, and unconditionally: a page that failed as data still
1547
+ // observed the meter, and dropping that is the one thing this phase says
1548
+ // it will not do.
1549
+ if (admitted.value?.observations) observations.push(...admitted.value.observations);
1550
+ if (admitted.value?.overrun) overrun = true;
1551
+ if (!admitted.ok || !admitted.value?.ok) { incomplete = true; break; }
1552
+ const next = admitted.value;
1553
+ spent += next.observedCost;
1554
+ page = graphqlConnection(next, connection);
1555
+ if (!page) { incomplete = true; break; }
1556
+ for (const node of page.nodes) rows.push(mapRow(node));
1557
+ }
1558
+ if (page?.pageInfo?.hasNextPage && rows.length >= LIST_LIMIT) incomplete = true;
1559
+ if (overrun) {
1560
+ // Recorded at its real cost above; suspended here until the resource resets,
1561
+ // which is the soonest a corrected bound could be trusted again.
1562
+ const reset = observations.at(-1)?.resetMs;
1563
+ pauseOperation(operation, Number.isFinite(reset) ? reset + BUDGET_RESET_GRACE_MS : Date.now() + BUDGET_PROBE_MS);
1564
+ }
1565
+ const limited = rows.slice(0, LIST_LIMIT);
1566
+ // Serialized rows are the payload identity the unchanged-frame suppression
1567
+ // and the cache both compare on, so it must cover exactly what is rendered.
1568
+ const raw = JSON.stringify({ v: GRAPHQL_QUERY_VERSION, totalCount, incomplete, rows: limited });
1170
1569
  return {
1171
- raw,
1172
- limit: LIST_LIMIT,
1173
- // Stated rather than omitted so the zero is visibly deliberate: SORT_RECENT's
1174
- // --search routes this through GraphQL, which is a separate budget.
1175
- restSpent: REST_PER_FETCH.issues,
1176
- graphqlSpent: GRAPHQL_PER_FETCH.issues,
1177
- parse: () =>
1178
- parseJsonOutput(raw).map((i) => ({
1179
- number: i.number,
1180
- title: safe(i.title),
1181
- author: safe(i.author?.login ?? ""),
1182
- label: safe(i.labels?.[0]?.name ?? ""),
1183
- updatedAt: i.updatedAt,
1184
- })),
1570
+ raw: raw === previousRaw ? previousRaw : raw,
1571
+ // An incomplete walk reports the rows it has as its own limit, so the
1572
+ // existing truncation indicator fires. "More rows exist than are shown" is
1573
+ // exactly what incomplete means, and publishing 50 of 150 rows as a
1574
+ // complete tab is indistinguishable from a repository with 50 open issues.
1575
+ limit: incomplete ? limited.length : LIST_LIMIT,
1576
+ restSpent: 0,
1577
+ graphqlSpent: envelopeSpent,
1578
+ // What the whole walk cost, for reporting. Never the settlement figure.
1579
+ graphqlSpentTotal: spent,
1580
+ observations,
1581
+ incomplete,
1582
+ parse: () => limited,
1185
1583
  };
1186
1584
  }
1187
1585
 
1188
- function prsArgs() {
1189
- return [
1190
- "pr",
1191
- "list",
1192
- ...repoArgs(),
1193
- "--state",
1194
- "open",
1195
- "--limit",
1196
- String(LIST_LIMIT),
1197
- ...SORT_RECENT,
1198
- "--json",
1199
- "number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
1200
- ];
1586
+ async function fetchIssues(signal, { governor = null, previousRaw = null } = {}) {
1587
+ return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw });
1201
1588
  }
1202
1589
 
1203
- async function fetchPRs(signal) {
1204
- const raw = await runGh(prsArgs(), { signal, operation: "tab:prs" });
1205
- return {
1206
- raw,
1207
- limit: LIST_LIMIT,
1208
- // Zero for the same reason as issues: --search makes this a GraphQL call.
1209
- restSpent: REST_PER_FETCH.prs,
1210
- graphqlSpent: GRAPHQL_PER_FETCH.prs,
1211
- parse: () =>
1212
- parseJsonOutput(raw).map((p) => ({
1213
- number: p.number,
1214
- title: safe(p.title),
1215
- author: safe(p.author?.login ?? ""),
1216
- headRefName: safe(p.headRefName),
1217
- isDraft: p.isDraft,
1218
- reviewDecision: p.reviewDecision,
1219
- updatedAt: p.updatedAt,
1220
- })),
1221
- };
1590
+ async function fetchPRs(signal, { governor = null, previousRaw = null } = {}) {
1591
+ return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw });
1222
1592
  }
1223
1593
 
1224
1594
  // The three alert endpoints were three near-identical 26-line blocks that had
@@ -1320,11 +1690,14 @@ const OPERATION_COSTS = Object.freeze({
1320
1690
  "tab:actions-runs": { core: 1, graphql: 0 },
1321
1691
  "tab:actions-workflows": { core: 1, graphql: 0 },
1322
1692
  "tab:security-endpoint": { core: 1, graphql: 0 },
1323
- "failure-context:repository": { core: 0, graphql: 1 },
1324
- "failure-context:auth": { core: 0, graphql: 0 },
1325
- "open:actions": { core: REST_PER_FETCH.actions, graphql: 0 },
1326
- "open:issues": { core: 0, graphql: GRAPHQL_PER_FETCH.issues },
1327
- "open:prs": { core: 0, graphql: GRAPHQL_PER_FETCH.prs },
1693
+ "failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
1694
+ // Each page past the first reserves its own envelope, so a denial there is a
1695
+ // scheduling outcome rather than an unadmitted overspend.
1696
+ "page:issues": { core: 0, graphql: GRAPHQL_PAGE_POINTS },
1697
+ "page:prs": { core: 0, graphql: GRAPHQL_PAGE_POINTS },
1698
+ // The one claimed GraphQL observer. Not free, and not describable as free:
1699
+ // it selects the meter and pays a point for it.
1700
+ "graphql-observer": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
1328
1701
  "doctor:repository": { core: 0, graphql: 1 },
1329
1702
  "doctor:actions-runs": { core: 1, graphql: 0 },
1330
1703
  "doctor:actions-workflows": { core: 1, graphql: 0 },
@@ -1337,7 +1710,6 @@ const OPERATION_COSTS = Object.freeze({
1337
1710
  "budget-core-observer": { core: 1, graphql: 0 },
1338
1711
  "rate-limit": { core: 0, graphql: 0 },
1339
1712
  "version": { core: 0, graphql: 0 },
1340
- "auth-status": { core: 0, graphql: 0 },
1341
1713
  "local-git": { core: 0, graphql: 0 },
1342
1714
  });
1343
1715
 
@@ -1856,13 +2228,718 @@ function scheduleIntents({
1856
2228
  };
1857
2229
  }
1858
2230
 
2231
+ // ---------- Credential identities and restart-safe coordination ----------
2232
+ const IDENTITY_VERSION = 1;
2233
+ const IDENTITY_ATTEMPT_WINDOW_MS = 15 * 60_000;
2234
+ const IDENTITY_ATTEMPT_LIMIT = 3;
2235
+ const IDENTITY_HOST_ATTEMPT_LIMIT = 12;
2236
+ const IDENTITY_MAX_ATTEMPTS = 4096;
2237
+ const HTTP_START_GAP_MS = 250;
2238
+ const HTTP_MAX_WAITERS = 128;
2239
+ // A permit is reclaimable once it cannot still belong to a live request. The
2240
+ // owner-death check alone cannot see a process that is stopped, or one whose
2241
+ // release lost its lock, so a permit outliving the subprocess timeout it guards
2242
+ // is stale by construction rather than by guess.
2243
+ const HTTP_PERMIT_MAX_MS = GH_TIMEOUT_MS + 5_000;
2244
+ // The longest a primary window can run. An attempt that never obtained reset
2245
+ // evidence is still covered once a full window has demonstrably elapsed since it
2246
+ // started, which is what keeps an unprovable charge from being retained forever.
2247
+ const IDENTITY_UNCERTAIN_MAX_MS = 3_600_000 + BUDGET_RESET_GRACE_MS;
2248
+ // Bound the in-band wait for the registry lock when a request is already
2249
+ // finishing. The permit is what other panes queue behind, so a slow release must
2250
+ // hand off to deferCompletion quickly instead of holding the whole machine.
2251
+ const IDENTITY_RELEASE_WAIT_MS = 500;
2252
+ // How long startup will wait for a warm local identity before painting. Long
2253
+ // enough to win the ordinary case, short enough that a stuck credential helper
2254
+ // costs a cache-cold first frame rather than a blank terminal.
2255
+ const WARM_IDENTITY_WAIT_MS = 1_000;
2256
+ // "busy" and "unwritable" say the registry could not be read or written *right
2257
+ // now*. Every other reason is a statement about the identity itself. Three
2258
+ // callers each drew this line differently -- retryIdentityCompletion retried
2259
+ // both, refresh() treated them as proof the identity was gone, and permit
2260
+ // acquisition threw on them -- so a 250 ms lock wait could be reported as an
2261
+ // account change. One definition keeps them from drifting apart again.
2262
+ const RETRYABLE_COORDINATION_REASONS = ["busy", "unwritable"];
2263
+ // 60 s doubling per prior attempt in the window. The two callers deliberately
2264
+ // count different prior sets -- bootstrap also has a per-host unknown-principal
2265
+ // allowance and the transport start gap to respect, while a known-capacity
2266
+ // observer is admitted normally and only the exceptional branch is throttled --
2267
+ // but the delay they apply is one law and belongs in one place.
2268
+ function attemptRetryDelayMs(priorAttempts) {
2269
+ return 60_000 * 2 ** priorAttempts;
2270
+ }
2271
+
2272
+ function retryableCoordination(reason) {
2273
+ return RETRYABLE_COORDINATION_REASONS.includes(reason);
2274
+ }
2275
+
2276
+ // A dead owner releases its permit immediately; a live owner keeps it only for
2277
+ // as long as a request could still be running under it. Without the second
2278
+ // test a stopped process, or one whose release never won the lock, holds the
2279
+ // single machine-wide permit forever and every pane queues behind it.
2280
+ function staleHttpPermit(permit, now, kill = process.kill.bind(process)) {
2281
+ if (pidIsDead(permit.pid, kill)) return true;
2282
+ return Number.isFinite(permit.startedAt) && now - permit.startedAt > HTTP_PERMIT_MAX_MS;
2283
+ }
2284
+ let runtimeIdentityCoordinator = null;
2285
+
2286
+ function privateIdentityDigest(...parts) {
2287
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
2288
+ }
2289
+
2290
+ function identityRegistryRoot(pathOptions = {}) {
2291
+ return join(dirname(widthPreferencesPath(pathOptions)), "coordination-v2");
2292
+ }
2293
+
2294
+ function credentialEnvironmentNames(host) {
2295
+ return host === "github.com" || host.endsWith(".ghe.com")
2296
+ ? ["GH_TOKEN", "GITHUB_TOKEN"] : ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"];
2297
+ }
2298
+
2299
+ // Deliberately not memoized, despite being called on the render and request
2300
+ // paths. resolveEffectiveCredential brackets the `gh auth token` subprocess with
2301
+ // a before/after comparison of this value to catch a credential swapped while
2302
+ // resolution was in flight, so any cache whose window can cover that gap makes
2303
+ // the process bind the wrong credential -- and a local token can resolve in
2304
+ // under a millisecond. The cost is one statSync plus a digest of four numbers;
2305
+ // reduce the number of callers instead of caching the answer.
2306
+ function credentialConfigurationRevision(host, env = process.env) {
2307
+ const names = credentialEnvironmentNames(host);
2308
+ const selected = names.find((name) => env[name]);
2309
+ if (selected) return privateIdentityDigest(host, String(env[selected]));
2310
+ const directory = effectiveGhConfigDir({ env });
2311
+ let stamp = null;
2312
+ try {
2313
+ const value = statSync(join(directory, "hosts.yml"));
2314
+ stamp = [value.dev, value.ino, value.size, value.mtimeMs];
2315
+ } catch { /* missing local configuration is resolved honestly by gh */ }
2316
+ return privateIdentityDigest(host, directory, stamp);
2317
+ }
2318
+
2319
+ async function resolveEffectiveCredential({ host, env = process.env, runLocalToken } = {}) {
2320
+ host = normalizeHost(host);
2321
+ if (!host) return { ok: false, reason: "unknown-host" };
2322
+ const names = credentialEnvironmentNames(host);
2323
+ const initialRevision = credentialConfigurationRevision(host, env);
2324
+ let token;
2325
+ try {
2326
+ const selected = names.find((name) => env[name]);
2327
+ if (selected) token = String(env[selected]);
2328
+ else {
2329
+ // This seam never passes through runGh/logGh and never exposes the child
2330
+ // error, stdout or stderr. Credentials exist only in this lexical scope.
2331
+ const result = await (runLocalToken ?? (async (args) => execFileAsync("gh", args, {
2332
+ env: { ...env, ...GH_ENV_OVERRIDES }, timeout: GH_TIMEOUT_MS,
2333
+ maxBuffer: 1024 * 1024, killSignal: "SIGKILL",
2334
+ })))(["auth", "token", "--hostname", host]);
2335
+ token = String(typeof result === "string" ? result : result.stdout ?? "").trim();
2336
+ }
2337
+ if (credentialConfigurationRevision(host, env) !== initialRevision) return { ok: false, reason: "credential-changed" };
2338
+ if (!token || /\s/.test(token)) return { ok: false, reason: "credential-unavailable" };
2339
+ const credentialKey = privateIdentityDigest("credential-v1", host, token);
2340
+ token = null;
2341
+ return { ok: true, value: { host, credentialKey, configurationRevision: initialRevision } };
2342
+ } catch {
2343
+ return { ok: false, reason: "credential-unavailable" };
2344
+ }
2345
+ }
2346
+
2347
+ function emptyIdentityRegistry() {
2348
+ return { version: IDENTITY_VERSION, migration: { activated: false, holdUntil: 0 }, identities: {}, attempts: {}, hosts: {} };
2349
+ }
2350
+
2351
+ function normalizeIdentityRegistry(raw) {
2352
+ if (!exactKeys(raw, ["version", "migration", "identities", "attempts", "hosts"]) || raw.version !== IDENTITY_VERSION ||
2353
+ !exactKeys(raw.migration, ["activated", "holdUntil"]) || typeof raw.migration.activated !== "boolean" ||
2354
+ !Number.isFinite(raw.migration.holdUntil) || raw.migration.holdUntil < 0 ||
2355
+ !isRecord(raw.identities) || !isRecord(raw.attempts) || !isRecord(raw.hosts) ||
2356
+ Object.keys(raw.attempts).length > IDENTITY_MAX_ATTEMPTS) return null;
2357
+ const digest = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
2358
+ const time = (value) => Number.isFinite(value) && value >= 0;
2359
+ for (const [key, identity] of Object.entries(raw.identities)) {
2360
+ if (!digest(key) || !exactKeys(identity, ["host", "kind", "id", "login", "quotaKey", "accessKey", "generation", "observedAt"]) ||
2361
+ !normalizeHost(identity.host) || identity.kind !== "user" || !Number.isSafeInteger(identity.id) || identity.id <= 0 ||
2362
+ typeof identity.login !== "string" || identity.login !== safe(identity.login) || !digest(identity.quotaKey) || !digest(identity.accessKey) ||
2363
+ !Number.isSafeInteger(identity.generation) || identity.generation < 1 || !time(identity.observedAt)) return null;
2364
+ }
2365
+ for (const [id, attempt] of Object.entries(raw.attempts)) {
2366
+ if (!validGovernorId(id) || !exactKeys(attempt, ["credentialKey", "host", "resource", "startedAt", "retryAt", "ownerPid", "nonce", "status", "quotaKey", "resetMs", "accounted", "imported", "exceptional"]) ||
2367
+ !digest(attempt.credentialKey) || !normalizeHost(attempt.host) || !RATE_RESOURCES.includes(attempt.resource) ||
2368
+ !time(attempt.startedAt) || !time(attempt.retryAt) || !Number.isSafeInteger(attempt.ownerPid) || attempt.ownerPid < 1 || !validGovernorId(attempt.nonce) ||
2369
+ !["started", "finished", "uncertain"].includes(attempt.status) || (attempt.quotaKey !== null && !digest(attempt.quotaKey)) ||
2370
+ (attempt.resetMs !== null && !time(attempt.resetMs)) || typeof attempt.accounted !== "boolean" || typeof attempt.imported !== "boolean" || typeof attempt.exceptional !== "boolean") return null;
2371
+ }
2372
+ for (const [host, transport] of Object.entries(raw.hosts)) {
2373
+ if (!isRecord(transport)) return null;
2374
+ const keys = ["lastStartedAt", "cooldownUntil", "permit"];
2375
+ if (Object.hasOwn(transport, "waiters")) keys.push("waiters");
2376
+ if (!normalizeHost(host) || !exactKeys(transport, keys) ||
2377
+ !time(transport.lastStartedAt) || !time(transport.cooldownUntil)) return null;
2378
+ if (transport.permit !== null && (!exactKeys(transport.permit, ["pid", "nonce", "startedAt"]) ||
2379
+ !Number.isSafeInteger(transport.permit.pid) || transport.permit.pid < 1 || !validGovernorId(transport.permit.nonce) || !time(transport.permit.startedAt))) return null;
2380
+ if (!Object.hasOwn(transport, "waiters")) transport.waiters = [];
2381
+ if (!Array.isArray(transport.waiters) || transport.waiters.length > HTTP_MAX_WAITERS ||
2382
+ new Set(transport.waiters.map((waiter) => waiter?.nonce)).size !== transport.waiters.length ||
2383
+ transport.waiters.some((waiter) => !exactKeys(waiter, ["pid", "nonce", "queuedAt", "deadline"]) ||
2384
+ !Number.isSafeInteger(waiter.pid) || waiter.pid < 1 || !validGovernorId(waiter.nonce) ||
2385
+ !time(waiter.queuedAt) || !time(waiter.deadline) || waiter.deadline <= waiter.queuedAt ||
2386
+ waiter.deadline - waiter.queuedAt > GH_TIMEOUT_MS)) return null;
2387
+ }
2388
+ return raw;
2389
+ }
2390
+
2391
+ function withIdentityRegistry(root, operation, { now = Date.now(), kill = process.kill.bind(process) } = {}) {
2392
+ const path = join(root, "registry.json");
2393
+ const scope = { path, hash: "identity-registry", host: "github.com", identityProvider: null, kill };
2394
+ return withGovernorLock(scope, () => {
2395
+ // Read and parse are separated for the same reason readGovernorState
2396
+ // separates them: EACCES, EIO or EMFILE mean "try again", while unparseable
2397
+ // bytes mean "stop and preserve the evidence". Collapsing the two reported a
2398
+ // transient descriptor shortage as permanent corruption.
2399
+ let raw = null;
2400
+ try { raw = readFileSync(path, "utf8"); }
2401
+ catch (error) { if (error?.code !== "ENOENT") return { ok: false, reason: "unwritable" }; }
2402
+ let state;
2403
+ if (raw === null) state = emptyIdentityRegistry();
2404
+ else {
2405
+ try { state = normalizeIdentityRegistry(JSON.parse(raw)); }
2406
+ catch { return { ok: false, reason: "corrupt" }; }
2407
+ }
2408
+ if (!state) return { ok: false, reason: "corrupt" };
2409
+ // Captured before the prunes below so their deletions still count as a
2410
+ // change. Every governor operation -- including read-only inspection --
2411
+ // funnels through here, and each one used to serialize and rename the
2412
+ // registry while holding the one machine-wide lock.
2413
+ const before = JSON.stringify(state);
2414
+ for (const transport of Object.values(state.hosts)) {
2415
+ transport.waiters = transport.waiters.filter((waiter) => waiter.deadline > now && !pidIsDead(waiter.pid, kill));
2416
+ }
2417
+ for (const [id, attempt] of Object.entries(state.attempts)) {
2418
+ // Past its rolling window *and* past any backoff it still imposes. retryAt
2419
+ // is what carries known exhaustion (settlement pushes it out to reset plus
2420
+ // grace, beyond the window), so testing it is what lets an aged-out entry
2421
+ // be dropped without forgiving a hold that is still in force.
2422
+ if (attempt.startedAt + IDENTITY_ATTEMPT_WINDOW_MS > now || attempt.retryAt > now) continue;
2423
+ const identity = state.identities[attempt.credentialKey];
2424
+ // No identity for this credential means no quota scope, so there is no
2425
+ // ledger receipt the attempt could be holding and nothing to reconcile.
2426
+ // These are the failed bootstraps -- they never reach importIdentityDebts,
2427
+ // so they used to be immortal and wedged claimIdentityBootstrap at
2428
+ // IDENTITY_MAX_ATTEMPTS permanently, with deleting registry.json by hand
2429
+ // as the only way out.
2430
+ if (!identity) { delete state.attempts[id]; continue; }
2431
+ const scope = { ...createQuotaScope({ ...identity, credentialKey: attempt.credentialKey }, { root, now: () => now, kill }), rootLocked: true };
2432
+ const retired = withGovernorLock(scope, () => {
2433
+ const ledger = readIdentityQuotaState(state, scope, now);
2434
+ if (!ledger.ok || ledger.missing) return ledger.ok ? { ok: false, reason: "corrupt" } : ledger;
2435
+ const budget = ledger.value.budgets[attempt.resource];
2436
+ const resetCovered = attempt.resetMs !== null
2437
+ ? now >= attempt.resetMs && budget?.resetMs > attempt.resetMs
2438
+ // An attempt whose observer never returned core evidence has no reset
2439
+ // of its own to wait for. A window boundary proven to fall after it
2440
+ // started covers its charge just as well, and is the only thing that
2441
+ // stops the entry from being retained for the life of the install.
2442
+ : budget?.resetMs > attempt.startedAt + IDENTITY_UNCERTAIN_MAX_MS;
2443
+ if (!attempt.accounted && !resetCovered) return { ok: true, value: false };
2444
+ delete ledger.value.reservations[`reservation:${id}`];
2445
+ const write = writeGovernorState(scope.path, ledger.value);
2446
+ return write.ok ? { ok: true, value: true } : write;
2447
+ });
2448
+ if (!retired.ok) return retired;
2449
+ if (retired.value) delete state.attempts[id];
2450
+ }
2451
+ const result = operation(state, now);
2452
+ if (!normalizeIdentityRegistry(state)) return { ok: false, reason: "corrupt" };
2453
+ if (JSON.stringify(state) === before) return result;
2454
+ const written = writeGovernorState(path, state);
2455
+ return written.ok ? result : written;
2456
+ });
2457
+ }
2458
+
2459
+ function inspectIdentityRegistry(root, options) {
2460
+ return withIdentityRegistry(root, (state) => ({ ok: true, value: structuredClone(state) }), options);
2461
+ }
2462
+
2463
+ function inspectLegacyMigration(root, state, now) {
2464
+ let names;
2465
+ try { names = readdirSync(dirname(root)).filter((name) => /^rate-governor-v1-[0-9a-f]{64}\.json$/.test(name)); }
2466
+ catch (error) { return { ok: false, reason: error.code === "ENOENT" ? "unwritable" : "corrupt" }; }
2467
+ let holdUntil = state.migration.holdUntil;
2468
+ for (const name of names) {
2469
+ let raw;
2470
+ try { raw = JSON.parse(readFileSync(join(dirname(root), name), "utf8")); } catch { return { ok: false, reason: "legacy-corrupt" }; }
2471
+ // Evidence is never rewritten. Recognize only the pinned legacy schemas.
2472
+ // Every protocol an older pane could still be running, newest first. The set
2473
+ // grows as GOVERNOR_STATE_VERSION advances: a version bump makes an old
2474
+ // binary fail closed on its own gate, but this side must still recognise
2475
+ // that binary's file well enough to see it holds a live lease.
2476
+ const legacy = LEGACY_GOVERNOR_VERSIONS
2477
+ .map((version) => normalizeGovernorState(raw, now, { prune: false, acceptVersion: version }))
2478
+ .find(Boolean) ?? migrateGovernorState(raw, now);
2479
+ if (legacy && raw.version === 1 && raw.budgets?.core) {
2480
+ const core = raw.budgets.core;
2481
+ const preserved = normalizeGovernorBudget({ ...core, source: "core-observer", factorBaseline: { epoch: core.epoch, used: core.used, observedAt: core.observedAt }, knownLocalUsed: 0 }, now, "core");
2482
+ if (!preserved) return { ok: false, reason: "legacy-corrupt" };
2483
+ // This is a migration hold view only, never published as new authority.
2484
+ legacy.budgets.core = preserved;
2485
+ legacy.epochs.core = preserved.epoch;
2486
+ }
2487
+ if (!legacy) return { ok: false, reason: "legacy-corrupt" };
2488
+ if (Object.values(legacy.leases).some((lease) => lease.expiresAt > now)) return { ok: false, reason: "restart-required" };
2489
+ for (const resource of RATE_RESOURCES) {
2490
+ const budget = legacy.budgets[resource];
2491
+ holdUntil = Math.max(holdUntil, budget?.blockUntil ?? 0);
2492
+ const uncertain = Object.values(legacy.reservations).some((reservation) =>
2493
+ ["started", "completed"].includes(reservation.status) && reservationCost(reservation, resource, legacy.leases, now) > 0);
2494
+ if (uncertain) {
2495
+ const reset = budget?.resetMs;
2496
+ if (!Number.isFinite(reset)) return { ok: false, reason: "legacy-unresolved" };
2497
+ holdUntil = Math.max(holdUntil, reset + BUDGET_RESET_GRACE_MS);
2498
+ }
2499
+ }
2500
+ }
2501
+ state.migration.holdUntil = holdUntil;
2502
+ if (holdUntil > now) return { ok: false, reason: "migration-hold", retryAt: holdUntil };
2503
+ // Deliberately a record, not a gate. Re-listing the legacy directory on every
2504
+ // operation is what satisfies "detect reappearing legacy leases during
2505
+ // operation": an old binary started after migration must still be able to
2506
+ // pause new admission, and it cannot announce itself through a sentinel it
2507
+ // does not read. Short-circuiting on this flag would be faster and wrong.
2508
+ state.migration.activated = true;
2509
+ return { ok: true };
2510
+ }
2511
+
2512
+ function claimIdentityBootstrap(root, { credentialKey, host, resource = "core", now = Date.now(), kill = process.kill.bind(process) } = {}) {
2513
+ return withIdentityRegistry(root, (state) => {
2514
+ const migration = inspectLegacyMigration(root, state, now);
2515
+ if (!migration.ok) return migration;
2516
+ const transport = state.hosts[host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null };
2517
+ if (transport.permit && staleHttpPermit(transport.permit, now, kill)) transport.permit = null;
2518
+ if (transport.permit || transport.waiters?.length) return { ok: false, reason: "identity-busy", retryAt: now + 1000 };
2519
+ const attempts = Object.values(state.attempts);
2520
+ const recent = attempts.filter((attempt) => attempt.startedAt > now - IDENTITY_ATTEMPT_WINDOW_MS);
2521
+ const own = recent.filter((attempt) => attempt.credentialKey === credentialKey && attempt.resource === resource && attempt.exceptional);
2522
+ const unknown = recent.filter((attempt) => attempt.host === host && attempt.exceptional);
2523
+ const retryAt = Math.max(transport.cooldownUntil, transport.lastStartedAt + HTTP_START_GAP_MS,
2524
+ ...attempts.filter((attempt) => attempt.credentialKey === credentialKey && attempt.resource === resource).map((attempt) => attempt.retryAt),
2525
+ own.length >= IDENTITY_ATTEMPT_LIMIT ? Math.min(...own.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0,
2526
+ unknown.length >= IDENTITY_HOST_ATTEMPT_LIMIT ? Math.min(...unknown.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0);
2527
+ if (retryAt > now) return { ok: false, reason: "identity-backoff", retryAt };
2528
+ if (attempts.length >= IDENTITY_MAX_ATTEMPTS) return { ok: false, reason: "identity-capacity" };
2529
+ const id = governorId();
2530
+ const nonce = governorId();
2531
+ state.attempts[id] = { credentialKey, host, resource, startedAt: now, retryAt: now + attemptRetryDelayMs(own.length),
2532
+ ownerPid: process.pid, nonce, status: "started", quotaKey: null, resetMs: null, accounted: false, imported: false, exceptional: true };
2533
+ transport.permit = { pid: process.pid, nonce, startedAt: now };
2534
+ transport.lastStartedAt = now;
2535
+ return { ok: true, value: { id, nonce } };
2536
+ }, { now, kill });
2537
+ }
2538
+
2539
+ function readIdentityQuotaState(registry, scope, now) {
2540
+ const loaded = readGovernorState(scope.path, now);
2541
+ const known = Object.values(registry.identities).some((identity) => identity.quotaKey === scope.quotaKey) ||
2542
+ Object.values(registry.attempts).some((attempt) => attempt.imported && attempt.quotaKey === scope.quotaKey);
2543
+ return loaded.ok && loaded.missing && known ? { ok: false, reason: "corrupt" } : loaded;
2544
+ }
2545
+
2546
+ function importIdentityDebts(root, state, credentialKey, identity, now) {
2547
+ const scope = { ...createQuotaScope({ ...identity, credentialKey }, { root, now: () => now }), rootLocked: true };
2548
+ return withGovernorLock(scope, () => {
2549
+ const loaded = readIdentityQuotaState(state, scope, now);
2550
+ if (!loaded.ok) return loaded;
2551
+ const transferred = [];
2552
+ for (const [id, attempt] of Object.entries(state.attempts)) {
2553
+ if (attempt.credentialKey !== credentialKey || attempt.imported) continue;
2554
+ const reservationId = `reservation:${id}`;
2555
+ loaded.value.reservations[reservationId] ??= {
2556
+ leaseId: id, intentId: id, costs: { core: 0, graphql: 0, [attempt.resource]: 1 }, actualCosts: null,
2557
+ accountedCosts: { core: 0, graphql: 0, [attempt.resource]: attempt.accounted ? 1 : 0 },
2558
+ notBefore: attempt.startedAt, status: "started", epochs: { core: null, graphql: null },
2559
+ startedAt: attempt.startedAt, completedAt: null, outcome: null,
2560
+ };
2561
+ transferred.push(id);
2562
+ }
2563
+ // importIdentityDebts writes this ledger before the registry transaction
2564
+ // that records the mapping commits. A crash in between leaves a charged
2565
+ // receipt here whose attempt is gone from the registry, and nothing else
2566
+ // ever looks at it again -- it presents only as a permanently smaller
2567
+ // budget. Re-bootstrapping the same credential is exactly when it can be
2568
+ // recognised, and a full window since it started proves a reset covered it.
2569
+ for (const key of Object.keys(loaded.value.reservations)) {
2570
+ const attemptId = /^reservation:(.+)$/.exec(key)?.[1];
2571
+ if (!attemptId || state.attempts[attemptId]) continue;
2572
+ const reservation = loaded.value.reservations[key];
2573
+ if (reservation.startedAt + IDENTITY_UNCERTAIN_MAX_MS > now) continue;
2574
+ delete loaded.value.reservations[key];
2575
+ }
2576
+ const written = writeGovernorState(scope.path, loaded.value);
2577
+ if (!written.ok) return written;
2578
+ // Marked transferred only once the receipts are durable. withIdentityRegistry
2579
+ // persists the registry whatever this returns, so setting these before the
2580
+ // ledger write meant a failed write could leave the registry claiming a debt
2581
+ // had moved to a ledger that never received it -- and the `imported` guard
2582
+ // would then skip those attempts forever, dropping the conservative charge.
2583
+ for (const id of transferred) {
2584
+ state.attempts[id].quotaKey = identity.quotaKey;
2585
+ state.attempts[id].imported = true;
2586
+ }
2587
+ return written;
2588
+ });
2589
+ }
2590
+
2591
+ function finishIdentityBootstrap(root, { credentialKey, id, nonce, response = null, now = Date.now(), isCurrent = () => true } = {}) {
2592
+ return withIdentityRegistry(root, (state) => {
2593
+ const attempt = state.attempts[id];
2594
+ if (!attempt || attempt.nonce !== nonce || attempt.credentialKey !== credentialKey) return { ok: false, reason: "stale" };
2595
+ const transport = state.hosts[attempt.host];
2596
+ if (transport.permit?.nonce === nonce) transport.permit = null;
2597
+ attempt.status = response ? "finished" : "uncertain";
2598
+ const parsed = response?.body;
2599
+ const evidence = response?.rateLimit;
2600
+ applyTransportCooldown(transport, { retryAfter: response?.retryAfter, status: response?.status, secondary: response?.secondary, at: now });
2601
+ if (evidence?.resource === "core" && normalizeBudgetResource(evidence, now)) {
2602
+ attempt.resetMs = evidence.resetMs;
2603
+ if (evidence.remaining === 0) attempt.retryAt = Math.max(attempt.retryAt, evidence.resetMs + BUDGET_RESET_GRACE_MS);
2604
+ }
2605
+ if (!isCurrent()) return { ok: false, reason: "stale" };
2606
+ if ((response?.status ?? 200) !== 200 || !isRecord(parsed) || !Number.isSafeInteger(parsed.id) || parsed.id <= 0 || typeof parsed.login !== "string" ||
2607
+ parsed.login.length === 0 || !evidence || evidence.resource !== "core" || !normalizeBudgetResource(evidence, now)) return { ok: false, reason: "identity-unavailable" };
2608
+ const generation = state.identities[credentialKey]?.generation ?? 1;
2609
+ const identity = { host: attempt.host, kind: "user", id: parsed.id, login: safe(parsed.login),
2610
+ quotaKey: privateIdentityDigest("quota-v2", attempt.host, "user", parsed.id),
2611
+ accessKey: privateIdentityDigest("access-v2", credentialKey, generation), generation, observedAt: now };
2612
+ attempt.accounted = true;
2613
+ for (const previous of Object.values(state.attempts)) {
2614
+ if (previous.credentialKey === credentialKey && previous.resource === "core") previous.resetMs ??= evidence.resetMs;
2615
+ }
2616
+ // Record the mapping only after the ledger contains every debt. A crash
2617
+ // between writes replays stable reservation IDs while the root lock prevents
2618
+ // observers from deleting receipts before the mapping transaction finishes.
2619
+ const imported = importIdentityDebts(root, state, credentialKey, identity, now);
2620
+ if (!imported.ok) return imported;
2621
+ // The admitted identity proof supplies core authority. Retain its validator
2622
+ // so later observers can confirm unchanged identity without primary spend.
2623
+ {
2624
+ const scope = { ...createQuotaScope({ ...identity, credentialKey }, { root, now: () => now }), rootLocked: true };
2625
+ const seeded = withGovernorLock(scope, () => {
2626
+ const ledger = readIdentityQuotaState(state, scope, now);
2627
+ if (!ledger.ok) return ledger;
2628
+ const observation = budgetFromObservation(evidence, ledger.value.budgets.core, now, { resource: "core", source: "core-observer", receivedAt: now, allowEpochChange: true });
2629
+ if (observation.status === "accepted") {
2630
+ ledger.value.budgets.core = observation.budget;
2631
+ ledger.value.epochs.core = observation.budget.epoch;
2632
+ ledger.value.observers.core = { etag: typeof response.etag === "string" ? response.etag : null, outcome: "healthy", at: now, nextAt: now + BUDGET_PROBE_MS };
2633
+ }
2634
+ return writeGovernorState(scope.path, ledger.value);
2635
+ });
2636
+ if (!seeded.ok) return seeded;
2637
+ }
2638
+ if (!isCurrent()) return { ok: false, reason: "stale" };
2639
+ state.identities[credentialKey] = identity;
2640
+ return { ok: true, value: { ...identity, credentialKey } };
2641
+ }, { now });
2642
+ }
2643
+
2644
+ function createSettlementContext(scope, coordinator) {
2645
+ return { scope: { ...scope, identityProvider: null }, isCurrent: () => coordinator.current()?.accessKey === scope.accessKey };
2646
+ }
2647
+
2648
+ function createQuotaScope(identity, { root = identityRegistryRoot(), now = Date.now, identityProvider = null, kill = process.kill.bind(process) } = {}) {
2649
+ return { hash: identity.quotaKey, quotaKey: identity.quotaKey, credentialKey: identity.credentialKey, accessKey: identity.accessKey,
2650
+ path: join(root, `quota-${identity.quotaKey}.json`), coordinationRoot: root, host: identity.host, authIdentity: identity.quotaKey,
2651
+ identityProvider, now, kill };
2652
+ }
2653
+
2654
+ function createIdentityCoordinator({ host, pathOptions = {}, env = process.env, now = Date.now, kill = process.kill.bind(process), runLocalToken, requestIdentity } = {}) {
2655
+ const root = identityRegistryRoot(pathOptions);
2656
+ let current = null;
2657
+ let revision = null;
2658
+ let pending = null;
2659
+ let failure = null;
2660
+ let resolvedCredential = null;
2661
+ let closed = false;
2662
+ const bootstrapAbort = new AbortController();
2663
+ const completions = new Map();
2664
+ const deferCompletion = (key, operation) => completions.set(key, operation);
2665
+ const flushCompletions = async () => {
2666
+ for (const [key, operation] of completions) {
2667
+ const result = await retryIdentityCompletion(operation, { shouldRetry: () => !closed });
2668
+ if (!result.ok && ["busy", "unwritable"].includes(result.reason)) return result;
2669
+ // A malformed/denied proof is a completed durable attempt, not pending
2670
+ // storage work. Drop that callback so ordinary backoff can recover.
2671
+ completions.delete(key);
2672
+ if (!result.ok && result.reason === "corrupt") return result;
2673
+ }
2674
+ return { ok: true };
2675
+ };
2676
+ const resolveHost = () => normalizeHost(typeof host === "function" ? host() : host);
2677
+ const snapshot = () => {
2678
+ if (closed) return null;
2679
+ const selectedHost = resolveHost();
2680
+ if (current && (current.host !== selectedHost || credentialConfigurationRevision(selectedHost, env) !== revision)) current = null;
2681
+ return current;
2682
+ };
2683
+ const refresh = ({ allowBootstrap = true } = {}) => {
2684
+ if (closed) return Promise.resolve({ ok: false, reason: "closed" });
2685
+ if (pending) return pending;
2686
+ pending = (async () => {
2687
+ const completed = await flushCompletions();
2688
+ if (!completed.ok) { failure = completed; return completed; }
2689
+ const selectedHost = resolveHost();
2690
+ const wantedRevision = selectedHost ? credentialConfigurationRevision(selectedHost, env) : null;
2691
+ const resolved = resolvedCredential?.value?.configurationRevision === wantedRevision
2692
+ ? resolvedCredential : await resolveEffectiveCredential({ host: selectedHost, env, runLocalToken });
2693
+ if (resolved.ok) resolvedCredential = resolved;
2694
+ if (closed) return { ok: false, reason: "closed" };
2695
+ if (!resolved.ok) { current = null; failure = resolved; return resolved; }
2696
+ revision = resolved.value.configurationRevision;
2697
+ const { credentialKey } = resolved.value;
2698
+ const stored = withIdentityRegistry(root, (state) => {
2699
+ const migration = inspectLegacyMigration(root, state, now());
2700
+ if (!migration.ok) return migration;
2701
+ const identity = state.identities[credentialKey];
2702
+ return { ok: true, value: identity ? { ...identity, credentialKey } : null };
2703
+ }, { now: now(), kill });
2704
+ // A retryable lock failure says nothing about the identity, and nulling
2705
+ // `current` for one makes ensureScope retire the lease and blank every
2706
+ // tab -- discarding the ETags that make the next round cheap.
2707
+ if (!stored.ok) { if (!retryableCoordination(stored.reason)) current = null; failure = stored; return stored; }
2708
+ if (stored.value) { current = stored.value; failure = null; return { ok: true, value: current }; }
2709
+ if (!allowBootstrap) {
2710
+ failure = { ok: false, reason: "identity-unavailable" };
2711
+ return failure;
2712
+ }
2713
+ const claim = claimIdentityBootstrap(root, { credentialKey, host: selectedHost, now: now(), kill });
2714
+ if (!claim.ok) { if (!retryableCoordination(claim.reason)) current = null; failure = claim; return claim; }
2715
+ const proofRevision = resolved.value.configurationRevision;
2716
+ // Check after claim lock waits as well as inside deferred completion:
2717
+ // either wait can outlive the host/configuration that selected this token.
2718
+ const isCurrent = () => !closed && resolveHost() === selectedHost && credentialConfigurationRevision(selectedHost, env) === proofRevision;
2719
+ let response = null;
2720
+ try {
2721
+ if (isCurrent()) response = await (requestIdentity ?? (async (requestHost, { signal }) => {
2722
+ let stdout;
2723
+ try {
2724
+ ({ stdout } = await execFileAsync("gh", ["api", "-i", "user", "--hostname", requestHost], {
2725
+ env: { ...env, ...GH_ENV_OVERRIDES }, timeout: GH_TIMEOUT_MS, maxBuffer: GH_MAX_BUFFER, killSignal: "SIGKILL", signal,
2726
+ }));
2727
+ } catch (error) { stdout = error.stdout ?? ""; }
2728
+ const parsed = parseGhApiResponse(stdout);
2729
+ let body = null;
2730
+ try { body = JSON.parse(parsed.body); } catch { /* selected error evidence is still useful */ }
2731
+ return { status: parsed.status, body, rateLimit: pickRateLimit(parsed.headers), etag: parsed.headers.etag ?? null,
2732
+ retryAfter: parsed.headers["retry-after"] ?? null, secondary: SECONDARY_LIMIT_PATTERN.test(String(body?.message ?? "")) };
2733
+ }))(selectedHost, { signal: bootstrapAbort.signal });
2734
+ } catch { /* raw authentication errors never leave the credential seam */ }
2735
+ const finish = () => finishIdentityBootstrap(root, { credentialKey, ...claim.value, response, now: now(), isCurrent });
2736
+ const settled = await retryIdentityCompletion(finish, { shouldRetry: () => !closed });
2737
+ if (!settled.ok && ["busy", "unwritable"].includes(settled.reason)) deferCompletion(`bootstrap:${claim.value.id}`, finish);
2738
+ if (!isCurrent()) { current = null; return { ok: false, reason: "stale" }; }
2739
+ current = settled.ok ? settled.value : null;
2740
+ failure = settled.ok ? null : settled;
2741
+ return settled;
2742
+ })().finally(() => { pending = null; });
2743
+ return pending;
2744
+ };
2745
+ return { root, current: snapshot, refresh, deferCompletion, flushCompletions, inspect: () => failure, isClosed: () => closed, close: () => { closed = true; current = null; bootstrapAbort.abort(); } };
2746
+ }
2747
+
2748
+ async function retryIdentityCompletion(operation, { timeoutMs = GH_TIMEOUT_MS, shouldRetry = () => true } = {}) {
2749
+ const deadline = performance.now() + timeoutMs;
2750
+ for (;;) {
2751
+ const result = operation();
2752
+ if (result.ok || !retryableCoordination(result.reason) || !shouldRetry() || performance.now() >= deadline) return result;
2753
+ await new Promise((resolve) => setTimeout(resolve, 50));
2754
+ // Shutdown retains the unresolved receipt; do not keep the CLI alive to
2755
+ // retry a lock owned by another live process. Account switches still retry.
2756
+ if (!shouldRetry()) return result;
2757
+ }
2758
+ }
2759
+
2760
+ function startIdentityControl(coordinator, now = Date.now()) {
2761
+ const identity = coordinator.current();
2762
+ if (!identity) return { ok: false, reason: "identity-unavailable" };
2763
+ return withIdentityRegistry(coordinator.root, (registry) => {
2764
+ const scope = { ...createQuotaScope(identity, { root: coordinator.root, now: () => now }), rootLocked: true };
2765
+ return withGovernorLock(scope, () => {
2766
+ const loaded = readIdentityQuotaState(registry, scope, now);
2767
+ if (!loaded.ok) return loaded;
2768
+ const state = loaded.value;
2769
+ const budget = state.budgets.core;
2770
+ const exceptional = !budget || now >= budget.resetMs + BUDGET_RESET_GRACE_MS;
2771
+ if (budget && now >= budget.resetMs && !exceptional) return { ok: false, reason: "identity-backoff", retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
2772
+ if (!exceptional) {
2773
+ const reserved = Object.values(state.reservations).reduce((sum, reservation) => sum + reservationCost(reservation, "core", state.leases, now), 0);
2774
+ if (budget.remaining - resourceReserve(budget.limit) - reserved < 1) return { ok: false, reason: "identity-backoff", retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
2775
+ }
2776
+ const recent = Object.values(registry.attempts).filter((attempt) => attempt.credentialKey === identity.credentialKey && attempt.resource === "core" && attempt.exceptional && attempt.startedAt > now - IDENTITY_ATTEMPT_WINDOW_MS);
2777
+ const retryAt = Math.max(0, ...recent.map((attempt) => attempt.retryAt), recent.length >= IDENTITY_ATTEMPT_LIMIT ? Math.min(...recent.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0);
2778
+ if (exceptional && retryAt > now) return { ok: false, reason: "identity-backoff", retryAt };
2779
+ if (Object.keys(registry.attempts).length >= IDENTITY_MAX_ATTEMPTS || Object.keys(state.reservations).length >= GOVERNOR_MAX_RESERVATIONS) return { ok: false, reason: "identity-capacity" };
2780
+ const id = governorId();
2781
+ registry.attempts[id] = { credentialKey: identity.credentialKey, host: identity.host, resource: "core", startedAt: now,
2782
+ retryAt: exceptional ? now + attemptRetryDelayMs(recent.length) : now, ownerPid: process.pid, nonce: governorId(), status: "started",
2783
+ quotaKey: identity.quotaKey, resetMs: budget?.resetMs ?? null, accounted: false, imported: true, exceptional };
2784
+ state.reservations[`reservation:${id}`] = { leaseId: id, intentId: id, costs: { core: 1, graphql: 0 }, actualCosts: null,
2785
+ accountedCosts: { core: 0, graphql: 0 }, notBefore: now, status: "started", epochs: { core: budget?.epoch ?? null, graphql: null },
2786
+ startedAt: now, completedAt: null, outcome: null };
2787
+ const written = writeGovernorState(scope.path, state);
2788
+ return written.ok ? { ok: true, value: { id, identity } } : written;
2789
+ });
2790
+ }, { now });
2791
+ }
2792
+
2793
+ function settleIdentityControl(coordinator, control, stdout, now = Date.now()) {
2794
+ return withIdentityRegistry(coordinator.root, (registry) => {
2795
+ const attempt = registry.attempts[control.id];
2796
+ if (!attempt) return { ok: false, reason: "stale" };
2797
+ const parsed = parseGhApiResponse(stdout ?? "");
2798
+ const evidence = pickRateLimit(parsed.headers);
2799
+ const proven = [200, 304].includes(parsed.status) && evidence?.resource === "core";
2800
+ attempt.status = proven ? "finished" : "uncertain";
2801
+ attempt.accounted = proven;
2802
+ if (evidence?.resource === "core") {
2803
+ attempt.resetMs = evidence.resetMs;
2804
+ if (evidence.remaining === 0) attempt.retryAt = Math.max(attempt.retryAt, evidence.resetMs + BUDGET_RESET_GRACE_MS);
2805
+ }
2806
+ const scope = { ...createQuotaScope(control.identity, { root: coordinator.root, now: () => now }), rootLocked: true };
2807
+ return withGovernorLock(scope, () => {
2808
+ const loaded = readIdentityQuotaState(registry, scope, now);
2809
+ if (!loaded.ok) return loaded;
2810
+ const reservation = loaded.value.reservations[`reservation:${control.id}`];
2811
+ if (!reservation) return { ok: false, reason: "stale" };
2812
+ if (proven) {
2813
+ const cost = parsed.status === 304 ? 0 : 1;
2814
+ reservation.status = "completed";
2815
+ reservation.completedAt = now;
2816
+ reservation.outcome = "measured-success";
2817
+ reservation.actualCosts = { core: cost, graphql: 0 };
2818
+ reservation.accountedCosts = { core: cost, graphql: 0 };
2819
+ }
2820
+ return writeGovernorState(scope.path, loaded.value);
2821
+ });
2822
+ }, { now });
2823
+ }
2824
+
2825
+ async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now } = {}) {
2826
+ const flushed = await coordinator.flushCompletions?.();
2827
+ if (flushed && !flushed.ok) throw new Error(identityCoordinationMessage(flushed.reason));
2828
+ const identity = coordinator.current();
2829
+ if (!identity) throw new Error("Verified identity unavailable");
2830
+ const queuedAt = now();
2831
+ const deadline = queuedAt + GH_TIMEOUT_MS;
2832
+ const nonce = governorId();
2833
+ let acquired = false;
2834
+ try {
2835
+ for (;;) {
2836
+ if (signal?.aborted) throw new Error("Request cancelled");
2837
+ if (coordinator.current()?.accessKey !== identity.accessKey) throw new Error("Credential changed before request start");
2838
+ if (now() >= deadline) throw new Error(identityCoordinationMessage("transport-busy"));
2839
+ const result = withIdentityRegistry(coordinator.root, (state) => {
2840
+ const migration = inspectLegacyMigration(coordinator.root, state, now());
2841
+ if (!migration.ok) return migration;
2842
+ if (now() >= deadline) return { ok: false, reason: "transport-busy", retryAt: deadline };
2843
+ const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [] };
2844
+ if (transport.permit && staleHttpPermit(transport.permit, now())) transport.permit = null;
2845
+ // Admission can happen during the mandatory start gap. Persist call
2846
+ // order once so later polling cannot overtake an earlier caller.
2847
+ if (!transport.waiters.some((waiter) => waiter.nonce === nonce)) {
2848
+ if (transport.waiters.length >= HTTP_MAX_WAITERS) return { ok: false, reason: "identity-capacity" };
2849
+ transport.waiters.push({ pid: process.pid, nonce, queuedAt, deadline });
2850
+ }
2851
+ const retryAt = Math.max(transport.cooldownUntil, transport.lastStartedAt + HTTP_START_GAP_MS);
2852
+ if (transport.permit || transport.waiters[0].nonce !== nonce || retryAt > now()) {
2853
+ return { ok: false, reason: "transport-busy", retryAt: Math.max(retryAt, now() + 50) };
2854
+ }
2855
+ transport.waiters.shift();
2856
+ transport.permit = { pid: process.pid, nonce, startedAt: now() };
2857
+ transport.lastStartedAt = now();
2858
+ return { ok: true, value: { nonce, host: identity.host } };
2859
+ }, { now: now() });
2860
+ if (result.ok) { acquired = true; return result.value; }
2861
+ // A contended registry lock is the same class of wait as a held permit:
2862
+ // both mean "not yet", not "no identity". Only transport-busy carries a
2863
+ // retryAt, so give the rest the poll interval the loop already uses.
2864
+ const retryAt = result.retryAt ?? now() + 50;
2865
+ if ((result.reason !== "transport-busy" && !retryableCoordination(result.reason)) ||
2866
+ now() >= deadline || retryAt >= deadline) throw new Error(identityCoordinationMessage(result.reason));
2867
+ await new Promise((resolve) => setTimeout(resolve, Math.max(1, Math.min(50, retryAt - now()))));
2868
+ }
2869
+ } finally {
2870
+ if (!acquired) {
2871
+ const cleanup = () => withIdentityRegistry(coordinator.root, (state) => {
2872
+ const transport = state.hosts[identity.host];
2873
+ if (transport) transport.waiters = transport.waiters.filter((waiter) => waiter.nonce !== nonce);
2874
+ return { ok: true };
2875
+ }, { now: now() });
2876
+ const cleaned = cleanup();
2877
+ if (!cleaned.ok) coordinator.deferCompletion?.(`waiter:${nonce}`, cleanup);
2878
+ }
2879
+ }
2880
+ }
2881
+
2882
+ // One cooldown law, two call sites: the bootstrap observer reads its evidence
2883
+ // from a parsed response, the permit release from a failed subprocess's captured
2884
+ // output. They had already drifted into two implementations with two different
2885
+ // secondary-limit detectors -- which is how a tuning applied to one quietly
2886
+ // stops applying to the other.
2887
+ const SECONDARY_LIMIT_PATTERN = /secondary rate|abuse/i;
2888
+
2889
+ function transportCooldownDeadline({ retryAfter, status, secondary, at }) {
2890
+ const deadline = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
2891
+ ? at + Number(retryAfter) * 1000
2892
+ : Date.parse(retryAfter ?? "");
2893
+ if (Number.isFinite(deadline)) return deadline;
2894
+ return status === 429 || secondary === true ? at + 60_000 : null;
2895
+ }
2896
+
2897
+ function applyTransportCooldown(transport, evidence) {
2898
+ const deadline = transportCooldownDeadline(evidence);
2899
+ if (deadline !== null) transport.cooldownUntil = Math.max(transport.cooldownUntil, deadline);
2900
+ }
2901
+
2902
+ function releaseIdentityHttpPermit(coordinator, permit, error = null) {
2903
+ return withIdentityRegistry(coordinator.root, (state) => {
2904
+ const transport = state.hosts[permit.host];
2905
+ if (transport?.permit?.nonce === permit.nonce) transport.permit = null;
2906
+ if (transport && error) {
2907
+ const response = parseGhApiResponse(error.stdout ?? "");
2908
+ applyTransportCooldown(transport, {
2909
+ retryAfter: response.headers["retry-after"],
2910
+ status: response.status,
2911
+ secondary: SECONDARY_LIMIT_PATTERN.test(String(error.stderr ?? "")),
2912
+ at: Date.now(),
2913
+ });
2914
+ }
2915
+ return { ok: true };
2916
+ });
2917
+ }
2918
+
2919
+ function identityCoordinationMessage(reason) {
2920
+ if (reason === "restart-required") return "Restart required: close older gh-glance panes";
2921
+ if (["migration-hold", "legacy-unresolved"].includes(reason)) return "Upgrade waiting for legacy quota reset";
2922
+ if (["legacy-corrupt", "corrupt"].includes(reason)) return "Coordination state unavailable; evidence preserved";
2923
+ if (reason === "transport-busy") return "Shared HTTP request or cooldown in progress";
2924
+ if (retryableCoordination(reason)) return "Coordinating with your other panes";
2925
+ if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
2926
+ return "Verified identity unavailable; waiting to retry";
2927
+ }
2928
+
1859
2929
  // ---------- Shared account governor ----------
1860
2930
 
1861
2931
  // The scope version is deliberately stable. Stored protocol revisions must use
1862
2932
  // the same file so an older process sees the new exact shape and fails closed
1863
2933
  // instead of coordinating through a second file.
1864
2934
  const GOVERNOR_SCOPE_VERSION = 1;
1865
- const GOVERNOR_STATE_VERSION = 2;
2935
+ // 3: GraphQL budgets carry `graphql-observer` provenance. An older build does
2936
+ // not know that source and would reject the budget -- and normalizeGovernorState
2937
+ // turns one rejected budget into total loss, discarding every live pane's
2938
+ // leases, intents and reservations. The version gate makes it fail closed on the
2939
+ // file instead, which is the whole point of having one.
2940
+ const GOVERNOR_STATE_VERSION = 3;
2941
+ // Readable-as-evidence, never written: 2 is the shape a 0.11.x pane still holds.
2942
+ const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 2];
1866
2943
  const GOVERNOR_MAX_LEASES = 128;
1867
2944
  const GOVERNOR_MAX_INTENTS = 512;
1868
2945
  const GOVERNOR_MAX_RESERVATIONS = 512;
@@ -1965,7 +3042,8 @@ function currentGovernorScope(scope) {
1965
3042
  } catch {
1966
3043
  return { ok: false, reason: "stale" };
1967
3044
  }
1968
- const hash = governorScopeHash(current?.effectiveHost, current?.authIdentity);
3045
+ const hash = scope.quotaKey ? current?.quotaKey : governorScopeHash(current?.effectiveHost, current?.authIdentity);
3046
+ if (scope.quotaKey && current?.accessKey !== scope.accessKey) return { ok: false, reason: "stale" };
1969
3047
  return hash === scope.hash ? { ok: true, value: scope } : { ok: false, reason: "stale" };
1970
3048
  }
1971
3049
 
@@ -2013,8 +3091,12 @@ function normalizeGovernorBudget(raw, nowMs, resource = null) {
2013
3091
  (raw.blockReason !== null && !GOVERNOR_BLOCK_REASONS.has(raw.blockReason)) ||
2014
3092
  (raw.roundRobinCursor !== null && !validGovernorId(raw.roundRobinCursor)) ||
2015
3093
  !Number.isFinite(raw.lastExternalFactor) || raw.lastExternalFactor < 1 ||
2016
- !["response-header", "core-observer", "rate-limit-probe"].includes(raw.source) ||
2017
- raw.source === "rate-limit-probe" && resource === "core" ||
3094
+ !["response-header", "core-observer", "graphql-observer", "rate-limit-probe"].includes(raw.source) ||
3095
+ // "rate-limit-probe" is retained as readable legacy provenance only: ledgers
3096
+ // written before the claimed GraphQL observer existed still carry it. Nothing
3097
+ // writes it any more, and neither it nor the GraphQL observer may ever stand
3098
+ // as core authority.
3099
+ ["rate-limit-probe", "graphql-observer"].includes(raw.source) && resource === "core" ||
2018
3100
  !exactKeys(raw.factorBaseline, ["epoch", "used", "observedAt"]) ||
2019
3101
  !validGovernorEpoch(raw.factorBaseline.epoch) ||
2020
3102
  !Number.isSafeInteger(raw.factorBaseline.used) || raw.factorBaseline.used < 0 ||
@@ -2144,11 +3226,15 @@ function normalizeManualProbe(raw, nowMs) {
2144
3226
  return { ...raw };
2145
3227
  }
2146
3228
 
2147
- function normalizeGovernorState(raw, nowMs, { prune = true } = {}) {
3229
+ // `acceptVersion` exists for one caller: the legacy inspector, which must be
3230
+ // able to *read* a still-running older pane's file to see its live leases. It
3231
+ // never publishes what it reads. Every other caller takes the default, so a
3232
+ // version this build does not write is unreadable and fails closed.
3233
+ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVERNOR_STATE_VERSION } = {}) {
2148
3234
  if (!exactKeys(raw, [
2149
3235
  "version", "epochs", "budgets", "observers", "probeClaim", "probeOutcome", "leases",
2150
3236
  "intents", "reservations", "manualProbe",
2151
- ]) || raw.version !== GOVERNOR_STATE_VERSION) return null;
3237
+ ]) || raw.version !== acceptVersion) return null;
2152
3238
  if (
2153
3239
  !exactKeys(raw.epochs, RATE_RESOURCES) ||
2154
3240
  RATE_RESOURCES.some((resource) => raw.epochs[resource] !== null && !validGovernorEpoch(raw.epochs[resource])) ||
@@ -2220,16 +3306,15 @@ function migrateGovernorState(raw, nowMs) {
2220
3306
  // preserve it as bootstrap evidence. GraphQL remains valid.
2221
3307
  delete migrated.budgets.core;
2222
3308
  migrated.epochs.core = null;
2223
- if (migrated.budgets.graphql) {
2224
- const budget = migrated.budgets.graphql;
2225
- budget.source = "rate-limit-probe";
2226
- budget.factorBaseline = {
2227
- epoch: budget.epoch,
2228
- used: budget.used,
2229
- observedAt: budget.observedAt,
2230
- };
2231
- budget.knownLocalUsed = 0;
2232
- }
3309
+ // The v1 GraphQL value came from /rate_limit, for the same reason the core
3310
+ // value did, and is dropped for the same reason: this phase makes that
3311
+ // endpoint explicitly non-authoritative, and "never a source of spendable
3312
+ // capacity" cannot have an exception for numbers that arrived before the rule.
3313
+ // Keeping it would let a migrated pane admit GraphQL work against it and let
3314
+ // it establish the epoch later headers refine. The claimed observer
3315
+ // re-establishes capacity on the next probe.
3316
+ delete migrated.budgets.graphql;
3317
+ migrated.epochs.graphql = null;
2233
3318
  for (const reservation of Object.values(migrated.reservations)) {
2234
3319
  reservation.accountedCosts = { core: 0, graphql: 0 };
2235
3320
  }
@@ -2410,6 +3495,16 @@ function withGovernorLock(scope, operation, {
2410
3495
  kill = scope?.kill ?? process.kill.bind(process),
2411
3496
  observeArtifact = null,
2412
3497
  } = {}) {
3498
+ if (scope?.coordinationRoot && !scope.rootLocked) {
3499
+ return withIdentityRegistry(scope.coordinationRoot, (registry, at) => {
3500
+ const migration = inspectLegacyMigration(scope.coordinationRoot, registry, at);
3501
+ if (!migration.ok) return migration;
3502
+ if (registry.identities[scope.credentialKey]) {
3503
+ try { statSync(scope.path); } catch { return { ok: false, reason: "corrupt" }; }
3504
+ }
3505
+ return withGovernorLock({ ...scope, rootLocked: true }, operation, { pid, nonce, waitMs, kill, observeArtifact });
3506
+ }, { now: scopeNow(scope), kill });
3507
+ }
2413
3508
  const current = currentGovernorScope(scope);
2414
3509
  if (!current.ok) return current;
2415
3510
  const lockPath = `${scope.path}.lock`;
@@ -2714,8 +3809,8 @@ function budgetFromObservation(raw, previous, nowMs, {
2714
3809
  if (
2715
3810
  !normalized || normalized.resetMs <= nowMs || receivedAt > nowMs ||
2716
3811
  !RATE_RESOURCES.includes(resource) ||
2717
- !["response-header", "core-observer", "rate-limit-probe"].includes(source) ||
2718
- resource === "core" && source === "rate-limit-probe"
3812
+ !["response-header", "core-observer", "graphql-observer", "rate-limit-probe"].includes(source) ||
3813
+ resource === "core" && ["rate-limit-probe", "graphql-observer"].includes(source)
2719
3814
  ) return { status: "invalid" };
2720
3815
  const epoch = `${normalized.limit}:${normalized.resetMs}`;
2721
3816
  // Endpoint responses can carry cached or endpoint-specific rate headers.
@@ -2776,12 +3871,16 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
2776
3871
  if (!claim.resources.every((resource) => budgets?.[resource])) {
2777
3872
  return { ok: false, reason: "corrupt" };
2778
3873
  }
3874
+ const observerCosts = {};
2779
3875
  for (const resource of claim.resources) {
2780
3876
  const supplied = budgets?.[resource];
2781
3877
  if (!supplied) continue;
2782
3878
  const raw = supplied.budget ?? supplied;
2783
- const source = supplied.source ?? (resource === "core" ? "core-observer" : "rate-limit-probe");
2784
- const ownerSource = resource === "core" ? "core-observer" : "rate-limit-probe";
3879
+ // The owner source names what actually established the number. Calling a
3880
+ // GraphQL budget a "rate-limit-probe" was accurate when /rate_limit supplied
3881
+ // it and is a lie now that the claimed observer does.
3882
+ const source = supplied.source ?? (resource === "core" ? "core-observer" : "graphql-observer");
3883
+ const ownerSource = resource === "core" ? "core-observer" : "graphql-observer";
2785
3884
  if (source !== ownerSource) return { ok: false, reason: "corrupt" };
2786
3885
  const observed = budgetFromObservation(raw, state.budgets[resource], at, {
2787
3886
  resource,
@@ -2792,6 +3891,10 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
2792
3891
  });
2793
3892
  if (observed.status === "invalid") return { ok: false, reason: "corrupt" };
2794
3893
  if (observed.status === "ignored") continue;
3894
+ // The observer's own cost is local spend. Left out, the external-factor
3895
+ // reconciliation below attributes it to other clients, and the governor
3896
+ // throttles the user's real work to make room for its own probing.
3897
+ observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
2795
3898
  nextBudgets[resource] = observed.budget;
2796
3899
  if (resource === "core" && supplied.blocked === true) {
2797
3900
  nextBudgets[resource].blockUntil = observed.budget.resetMs;
@@ -2820,7 +3923,7 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
2820
3923
  const factor = nextExternalFactor({
2821
3924
  lastExternalFactor: previous.lastExternalFactor,
2822
3925
  globalUsedDelta: nextBudgets[resource].used - baseline.used,
2823
- sharedCompletedDelta: previous.knownLocalUsed + legacyCompletedDelta,
3926
+ sharedCompletedDelta: previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0),
2824
3927
  });
2825
3928
  if (factor === null) return { ok: false, reason: "corrupt" };
2826
3929
  nextBudgets[resource].lastExternalFactor = factor;
@@ -2970,16 +4073,23 @@ function registerIntent(scope, intent) {
2970
4073
  });
2971
4074
  }
2972
4075
 
2973
- function readIntentDecision(scope, intentId, nowMs) {
4076
+ function readIntentDecision(scope, intentId, nowMs, previousEvidence = null) {
2974
4077
  return mutateGovernor(scope, nowMs, (state, at) => {
2975
4078
  const scheduled = scheduleGovernorState(state, at);
2976
4079
  const reservationId = `reservation:${intentId}`;
2977
4080
  const reservation = state.reservations[reservationId];
2978
4081
  const grant = scheduled.grants.find((item) => item.id === reservationId);
4082
+ // UI provenance stays process-local. Reuse it only for this same pending
4083
+ // intent while its original owners and pane count remain valid.
4084
+ const retainedSharing = previousEvidence?.intentId === intentId &&
4085
+ reservation?.status === "scheduled" && reservation.notBefore > at &&
4086
+ currentSharedLaneProvenance(previousEvidence, state.leases, at).waitCause
4087
+ ? sharedLaneEvidence(previousEvidence) : {};
2979
4088
  if (reservation) return { value: {
2980
4089
  status: reservation.status,
2981
4090
  reservationId,
2982
4091
  ...reservation,
4092
+ ...retainedSharing,
2983
4093
  ...sharedLaneEvidence(grant),
2984
4094
  } };
2985
4095
  if (!state.intents[intentId]) return { ok: false, reason: "stale" };
@@ -3309,10 +4419,24 @@ async function refreshSharedBudget(scope, leaseId, signal, {
3309
4419
  failProbeClaim(scope, leaseId, nonce, now());
3310
4420
  return renewed;
3311
4421
  }
4422
+ // Known exhaustion waits for its actual reset. The GraphQL observer costs a
4423
+ // point, so probing an already-empty budget to be told it is still empty digs
4424
+ // the hole deeper -- the /rate_limit read this replaced was free and could
4425
+ // afford to be unconditional.
4426
+ const beforeRead = inspect(scope, now());
4427
+ const spendable = resources.filter((resource) => {
4428
+ if (resource !== "graphql" || !beforeRead.ok) return true;
4429
+ const budget = beforeRead.value.budgets.graphql;
4430
+ return !(budget && budget.remaining <= 0 && now() < budget.resetMs + BUDGET_RESET_GRACE_MS);
4431
+ });
4432
+ if (spendable.length === 0) {
4433
+ failProbeClaim(scope, leaseId, nonce, now());
4434
+ return { ok: false, reason: "budget-reset" };
4435
+ }
3312
4436
  let budgets;
3313
4437
  try {
3314
- budgets = await readBudgets(signal, scope.host, {
3315
- resources,
4438
+ budgets = await requestIdentityStorage.run(scope, () => readBudgets(signal, scope.host, {
4439
+ resources: spendable,
3316
4440
  coreEtag,
3317
4441
  renewClaim: async () => {
3318
4442
  const startedAt = now();
@@ -3328,7 +4452,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
3328
4452
  if (extended.ok) renewed.value.leaseUntil = extended.value.leaseUntil;
3329
4453
  return extended.ok;
3330
4454
  },
3331
- });
4455
+ }));
3332
4456
  } catch {
3333
4457
  budgets = null;
3334
4458
  }
@@ -3417,8 +4541,13 @@ function mergeAlertRows(groups) {
3417
4541
  // mid-session is picked up within the hour.
3418
4542
  const alertBackoff = new Map();
3419
4543
 
4544
+ function backoffStorageKey(key) {
4545
+ const access = requestIdentityStorage.getStore()?.accessKey ?? runtimeIdentityCoordinator?.current()?.accessKey;
4546
+ return access ? `${access}\0${key}` : key;
4547
+ }
4548
+
3420
4549
  function backoffActive(key, now) {
3421
- const state = alertBackoff.get(key);
4550
+ const state = alertBackoff.get(backoffStorageKey(key));
3422
4551
  return Boolean(state) && now < state.until;
3423
4552
  }
3424
4553
 
@@ -3434,13 +4563,13 @@ const FAILURE_LADDER = {
3434
4563
  // which is the drift the ALERT_SOURCES comment below warns about: two copies of
3435
4564
  // this would diverge the first time one of them was fixed.
3436
4565
  function recordFailure(key, now, steps = BACKOFF_STEPS_MS) {
3437
- const previous = alertBackoff.get(key);
4566
+ const previous = alertBackoff.get(backoffStorageKey(key));
3438
4567
  const step = Math.min((previous?.step ?? -1) + 1, steps.length - 1);
3439
- alertBackoff.set(key, { step, until: now + steps[step] });
4568
+ alertBackoff.set(backoffStorageKey(key), { step, until: now + steps[step] });
3440
4569
  }
3441
4570
 
3442
4571
  function clearBackoff(key) {
3443
- alertBackoff.delete(key);
4572
+ alertBackoff.delete(backoffStorageKey(key));
3444
4573
  }
3445
4574
 
3446
4575
  function forcedBackoffKeys(key) {
@@ -3462,7 +4591,7 @@ async function fetchAlertSource(source, signal, now, {
3462
4591
  request = fetchConditionalEntity,
3463
4592
  } = {}) {
3464
4593
  if (backoffActive(source.key, now)) {
3465
- const { note, verdict } = alertBackoff.get(source.key);
4594
+ const { note, verdict } = alertBackoff.get(backoffStorageKey(source.key));
3466
4595
  // The verdict is replayed alongside the note. Replaying only the note left
3467
4596
  // the tab unable to tell "Dependabot is switched off here" from "we cannot
3468
4597
  // see Dependabot" for the whole length of a backoff window.
@@ -3515,7 +4644,10 @@ async function fetchAlertSource(source, signal, now, {
3515
4644
  completedCalls,
3516
4645
  verdict: "ok",
3517
4646
  allNotModified: batch.allNotModified,
3518
- stagedEntities: batch.stagedEntities,
4647
+ // Every other fetchAlertSource path returns a Map. An all-304 batch has
4648
+ // no staged writes, so conditionalBatchResult returns null; normalize it
4649
+ // here before fetchSecurity merges the independently fetched sources.
4650
+ stagedEntities: batch.stagedEntities ?? new Map(),
3519
4651
  observations,
3520
4652
  parse: () => {
3521
4653
  const rows = mergeAlertRows(groups);
@@ -3554,7 +4686,7 @@ async function fetchAlertSource(source, signal, now, {
3554
4686
  const steps = pick(FAILURE_LADDER, verdict, null);
3555
4687
  if (steps) {
3556
4688
  recordFailure(source.key, now, steps);
3557
- Object.assign(alertBackoff.get(source.key), { note, verdict });
4689
+ Object.assign(alertBackoff.get(backoffStorageKey(source.key)), { note, verdict });
3558
4690
  }
3559
4691
  return {
3560
4692
  raw: `unavailable:${note}`,
@@ -3665,36 +4797,72 @@ function reconcileSelectionViewport({ items, key, offset = 0, rows = 1 }) {
3665
4797
  return { key, offset: nextOffset };
3666
4798
  }
3667
4799
 
3668
- // `gh <kind> view --web` for the tabs that have a per-item command. The Security
3669
- // tab is absent on purpose: alerts have no `gh` view subcommand, so there is
3670
- // nothing honest to open.
3671
- const OPENABLE = { actions: "run", issues: "issue", prs: "pr" };
4800
+ // The tabs with a per-item URL. Security is absent on purpose: an alert has no
4801
+ // stable per-item page this app can construct honestly.
4802
+ const OPENABLE = ["actions", "issues", "prs"];
4803
+
4804
+ // The platform's opener, by fixed argv and without a shell, so a URL is an
4805
+ // argument and never a fragment of a command line.
4806
+ // rundll32 rather than `cmd /c start ""`: cmd re-parses its /c string, so a URL
4807
+ // handed to it is a fragment of a command line and its metacharacters are live.
4808
+ // FileProtocolHandler takes the URL as one real argument, which is the whole
4809
+ // point of using execFile without a shell.
4810
+ const BROWSER_OPENERS = {
4811
+ darwin: ["open"],
4812
+ linux: ["xdg-open"],
4813
+ win32: ["rundll32", "url.dll,FileProtocolHandler"],
4814
+ };
3672
4815
 
3673
- // Output is captured rather than inherited. `gh ... --web` prints "Opening ...
3674
- // in your browser" to stdout, and stdout is ink's frame stream -- letting that
3675
- // through would corrupt the diff and the alternate screen.
3676
- //
3677
- // `gh run view` takes the run's databaseId, not its display `number` -- the
3678
- // two are different ID spaces (databaseId is global across GitHub, number is
3679
- // per-workflow and restarts near 1 in every repo), so a run's `number` is
3680
- // almost never a valid databaseId elsewhere and 404s. `gh issue view` and
3681
- // `gh pr view` are the opposite: they take the issue/PR number, and neither
3682
- // row shape carries a databaseId to prefer instead.
3683
- async function openInBrowser(tabKey, item, signal, governor = null, { run = runGh } = {}) {
3684
- const kind = pick(OPENABLE, tabKey, null);
3685
- const id = kind === "run" ? (item?.databaseId ?? item?.number) : (item?.number ?? item?.databaseId);
3686
- if (!kind || id == null) return;
3687
- if (!governor) throw new Error("API budget unavailable; refresh and try again");
3688
- const admitted = await runAdmittedOperation({
3689
- ...governor,
3690
- operation: `open:${tabKey}`,
3691
- signal,
3692
- run: (admittedSignal) => run([kind, "view", ...repoArgs(), String(id), "--web"], {
3693
- signal: admittedSignal,
3694
- operation: `open:${tabKey}`,
3695
- }),
4816
+ // Only https, and only the host this pane is actually talking to. A row is
4817
+ // remote data; without this a crafted `url` could point the user's browser
4818
+ // anywhere, or hand a `file:`/`javascript:` URL to the platform opener.
4819
+ function admittedRowUrl(value, host) {
4820
+ if (typeof value !== "string" || value.length === 0) return null;
4821
+ let parsed;
4822
+ try { parsed = new URL(value); } catch { return null; }
4823
+ if (parsed.protocol !== "https:") return null;
4824
+ return parsed.host === host ? parsed.toString() : null;
4825
+ }
4826
+
4827
+ // Actions rows come from REST and carry no URL, but a run's page is a pure
4828
+ // function of the repository and its databaseId -- both already validated --
4829
+ // so it is derived locally rather than fetched. Issues and PRs select `url` in
4830
+ // their query, which is validated against the same host before use.
4831
+ function rowBrowserUrl(tabKey, item, { host = effectiveRuntimeHost(), repo = effectiveRuntimeRepository() } = {}) {
4832
+ if (!OPENABLE.includes(tabKey) || !isRecord(item) || !host) return null;
4833
+ // The row's own URL first, for every tab. A run only falls back to a derived
4834
+ // URL when the payload predates the projection, and that fallback needs the
4835
+ // slug, which the app only has when --repo was given.
4836
+ const declared = admittedRowUrl(item.url, host);
4837
+ if (declared || tabKey !== "actions") return declared;
4838
+ const id = item.databaseId ?? item.number;
4839
+ if (!Number.isSafeInteger(id) || id <= 0 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(repo ?? ""))) return null;
4840
+ return `https://${host}/${repo}/actions/runs/${id}`;
4841
+ }
4842
+
4843
+ // Opening a page the row already names costs nothing and needs no admission:
4844
+ // `gh <kind> view --web` spent a request to be told a URL that was already in
4845
+ // hand. Output is still captured rather than inherited, because stdout is ink's
4846
+ // frame stream and an opener's chatter would corrupt the alternate screen.
4847
+ async function openInBrowser(tabKey, item, signal, _governor = null, { run = null, host, repo } = {}) {
4848
+ if (!OPENABLE.includes(tabKey)) return;
4849
+ const url = rowBrowserUrl(tabKey, item, {
4850
+ ...(host === undefined ? {} : { host }),
4851
+ ...(repo === undefined ? {} : { repo }),
3696
4852
  });
3697
- if (!admitted.ok) throw admitted.error;
4853
+ // Returning quietly here is how a dead Enter key looks: the row has no usable
4854
+ // page, and the user is entitled to know that rather than press it again.
4855
+ if (!url) throw new Error("This row has no page to open yet; refresh and try again");
4856
+ const opener = pick(BROWSER_OPENERS, process.platform, null);
4857
+ if (!opener) throw new Error(`Opening a browser is not supported on ${process.platform}`);
4858
+ const [command, ...prefix] = opener;
4859
+ const launch = run ?? ((argv) => execFileAsync(argv[0], argv.slice(1), {
4860
+ timeout: GH_TIMEOUT_MS,
4861
+ killSignal: "SIGKILL",
4862
+ maxBuffer: GH_MAX_BUFFER,
4863
+ signal,
4864
+ }));
4865
+ await launch([command, ...prefix, url]);
3698
4866
  }
3699
4867
 
3700
4868
  function createOpenRequestRegistry() {
@@ -3860,10 +5028,16 @@ async function captureGh(args, operation) {
3860
5028
 
3861
5029
  const PROBE_STDERR_LIMIT = 400;
3862
5030
 
3863
- async function probe(name, args, operation) {
5031
+ async function probe(name, args, operation, input = null) {
3864
5032
  const startedAt = Date.now();
5033
+ // A declared local prerequisite the run cannot satisfy is reported, not
5034
+ // attempted: starting a request whose document cannot be built would spend a
5035
+ // reservation to receive a GraphQL error that says nothing about the tenant.
5036
+ if (input === UNRESOLVED_REPOSITORY) {
5037
+ return { name, args, ms: 0, failed: true, stderr: UNRESOLVED_REPOSITORY, http: null, classified: "unavailable" };
5038
+ }
3865
5039
  try {
3866
- const stdout = await runGh(args, { operation });
5040
+ const stdout = await runGh(args, { operation, input });
3867
5041
  return { name, args, ms: Date.now() - startedAt, bytes: stdout.length, classified: "ok" };
3868
5042
  } catch (err) {
3869
5043
  return {
@@ -3944,6 +5118,28 @@ function targetSource() {
3944
5118
  // measures as free (verified: delta 0), so this is safe to run on a diagnostic
3945
5119
  // path. GHES tenants can be configured with a different ceiling, which is
3946
5120
  // exactly why this reports the server's own numbers rather than asserting 5,000.
5121
+ // The claimed GraphQL observer, and the only source of spendable GraphQL
5122
+ // capacity. `/rate_limit` used to supply this number for free, but a counter
5123
+ // obtained for free is not authority: it is another endpoint's view of the
5124
+ // meter, it can lag behind the spend it is meant to bound, and it can never
5125
+ // report what a particular query actually cost. This query costs a point and
5126
+ // says exactly what it cost, which is what makes it reconcilable.
5127
+ async function readGraphqlObserver(signal, host = effectiveRuntimeHost(), {
5128
+ fetchPage = fetchGraphqlPage,
5129
+ } = {}) {
5130
+ const page = await fetchPage("observer", { signal, operation: "graphql-observer", host, variables: {} });
5131
+ const observed = page.observations[0];
5132
+ if (!observed || observed.resource !== "graphql") return null;
5133
+ return {
5134
+ budget: { remaining: observed.remaining, limit: observed.limit, used: observed.used, resetMs: observed.resetMs },
5135
+ receivedAt: observed.receivedAt,
5136
+ cost: page.observedCost,
5137
+ };
5138
+ }
5139
+
5140
+ // Report data only, and explicitly not authority: it is left available because
5141
+ // a diagnostic that can contradict the observer is useful evidence, and pinned
5142
+ // as non-authoritative so it can never be mistaken for spendable capacity.
3947
5143
  async function readRateLimitResources(signal, host = effectiveRuntimeHost()) {
3948
5144
  const raw = await runGh(["api", "rate_limit", ...apiHostArgs(host)], {
3949
5145
  signal,
@@ -4034,20 +5230,21 @@ async function readSharedBudgetSources(signal, host = effectiveRuntimeHost(), {
4034
5230
  resources = RATE_RESOURCES,
4035
5231
  coreEtag = null,
4036
5232
  renewClaim = null,
4037
- readGraphql = readRateLimitResources,
5233
+ readGraphql = readGraphqlObserver,
4038
5234
  readCore = readCoreBudget,
4039
5235
  } = {}) {
4040
5236
  const result = {};
4041
5237
  if (resources.includes("graphql")) {
4042
- const rateResources = await readGraphql(signal, host).catch(() => null);
4043
- const graphql = normalizeRateBudget(rateResources?.graphql);
4044
- if (!graphql) return null;
4045
- result.graphql = { budget: graphql, receivedAt: Date.now(), cost: 0 };
5238
+ const observed = await readGraphql(signal, host).catch(() => null);
5239
+ if (!observed?.budget) return null;
5240
+ result.graphql = observed;
4046
5241
  }
4047
5242
  if (resources.includes("core")) {
4048
- // Each gh command is independently bounded, but two sequential commands
4049
- // can outlive one claim. Extend the same nonce after the free GraphQL read
4050
- // and before the one-cost bootstrap observer can start.
5243
+ // Each gh command is independently bounded, but two sequential commands can
5244
+ // outlive one claim. Extend the same nonce between the two observers. The
5245
+ // GraphQL read is no longer the free one -- it costs a point of its own now
5246
+ // -- so this renewal separates two charged requests, not a free one from a
5247
+ // charged one.
4051
5248
  if (resources.includes("graphql") && typeof renewClaim === "function" && !await renewClaim()) return null;
4052
5249
  const core = await readCore(signal, host, coreEtag);
4053
5250
  if (core) result.core = core;
@@ -4069,12 +5266,21 @@ function projectedHourlyCost(activeKey) {
4069
5266
  }
4070
5267
 
4071
5268
  function doctorProbePlan() {
5269
+ // GraphQL cannot infer a repository the way `gh` does, so a report run from a
5270
+ // directory with no unambiguous remote says so per probe instead of sending a
5271
+ // document with a missing variable and reporting the resulting error as if it
5272
+ // came from the server.
5273
+ const target = effectiveRuntimeRepository();
5274
+ const document = (kind, variables) => (variables ? graphqlInput(kind, variables) : UNRESOLVED_REPOSITORY);
4072
5275
  return [
4073
- ["Repository access", repoContextArgs(), "doctor:repository"],
5276
+ // Every probe is now an explicit request whose cost is declared. The old
5277
+ // Issues/PRs entries ran `gh issue list`/`gh pr list`, whose `--search`
5278
+ // routed through GraphQL at a price the report could not name.
5279
+ ["Repository access", graphqlArgs(), "doctor:repository", document("repository", graphqlRepositoryVariables(target)), "repository"],
4074
5280
  ["Actions runs", ghApiArgs(actionsRunsArgs(MIN_RUN_LIMIT)), "doctor:actions-runs"],
4075
5281
  ["Actions workflows", ghApiArgs(actionsWorkflowsArgs()), "doctor:actions-workflows"],
4076
- ["Issues (issue list)", issuesArgs(), "doctor:issues"],
4077
- ["Pull requests (pr list)", prsArgs(), "doctor:prs"],
5282
+ ["Issues (first page)", graphqlArgs(), "doctor:issues", document("issues", graphqlPageVariables(null, target)), "issues"],
5283
+ ["Pull requests (first page)", graphqlArgs(), "doctor:prs", document("prs", graphqlPageVariables(null, target)), "prs"],
4078
5284
  ...ALERT_SOURCES.flatMap((source) =>
4079
5285
  alertRequestArgs(source).map((args, index) => [
4080
5286
  index === 0 ? source.name : `${source.name} (priority ${index})`,
@@ -4125,18 +5331,23 @@ async function runDoctor() {
4125
5331
 
4126
5332
  // Free checks run first. The display rate_limit read is report data only;
4127
5333
  // admission re-reads its GraphQL source after owning the shared claim.
4128
- const [ghVersion, authStatus, remote, remoteUrls] = await Promise.all([
5334
+ const [ghVersion, remote, remoteUrls] = await Promise.all([
4129
5335
  captureGh(["--version"], "version"),
4130
- captureGh(["auth", "status"], "auth-status"),
4131
5336
  gitRemote(),
4132
5337
  gitRemoteUrls(),
4133
5338
  ]);
4134
5339
  const effectiveHost = effectiveRuntimeHost({ remoteUrls });
4135
- const resources = await readRateLimitResources(undefined, effectiveHost).catch(() => null);
5340
+ runtimeIdentityCoordinator = createIdentityCoordinator({ host: effectiveHost });
5341
+ const verified = await runtimeIdentityCoordinator.refresh();
5342
+ const verifiedIdentity = runtimeIdentityCoordinator.current();
5343
+ const authStatus = verifiedIdentity ? `${verifiedIdentity.host}: ${verifiedIdentity.login} (verified)` : identityCoordinationMessage(verified.reason);
5344
+ const resources = verifiedIdentity ? await readRateLimitResources(undefined, effectiveHost).catch(() => null) : null;
4136
5345
  const budget = resources
4137
5346
  ? await rateBudget(resources)
4138
5347
  : { core: "unavailable", graphql: "unavailable" };
4139
- const scopeResult = createGovernorScope({ effectiveHost });
5348
+ const scopeResult = verifiedIdentity
5349
+ ? { ok: true, value: createQuotaScope(verifiedIdentity, { root: runtimeIdentityCoordinator.root, identityProvider: runtimeIdentityCoordinator.current }) }
5350
+ : verified;
4140
5351
  let governorResult = scopeResult;
4141
5352
  let results = [];
4142
5353
  if (scopeResult.ok) {
@@ -4154,20 +5365,21 @@ async function runDoctor() {
4154
5365
  try {
4155
5366
  if (registered.ok) await refreshSharedBudget(scope, leaseId, undefined);
4156
5367
  const admissionAt = Date.now();
4157
- const admittedProbes = probes.map(([name, args, operation], index) => {
5368
+ const admittedProbes = probes.map(([name, args, operation, input = null, document = null], index) => {
4158
5369
  const admitted = registered.ok
4159
5370
  ? admitGovernorOperation(scope, leaseId, operation, "diagnostic", admissionAt)
4160
5371
  : registered;
4161
5372
  if (!admitted?.ok || admitted.value.status !== "started") {
4162
- return { index, skipped: skippedDoctorProbe(name, args, admitted) };
5373
+ return { index, skipped: { ...skippedDoctorProbe(name, args, admitted), ...(document ? { document } : {}) } };
4163
5374
  }
4164
- return { index, name, args, operation, reservationId: admitted.value.reservationId };
5375
+ return { index, name, args, operation, input, document, reservationId: admitted.value.reservationId };
4165
5376
  });
4166
5377
  results = admittedProbes.map((item) => item.skipped ?? null);
4167
5378
  const runnable = admittedProbes.filter((item) => !item.skipped);
4168
5379
  const settled = await mapAllSettledBounded(runnable, 4, async (item) => {
4169
- const result = await probe(item.name, item.args, item.operation);
4170
- completeReservation(scope, item.reservationId, result.failed
5380
+ const result = await requestIdentityStorage.run(scope, () => probe(item.name, item.args, item.operation, item.input));
5381
+ if (item.document) result.document = item.document;
5382
+ completeReservation({ ...scope, identityProvider: null }, item.reservationId, result.failed
4171
5383
  ? { outcome: "rejected" }
4172
5384
  : { outcome: "measured-success", actualCost: operationCost(item.operation) }, Date.now());
4173
5385
  return result;
@@ -4208,7 +5420,12 @@ async function runDoctor() {
4208
5420
  field("slug", runtime.repo ?? "(inferred from the working directory)"),
4209
5421
  field("git remote", remote),
4210
5422
  "",
4211
- ...section("API budget"),
5423
+ // Labelled, because these two numbers come from `gh api rate_limit`, which
5424
+ // this version treats as a diagnostic and never as spendable capacity. The
5425
+ // "API governor" section below carries the numbers actually admitted
5426
+ // against, with their provenance. A reader who cannot tell the two apart
5427
+ // will believe the wrong one.
5428
+ ...section("API budget (rate_limit, non-authoritative)"),
4212
5429
  field("REST core", budget.core),
4213
5430
  field("GraphQL", budget.graphql),
4214
5431
  field(
@@ -4243,6 +5460,10 @@ async function runDoctor() {
4243
5460
  for (const result of results) {
4244
5461
  lines.push(` ${result.name}`);
4245
5462
  probeLine("argv", `gh ${result.args.join(" ")}`);
5463
+ // Three GraphQL probes share one argv, and the document that distinguishes
5464
+ // them travels on stdin. A report that cannot say which query it sent is
5465
+ // exactly the drift this command exists to rule out.
5466
+ if (result.document) probeLine("document", result.document);
4246
5467
  probeLine(
4247
5468
  "outcome",
4248
5469
  result.skipped
@@ -4623,6 +5844,21 @@ if (IS_MAIN) {
4623
5844
  console.error(problem);
4624
5845
  process.exit(3);
4625
5846
  }
5847
+ runtimeRemoteUrls = await gitRemoteUrls();
5848
+ runtimeIdentityCoordinator = createIdentityCoordinator({ host: () => effectiveRuntimeHost() });
5849
+ // Resolve warm identity/cache locally; cold proof belongs to the mounted UI
5850
+ // so quit/signal handling remains available while GitHub is slow.
5851
+ //
5852
+ // Bounded, because the warm path still spawns `gh auth token`: a prompting
5853
+ // credential helper, a keyring daemon that is not up yet in a fresh login
5854
+ // session, or a cold binary on a network filesystem would otherwise hold the
5855
+ // first frame for the full subprocess timeout with nothing on screen at all.
5856
+ // Past the bound the same refresh is picked up by the mounted UI, and
5857
+ // ensureScope corrects the cache target and hydrates from it.
5858
+ await Promise.race([
5859
+ runtimeIdentityCoordinator.refresh({ allowBootstrap: false }),
5860
+ new Promise((resolve) => { setTimeout(resolve, WARM_IDENTITY_WAIT_MS).unref(); }),
5861
+ ]);
4626
5862
  }
4627
5863
 
4628
5864
  const ReactModule = await import("react");
@@ -5306,7 +6542,7 @@ const TABS = [
5306
6542
  },
5307
6543
  {
5308
6544
  key: "issues",
5309
- fetch: ({ signal }) => fetchIssues(signal),
6545
+ fetch: ({ signal, governor, previousRaw }) => fetchIssues(signal, { governor, previousRaw }),
5310
6546
  label: "Issues",
5311
6547
  short: "Issues",
5312
6548
  header: ISSUES_HEADER,
@@ -5316,7 +6552,7 @@ const TABS = [
5316
6552
  },
5317
6553
  {
5318
6554
  key: "prs",
5319
- fetch: ({ signal }) => fetchPRs(signal),
6555
+ fetch: ({ signal, governor, previousRaw }) => fetchPRs(signal, { governor, previousRaw }),
5320
6556
  label: "Pull requests",
5321
6557
  short: "PRs",
5322
6558
  header: PRS_HEADER,
@@ -5866,7 +7102,8 @@ const createWidthPreferenceWriter = createCoalescedWriter;
5866
7102
 
5867
7103
  // ---------- Last-known-good dashboard cache ----------
5868
7104
 
5869
- const DASHBOARD_CACHE_VERSION = 2;
7105
+ // 3: cached rows carry their page URL, without which they cannot be opened.
7106
+ const DASHBOARD_CACHE_VERSION = 3;
5870
7107
  const MAX_DASHBOARD_CACHE_TARGETS = 5;
5871
7108
  const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
5872
7109
  let dashboardCacheTempSequence = 0;
@@ -5955,6 +7192,9 @@ function normalizeCachedItem(tabKey, item) {
5955
7192
  conclusion: item.conclusion == null ? null : safe(item.conclusion),
5956
7193
  startedAt: safe(item.startedAt),
5957
7194
  updatedAt: safe(item.updatedAt),
7195
+ // Retained because opening a row now reads its URL from the row. Dropping
7196
+ // it made Enter on a cache-hydrated row do nothing at all, with no error.
7197
+ url: safe(item.url ?? ""),
5958
7198
  };
5959
7199
  }
5960
7200
  if (tabKey === "issues") {
@@ -5965,6 +7205,7 @@ function normalizeCachedItem(tabKey, item) {
5965
7205
  author: safe(item.author),
5966
7206
  label: safe(item.label),
5967
7207
  updatedAt: safe(item.updatedAt),
7208
+ url: safe(item.url ?? ""),
5968
7209
  };
5969
7210
  }
5970
7211
  if (tabKey === "prs") {
@@ -5977,6 +7218,7 @@ function normalizeCachedItem(tabKey, item) {
5977
7218
  isDraft: Boolean(item.isDraft),
5978
7219
  reviewDecision: safe(item.reviewDecision),
5979
7220
  updatedAt: safe(item.updatedAt),
7221
+ url: safe(item.url ?? ""),
5980
7222
  };
5981
7223
  }
5982
7224
  if (tabKey === "security") {
@@ -6436,10 +7678,27 @@ function rateLimitBlockDecision(results, resetMs) {
6436
7678
  };
6437
7679
  }
6438
7680
 
7681
+ // Identity reasons that name a condition the user can act on or wait out. The
7682
+ // list previously stopped short of identity-unavailable -- the ordinary "not
7683
+ // signed in" or "proof denied" case, and precisely the one this phase requires
7684
+ // to read as an honest unavailable state rather than a generic retry -- and of
7685
+ // identity-capacity and transport-busy.
7686
+ //
7687
+ // Purely internal reasons (corrupt, unwritable, unknown-host, closed) stay on
7688
+ // the generic message on purpose: this surface must not leak internal
7689
+ // vocabulary, which is the contract "coordination notices translate raw reasons
7690
+ // without exposing internal vocabulary" in test/unit.test.mjs pins.
7691
+ const IDENTITY_COORDINATION_REASONS = [
7692
+ "restart-required", "migration-hold", "legacy-unresolved", "legacy-corrupt",
7693
+ "credential-unavailable", "identity-backoff", "identity-busy",
7694
+ "identity-unavailable", "identity-capacity", "transport-busy",
7695
+ ];
7696
+
6439
7697
  function coordinationNotice(reason) {
6440
7698
  if (reason === "unknown-scope") return "Confirming your GitHub login…";
6441
7699
  if (reason === "block-unpublished") return "Holding until the rate-limit block is shared";
6442
7700
  if (reason === "busy" || reason === "stale") return "Coordinating with your other panes";
7701
+ if (IDENTITY_COORDINATION_REASONS.includes(reason)) return identityCoordinationMessage(reason);
6443
7702
  return "Can't coordinate API use — retrying";
6444
7703
  }
6445
7704
 
@@ -6520,16 +7779,18 @@ async function runAdmittedOperation({ scope, leaseId, operation, priority = "man
6520
7779
  return { ok: false, skipped: true, decision: admitted, error: new Error(`API budget paused${detail}`) };
6521
7780
  }
6522
7781
  const reservationId = admitted.value.reservationId;
7782
+ const settlementScope = { ...scope, identityProvider: null };
6523
7783
  try {
6524
- const value = await run(signal);
7784
+ const value = await requestIdentityStorage.run(scope, () => run(signal));
6525
7785
  const costs = operationCost(operation);
6526
- completeReservation(scope, reservationId, {
7786
+ completeReservation(settlementScope, reservationId, {
6527
7787
  outcome: "measured-success",
6528
7788
  actualCost: costs,
6529
7789
  }, Date.now());
7790
+ if (scope.accessKey && scope.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) return { ok: false, error: new Error("Credential changed"), reservationId };
6530
7791
  return { ok: true, value, reservationId };
6531
7792
  } catch (error) {
6532
- completeReservation(scope, reservationId, { outcome: governorOutcomeForError(error) }, Date.now());
7793
+ completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, Date.now());
6533
7794
  return { ok: false, error, reservationId };
6534
7795
  }
6535
7796
  }
@@ -7180,8 +8441,9 @@ function App({ onCreateRemote = () => {} } = {}) {
7180
8441
  dashboardCacheTarget({
7181
8442
  repo: runtime.repo,
7182
8443
  ghRepo: process.env.GH_REPO,
7183
- host: runtime.host ?? process.env.GH_HOST,
8444
+ host: runtimeIdentityCoordinator?.current()?.host ?? effectiveRuntimeHost(),
7184
8445
  cwd: process.cwd(),
8446
+ account: runtimeIdentityCoordinator?.current()?.accessKey ?? "unverified",
7185
8447
  }),
7186
8448
  );
7187
8449
  const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
@@ -7882,6 +9144,9 @@ function App({ onCreateRemote = () => {} } = {}) {
7882
9144
  // refresh the tab you just landed on without waiting on an unrelated
7883
9145
  // background fetch -- and so a slow repo can't stack refreshes.
7884
9146
  if (inFlightRef.current[key]) return Promise.resolve();
9147
+ const settlement = createSettlementContext(scope, runtimeIdentityCoordinator);
9148
+ const settlementScope = settlement.scope;
9149
+ const currentAccess = settlement.isCurrent;
7885
9150
  inFlightRef.current[key] = true;
7886
9151
  if (force) manualInFlight.add(key);
7887
9152
  admittedCadenceRef.current[key] = nextAdmittedCadence(
@@ -7907,9 +9172,9 @@ function App({ onCreateRemote = () => {} } = {}) {
7907
9172
  [key]: { automaticStatusVisible },
7908
9173
  }));
7909
9174
  }
7910
- return run()
9175
+ return requestIdentityStorage.run(scope, run)
7911
9176
  .then((result) => {
7912
- settleReservationWithBudgetObservations(scope, leaseId, reservationId, result?.measuredSuccess === false
9177
+ settleReservationWithBudgetObservations(settlementScope, leaseId, reservationId, result?.measuredSuccess === false
7913
9178
  ? { outcome: "rejected", observations: result?.observations ?? [] }
7914
9179
  : {
7915
9180
  outcome: "measured-success",
@@ -7919,6 +9184,7 @@ function App({ onCreateRemote = () => {} } = {}) {
7919
9184
  },
7920
9185
  observations: result?.observations ?? [],
7921
9186
  }, Date.now());
9187
+ if (!currentAccess()) return;
7922
9188
  if (result?.rateLimited) {
7923
9189
  const blockAt = Date.now();
7924
9190
  const budget = inspectGovernor(scope, blockAt).value?.budgets?.core;
@@ -8035,11 +9301,11 @@ function App({ onCreateRemote = () => {} } = {}) {
8035
9301
  });
8036
9302
  })
8037
9303
  .catch((err) => {
8038
- settleReservationWithBudgetObservations(scope, leaseId, reservationId, {
9304
+ settleReservationWithBudgetObservations(settlementScope, leaseId, reservationId, {
8039
9305
  outcome: governorOutcomeForError(err),
8040
9306
  observations: err?.budgetObservations ?? [],
8041
9307
  }, Date.now());
8042
- if (cancelled || err?.name === "AbortError") return;
9308
+ if (cancelled || !currentAccess() || err?.name === "AbortError") return;
8043
9309
  // Preserve both the verdict and the bounded raw error in state. The
8044
9310
  // renderer translates recognized verdicts at draw time, which lets a
8045
9311
  // later repository/account context refine the one-line remedy without
@@ -8073,6 +9339,7 @@ function App({ onCreateRemote = () => {} } = {}) {
8073
9339
  })
8074
9340
  .finally(() => {
8075
9341
  inFlightRef.current[key] = false;
9342
+ if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
8076
9343
  manualInFlight.delete(key);
8077
9344
  onSettled?.();
8078
9345
  if (!cancelled) {
@@ -8106,6 +9373,10 @@ function App({ onCreateRemote = () => {} } = {}) {
8106
9373
  let scope = null;
8107
9374
  let cleanupScope = null;
8108
9375
  let registeredScopeHash = null;
9376
+ // The access partition the retained rows belong to, kept across a window
9377
+ // where the identity is momentarily unknown so that an account which comes
9378
+ // back *different* still clears them. Null means nothing is retained.
9379
+ let retainedAccessKey = null;
8109
9380
  let remoteUrls = [];
8110
9381
  let liveScheduling = false;
8111
9382
  let controlEpochs = null;
@@ -8175,14 +9446,34 @@ function App({ onCreateRemote = () => {} } = {}) {
8175
9446
  }
8176
9447
 
8177
9448
  function identity() {
8178
- return {
8179
- effectiveHost: effectiveRuntimeHost({ remoteUrls }),
8180
- authIdentity: authCacheIdentity(),
8181
- };
9449
+ return runtimeIdentityCoordinator?.current() ?? null;
9450
+ }
9451
+
9452
+ function retireCurrentScope() {
9453
+ if (!cleanupScope || !registeredScopeHash) return;
9454
+ for (const item of pending.values()) cancelIntent(cleanupScope, item.intentId, Date.now());
9455
+ if (!Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
8182
9456
  }
8183
9457
 
8184
- function resetVisibleScope(nowMs, { immediate = false } = {}) {
9458
+ // discardData separates the two reasons this runs. An account *change* must
9459
+ // drop every row, validator and note, because they belong to an access
9460
+ // partition this pane no longer holds. An identity that is merely unknown
9461
+ // for a moment must not: the rows are still ours, and throwing away the
9462
+ // ETags with them makes the recovery round cost full price.
9463
+ function resetVisibleScope(nowMs, { immediate = false, discardData = true } = {}) {
8185
9464
  pending.clear();
9465
+ coordinator.invalidate();
9466
+ setFailureContext(null);
9467
+ if (discardData) {
9468
+ rawRef.current = {};
9469
+ entityRef.current.clear();
9470
+ lastOkRef.current = {};
9471
+ setData(Object.fromEntries(TAB_KEYS.map((key) => [key, null])));
9472
+ setMeta({});
9473
+ setSecurityNotes([]);
9474
+ setSecurityBlind(false);
9475
+ retainedAccessKey = null;
9476
+ }
8186
9477
  pendingBlockPublications.clear();
8187
9478
  liveScheduling = false;
8188
9479
  activePollAt = Number.POSITIVE_INFINITY;
@@ -8201,20 +9492,26 @@ function App({ onCreateRemote = () => {} } = {}) {
8201
9492
 
8202
9493
  function ensureScope(nowMs = Date.now(), { maintain = false } = {}) {
8203
9494
  const current = identity();
8204
- const nextHash = governorScopeHash(current.effectiveHost, current.authIdentity);
9495
+ const nextHash = current?.quotaKey;
8205
9496
  if (!nextHash) {
8206
9497
  if (scope !== null) {
8207
- if (cleanupScope && registeredScopeHash) releaseLease(cleanupScope, leaseId);
9498
+ retireCurrentScope();
8208
9499
  registeredScopeHash = null;
8209
9500
  if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
8210
9501
  scope = null;
8211
9502
  cleanupScope = null;
8212
- resetVisibleScope(nowMs);
9503
+ // Unknown is not the same as changed. A contended registry lock, a
9504
+ // `gh` write that only touched an unrelated host's entry, or a
9505
+ // re-resolution still in flight all arrive here, and none of them says
9506
+ // the rows on screen stopped being ours. Stop scheduling and let them
9507
+ // age visibly; retainedAccessKey is what still clears them if the
9508
+ // account turns out to have actually changed.
9509
+ resetVisibleScope(nowMs, { discardData: false });
8213
9510
  }
8214
9511
  return null;
8215
9512
  }
8216
- const migrating = scope !== null && scope.hash !== nextHash;
8217
- if (scope?.hash === nextHash && registeredScopeHash === nextHash) {
9513
+ const migrating = scope !== null && (scope.hash !== nextHash || scope.accessKey !== current.accessKey);
9514
+ if (scope?.hash === nextHash && scope.accessKey === current.accessKey && registeredScopeHash === nextHash) {
8218
9515
  if (!maintain) return scope;
8219
9516
  const activeTab = TABS[activeIndexRef.current].key;
8220
9517
  const kept = maintainControlLease(scope, leaseId, runtime.refreshMs, activeTab, nowMs);
@@ -8223,20 +9520,40 @@ function App({ onCreateRemote = () => {} } = {}) {
8223
9520
  if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
8224
9521
  return null;
8225
9522
  }
8226
- if (scope?.hash !== nextHash) {
8227
- if (cleanupScope && registeredScopeHash) releaseLease(cleanupScope, leaseId);
9523
+ if (scope?.hash !== nextHash || scope.accessKey !== current.accessKey) {
9524
+ retireCurrentScope();
8228
9525
  registeredScopeHash = null;
8229
- if (migrating) {
9526
+ // The second test covers the identity returning after a window where it
9527
+ // was unknown: scope is null by then, so `migrating` cannot see the
9528
+ // change, and retained rows from the previous access partition would
9529
+ // otherwise survive into an account that must not be able to read them.
9530
+ if (migrating || (scope === null && retainedAccessKey !== null && retainedAccessKey !== current.accessKey)) {
8230
9531
  // The failed publication belonged to the old account scope. Releasing
8231
9532
  // that lease guarantees this process will start no more work there;
8232
9533
  // the new scope must establish its own authoritative budget instead
8233
9534
  // of inheriting an unrelated account's local hold.
8234
9535
  resetVisibleScope(nowMs, { immediate: true });
8235
9536
  }
8236
- const created = createGovernorScope({ ...current, identityProvider: identity });
8237
- if (!created.ok) return null;
8238
- scope = created.value;
9537
+ scope = createQuotaScope(current, { root: runtimeIdentityCoordinator.root, identityProvider: identity });
8239
9538
  cleanupScope = { ...scope, identityProvider: null };
9539
+ const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey });
9540
+ // The mount-time target had to guess the account, and guesses
9541
+ // "unverified" whenever the bounded warm resolution above did not land
9542
+ // in time -- so a session that has rows on disk can start with none.
9543
+ // Now that the partition is proven, take them. Guarded on having
9544
+ // published nothing yet, so this can never overwrite live rows.
9545
+ if (nextTarget !== dashboardCacheTargetRef.current && retainedAccessKey === null &&
9546
+ Object.values(dataRef.current).every((rows) => rows === null)) {
9547
+ const warm = pick(dashboardCacheRef.current, nextTarget, null);
9548
+ if (warm) {
9549
+ setData(Object.fromEntries(TABS.map((candidate) => [candidate.key, warm.tabs[candidate.key]?.data ?? null])));
9550
+ setMeta(Object.fromEntries(TABS.map((candidate) => [candidate.key, warm.tabs[candidate.key]?.meta ?? null])));
9551
+ setSecurityNotes(warm.securityNotes ?? []);
9552
+ setSecurityBlind(warm.securityBlind ?? false);
9553
+ }
9554
+ }
9555
+ retainedAccessKey = current.accessKey;
9556
+ dashboardCacheTargetRef.current = nextTarget;
8240
9557
  }
8241
9558
  const activeTab = TABS[activeIndexRef.current].key;
8242
9559
  const registered = registerLease(scope, {
@@ -8413,6 +9730,7 @@ function App({ onCreateRemote = () => {} } = {}) {
8413
9730
  runLimit: runLimitRef.current,
8414
9731
  entities: entityRef.current,
8415
9732
  force,
9733
+ governor: { scope: currentScope, leaseId },
8416
9734
  previousRaw: rawRef.current[key] ?? null,
8417
9735
  }),
8418
9736
  {
@@ -8452,13 +9770,14 @@ function App({ onCreateRemote = () => {} } = {}) {
8452
9770
  if (inFlightRef.current[key]) continue;
8453
9771
  const currentScope = ensureScope(nowMs);
8454
9772
  if (!currentScope) continue;
8455
- const decision = readIntentDecision(currentScope, item.intentId, nowMs);
9773
+ const decision = readIntentDecision(currentScope, item.intentId, nowMs, item);
8456
9774
  if (!decision.ok) {
8457
9775
  pauseCoordination(key, decision.reason);
8458
9776
  if (pendingFailureIsTerminal(decision.reason)) pending.delete(key);
8459
9777
  else armWake("data", governorControlRetryAt(nowMs, runtime.refreshMs), dataWake);
8460
9778
  continue;
8461
9779
  }
9780
+ Object.assign(item, sharedLaneEvidence(decision.value));
8462
9781
  if (decision.value.status !== "scheduled" || decision.value.notBefore > nowMs) {
8463
9782
  item.wasDeferred = true;
8464
9783
  setTabGovernorDecision(key, visibleGovernorDecision({
@@ -8498,6 +9817,7 @@ function App({ onCreateRemote = () => {} } = {}) {
8498
9817
  runLimit: runLimitRef.current,
8499
9818
  entities: entityRef.current,
8500
9819
  force: item.force,
9820
+ governor: { scope: currentScope, leaseId },
8501
9821
  previousRaw: rawRef.current[key] ?? null,
8502
9822
  }),
8503
9823
  {
@@ -8582,6 +9902,8 @@ function App({ onCreateRemote = () => {} } = {}) {
8582
9902
  });
8583
9903
 
8584
9904
  async function controlWake() {
9905
+ if (cancelled) return;
9906
+ await runtimeIdentityCoordinator.refresh();
8585
9907
  if (cancelled) return;
8586
9908
  const nowMs = Date.now();
8587
9909
  const currentScope = ensureScope(nowMs, { maintain: true });
@@ -8596,7 +9918,7 @@ function App({ onCreateRemote = () => {} } = {}) {
8596
9918
  }
8597
9919
  const refreshed = currentScope
8598
9920
  ? await refreshSharedBudget(currentScope, leaseId, controller.signal)
8599
- : { ok: false, reason: "stale" };
9921
+ : { ok: false, reason: runtimeIdentityCoordinator?.inspect()?.reason ?? "stale" };
8600
9922
  if (cancelled) return;
8601
9923
  const checkedAt = Date.now();
8602
9924
  const snapshot = currentScope ? inspectGovernor(currentScope, checkedAt) : refreshed;
@@ -8642,12 +9964,13 @@ function App({ onCreateRemote = () => {} } = {}) {
8642
9964
  }
8643
9965
 
8644
9966
  async function bootstrap() {
8645
- remoteUrls = await gitRemoteUrls();
9967
+ remoteUrls = runtimeRemoteUrls.length > 0 ? runtimeRemoteUrls : await gitRemoteUrls();
8646
9968
  runtimeRemoteUrls = remoteUrls;
9969
+ await runtimeIdentityCoordinator.refresh();
8647
9970
  if (cancelled) return;
8648
9971
  const currentScope = ensureScope(Date.now());
8649
9972
  if (!currentScope) {
8650
- pauseCoordination(TABS[activeIndexRef.current].key, "unknown-scope");
9973
+ pauseCoordination(TABS[activeIndexRef.current].key, runtimeIdentityCoordinator?.inspect()?.reason ?? "unknown-scope");
8651
9974
  armWake("control", governorControlRetryAt(Date.now(), runtime.refreshMs), controlWake);
8652
9975
  return;
8653
9976
  }
@@ -9146,7 +10469,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9146
10469
  canMove: allItems.length > 0,
9147
10470
  canOpen:
9148
10471
  selectedKey !== null &&
9149
- Object.hasOwn(OPENABLE, tab.key) &&
10472
+ OPENABLE.includes(tab.key) &&
9150
10473
  allItems.some((item) => itemKey(item) === selectedKey),
9151
10474
  canResize: fullHeaderVisible,
9152
10475
  }),
@@ -9339,6 +10662,10 @@ if (IS_MAIN) {
9339
10662
  });
9340
10663
  };
9341
10664
  app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
10665
+ // Ink's q/Esc/Ctrl+C exit unmounts directly rather than calling unmountApp.
10666
+ // Only close the identity coordinator after that actual application exit,
10667
+ // not when a polling effect reruns. Rejections reach the crash handler above.
10668
+ void app.waitUntilExit().then(abortLiveRequests);
9342
10669
 
9343
10670
  // 128 + signal number, so a supervisor or `timeout` can tell an interrupted
9344
10671
  // run from a clean one. These fire on external `kill` and when raw mode is
@@ -9368,6 +10695,7 @@ if (IS_MAIN) {
9368
10695
  const finish = () => {
9369
10696
  unmountApp();
9370
10697
  restoreScreen();
10698
+ abortLiveRequests();
9371
10699
  process.exit(code);
9372
10700
  };
9373
10701
 
@@ -9393,6 +10721,19 @@ if (IS_MAIN) {
9393
10721
  // Exported for unit tests. The dashboard itself is still one file; these are
9394
10722
  // the pure functions worth pinning, and nothing here is part of the public API.
9395
10723
  export {
10724
+ retryIdentityCompletion,
10725
+ createSettlementContext,
10726
+ startIdentityControl,
10727
+ settleIdentityControl,
10728
+ resolveEffectiveCredential,
10729
+ identityRegistryRoot,
10730
+ inspectIdentityRegistry,
10731
+ claimIdentityBootstrap,
10732
+ finishIdentityBootstrap,
10733
+ createIdentityCoordinator,
10734
+ createQuotaScope,
10735
+ acquireIdentityHttpPermit,
10736
+ releaseIdentityHttpPermit,
9396
10737
  parseArgs,
9397
10738
  validateArgs,
9398
10739
  parseRepoTarget,
@@ -9472,6 +10813,7 @@ export {
9472
10813
  settleReservationWithBudgetObservations,
9473
10814
  recordResourceBlock,
9474
10815
  releaseLease,
10816
+ inspectAdmittedHttpStart,
9475
10817
  inspectGovernor,
9476
10818
  governorHealth,
9477
10819
  refreshSharedBudget,
@@ -9573,6 +10915,24 @@ export {
9573
10915
  forcedBackoffKeys,
9574
10916
  clearForcedBackoffAfterStart,
9575
10917
  doctorProbePlan,
10918
+ rowBrowserUrl,
10919
+ admittedRowUrl,
10920
+ remoteSlug,
10921
+ resolveEffectiveRepository,
10922
+ graphqlPageVariables,
10923
+ graphqlRepositoryVariables,
10924
+ parseGraphqlEnvelope,
10925
+ graphqlInput,
10926
+ graphqlArgs,
10927
+ fetchGraphqlPage,
10928
+ fetchGraphqlList,
10929
+ operationPausedUntil,
10930
+ LIST_LIMIT,
10931
+ readGraphqlObserver,
10932
+ GRAPHQL_QUERIES,
10933
+ GRAPHQL_PAGE_SIZE,
10934
+ GRAPHQL_PAGE_POINTS,
10935
+ GRAPHQL_OBSERVER_POINTS,
9576
10936
  mapAllSettledBounded,
9577
10937
  alertRequestArgs,
9578
10938
  shouldFetchAlertPriorityLanes,