gh-glance 0.16.2 → 0.17.1

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 +64 -1
  2. package/README.md +80 -17
  3. package/index.mjs +795 -253
  4. package/package.json +1 -1
package/index.mjs CHANGED
@@ -292,7 +292,7 @@ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
292
292
  // legitimate mixed-direction Arabic and Hebrew titles render correctly, and
293
293
  // preserving real RTL text is a stated property of this function.
294
294
  // eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
295
- const CONTROL_CHARS = /[--Ÿ]+/g;
295
+ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]+/g;
296
296
  const BIDI_OVERRIDES = /[‪-‮⁦-⁩]/g;
297
297
 
298
298
  // Titles are rendered at whatever length GitHub returns, and ink memoizes
@@ -307,7 +307,7 @@ const MAX_FIELD_LENGTH = 300;
307
307
  function safe(value) {
308
308
  // Coerce first, sanitize always. Returning a non-string early skipped both the
309
309
  // control-character strip and the length clamp, so an array or an object with a
310
- // toString() came through untouched -- `safe(["x"])` returned the escape
310
+ // toString() came through untouched -- `safe(["\x1b[2Jx"])` returned the escape
311
311
  // verbatim. GitHub returns strings for every field this is called on today, so
312
312
  // the guarantee was being provided by the upstream schema rather than by the
313
313
  // function that claims to provide it.
@@ -1005,7 +1005,7 @@ function ghApiArgs(args, etag = null) {
1005
1005
  ];
1006
1006
  }
1007
1007
 
1008
- async function ghApi(args, { operation, signal, etag = null, run = runGh } = {}) {
1008
+ async function ghApi(args, { operation, signal, etag = null, run = runGh, includeHeaders = false } = {}) {
1009
1009
  const argv = ghApiArgs(args, etag);
1010
1010
  try {
1011
1011
  const parsed = parseGhApiResponse(await run(argv, { operation, signal }));
@@ -1014,6 +1014,7 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
1014
1014
  etag: parsed.headers.etag ?? null,
1015
1015
  rateLimit: pickRateLimit(parsed.headers),
1016
1016
  body: parsed.body,
1017
+ ...(includeHeaders ? { headers: parsed.headers } : {}),
1017
1018
  };
1018
1019
  } catch (error) {
1019
1020
  const parsed = parseGhApiResponse(error?.stdout);
@@ -1490,6 +1491,72 @@ function actionsWorkflowsArgs() {
1490
1491
  ];
1491
1492
  }
1492
1493
 
1494
+ const ACTIONS_SETUP_TTL_MS = 5 * 60_000;
1495
+
1496
+ function actionsSetupArgs({ host, repository }) {
1497
+ return [`repos/${repository}/actions/workflows?per_page=100&page=1`, "--hostname", host,
1498
+ "--jq", "{total_count, workflows: [.workflows[] | {id, name, state}]}"];
1499
+ }
1500
+
1501
+ function parseActionsSetup(body, headers = {}) {
1502
+ const unusable = () => { throw new Error("Actions setup response is unusable"); };
1503
+ let value;
1504
+ try { value = JSON.parse(body); } catch { return unusable(); }
1505
+ if (!isRecord(value) || !Number.isSafeInteger(value.total_count) || value.total_count < 0 ||
1506
+ !Array.isArray(value.workflows) || value.workflows.length > 100 ||
1507
+ value.total_count < value.workflows.length || (value.total_count > 0 && value.workflows.length === 0)) return unusable();
1508
+ const ids = new Set();
1509
+ const workflows = value.workflows.map((entry) => {
1510
+ if (!isRecord(entry) || !Number.isSafeInteger(entry.id) || entry.id <= 0 || ids.has(entry.id) ||
1511
+ typeof entry.name !== "string" || !safe(entry.name).trim() ||
1512
+ typeof entry.state !== "string" || !entry.state.trim()) return unusable();
1513
+ ids.add(entry.id);
1514
+ return { id: entry.id, name: safe(entry.name), state: entry.state };
1515
+ });
1516
+ const hasNextPage = /rel=["']?next\b/i.test(headers.link ?? "");
1517
+ if (value.total_count === 0 && hasNextPage) return unusable();
1518
+ const complete = value.total_count === workflows.length && !hasNextPage;
1519
+ const disabled = workflows.filter(({ state }) => ["disabled_manually", "disabled_inactivity"].includes(state)).length;
1520
+ const active = workflows.filter(({ state }) => state === "active").length;
1521
+ const title = !complete ? "Workflows found; setup check incomplete"
1522
+ : workflows.length === 0 ? "No workflows found"
1523
+ : disabled === workflows.length ? "All listed workflows are disabled" : "Workflows found";
1524
+ return { total: value.total_count, workflows, complete, disabled, active,
1525
+ other: workflows.length - disabled - active, title };
1526
+ }
1527
+
1528
+ function actionsSetupFresh(result, nowMs) {
1529
+ return result != null && nowMs >= result.checkedAt && nowMs - result.checkedAt < ACTIONS_SETUP_TTL_MS;
1530
+ }
1531
+
1532
+ function actionsSetupRetryAt(nowMs, holdUntil = 0) {
1533
+ return Math.max(nowMs + 30_000, Number.isFinite(holdUntil) ? holdUntil : 0);
1534
+ }
1535
+
1536
+ function actionsPageUrl(target = dashboardTarget()) {
1537
+ const validated = dashboardTarget(target);
1538
+ return validated.host && validated.repository
1539
+ ? `https://${validated.host}/${validated.repository}/actions` : null;
1540
+ }
1541
+
1542
+ function actionsSetupPresentation(state, nowMs) {
1543
+ const result = state.result;
1544
+ const notes = [];
1545
+ if (result) {
1546
+ notes.push(`${state.saved ? "Saved setup check" : "Setup checked"}: ${new Date(result.checkedAt).toISOString()}`,
1547
+ `${result.workflows.length} of ${result.total} workflows listed: ${result.active} active, ${result.disabled} disabled, ${result.other} other state.`,
1548
+ actionsSetupFresh(result, nowMs)
1549
+ ? `Press s to check again after ${new Date(result.checkedAt + ACTIONS_SETUP_TTL_MS).toISOString()}.`
1550
+ : "Setup check expired; press s to check again.");
1551
+ }
1552
+ if (state.error) notes.push(state.error,
1553
+ `Press s to retry${state.retryAt ? ` after ${new Date(state.retryAt).toISOString()}` : ""}.`);
1554
+ if (state.message) notes.push(state.message);
1555
+ return { title: state.busy ? "Checking Actions setup" : state.error ? "Setup check unavailable"
1556
+ : state.message ? state.title ?? "Actions setup" : result?.title,
1557
+ action: state.action ?? (state.error ? "s Retry; g Actions; ? Details" : "s Setup; g Actions; ? Details"), notes };
1558
+ }
1559
+
1493
1560
  function parseActionsRuns(runsBody) {
1494
1561
  return parseJsonOutput(runsBody).map((run) => ({
1495
1562
  databaseId: run.databaseId,
@@ -2214,6 +2281,7 @@ const OPERATION_COSTS = Object.freeze({
2214
2281
  // Separately admitted, like a later list page: only fetched when a run has
2215
2282
  // no name of its own, and settled against its own reservation.
2216
2283
  "catalog:actions-workflows": { core: 1, graphql: 0 },
2284
+ "setup:actions-workflows": { core: 1, graphql: 0 },
2217
2285
  "tab:security-endpoint": { core: 1, graphql: 0 },
2218
2286
  "failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
2219
2287
  // Each page past the first reserves its own envelope, so a denial there is a
@@ -3207,8 +3275,8 @@ function claimIdentityBootstrap(root, { credentialKey, host, resource = "core",
3207
3275
  }, { now, kill });
3208
3276
  }
3209
3277
 
3210
- function readIdentityQuotaState(registry, scope, now) {
3211
- const loaded = readGovernorState(scope.path, now);
3278
+ function readIdentityQuotaState(registry, scope, now, options = {}) {
3279
+ const loaded = readGovernorState(scope.path, now, options);
3212
3280
  const known = Object.values(registry.identities).some((identity) => identity.quotaKey === scope.quotaKey) ||
3213
3281
  Object.values(registry.attempts).some((attempt) => attempt.imported && attempt.quotaKey === scope.quotaKey);
3214
3282
  return loaded.ok && loaded.missing && known ? { ok: false, reason: "corrupt" } : loaded;
@@ -3234,9 +3302,9 @@ function importIdentityDebts(root, state, credentialKey, identity, now) {
3234
3302
  notBefore: attempt.startedAt, status: "started", epochs: { core: null, graphql: null },
3235
3303
  startedAt: attempt.startedAt, completedAt: null, outcome: null,
3236
3304
  };
3237
- if (!loaded.value.importMarkers[id] &&
3238
- !transferReceiptToDebt(loaded.value, legacyControl, now, id)) {
3239
- return { ok: false, reason: "accounting-overflow" };
3305
+ if (!loaded.value.importMarkers[id]) {
3306
+ const failure = transferReceiptToDebt(loaded.value, legacyControl, now, id);
3307
+ if (failure) return failure;
3240
3308
  }
3241
3309
  if (Object.keys(loaded.value.importMarkers).length >= IDENTITY_MAX_ATTEMPTS &&
3242
3310
  !loaded.value.importMarkers[id]) return { ok: false, reason: "identity-capacity" };
@@ -3451,7 +3519,8 @@ function startIdentityControl(coordinator, now = Date.now()) {
3451
3519
  if (Object.keys(registry.attempts).length >= IDENTITY_MAX_ATTEMPTS) return { ok: false, reason: "identity-capacity" };
3452
3520
  const priorControl = state.controlReceipts.core;
3453
3521
  if (priorControl) {
3454
- if (!retireControlReceiptToDebt(state, "core", now)) return { ok: false, reason: "accounting-overflow" };
3522
+ const failure = retireControlReceiptToDebt(state, "core", now);
3523
+ if (failure) return failure;
3455
3524
  }
3456
3525
  const id = governorId();
3457
3526
  registry.attempts[id] = { credentialKey: identity.credentialKey, host: identity.host, resource: "core", startedAt: now,
@@ -3506,8 +3575,9 @@ function settleIdentityControl(coordinator, control, stdout, now = Date.now(),
3506
3575
  reservation.actualCosts = { core: cost, graphql: 0 };
3507
3576
  reservation.accountedCosts = { core: cost, graphql: 0 };
3508
3577
  loaded.value.controlReceipts.core = null;
3509
- } else if (!retireControlReceiptToDebt(loaded.value, "core", now)) {
3510
- return { ok: false, reason: "accounting-overflow" };
3578
+ } else {
3579
+ const failure = retireControlReceiptToDebt(loaded.value, "core", now);
3580
+ if (failure) return failure;
3511
3581
  }
3512
3582
  return writeGovernorQuotaState(scope.path, loaded.value, now);
3513
3583
  });
@@ -3776,6 +3846,7 @@ function identityCoordinationMessage(reason) {
3776
3846
  if (reason === "throttle-paused") return "Paused after repeated rate limits; refresh to retry";
3777
3847
  if (retryableCoordination(reason)) return "Coordinating with your other panes";
3778
3848
  if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
3849
+ if (reason === "debt-journal-full") return "Coordination journal full; retrying after older requests settle";
3779
3850
  return "Verified identity unavailable; waiting to retry";
3780
3851
  }
3781
3852
 
@@ -3821,6 +3892,9 @@ const GOVERNOR_MAX_LEASES = 128;
3821
3892
  const GOVERNOR_MAX_INTENTS = 512;
3822
3893
  const GOVERNOR_MAX_RESERVATIONS = 512;
3823
3894
  const GOVERNOR_MAX_DEBT_GROUPS = 128;
3895
+ // Quiescent groups merge once the journal is this full, so retirement never
3896
+ // waits on a probe publication that a full journal would itself block.
3897
+ const DEBT_JOURNAL_COMPACT_AT = GOVERNOR_MAX_DEBT_GROUPS / 2;
3824
3898
  const GOVERNOR_MAX_LEDGER_BYTES = 2 * 1024 * 1024;
3825
3899
  const GOVERNOR_LOCK_WAIT_MS = 250;
3826
3900
  // How old an unreadable lock record may be before it counts as abandoned. A
@@ -4458,10 +4532,8 @@ function migrateLegacyGovernorReceipts(legacy, nowMs) {
4458
4532
  if (RATE_RESOURCES.some((resource) => units[resource] > 0)) {
4459
4533
  const nonce = governorId();
4460
4534
  state.debtGroups[nonce] = {
4461
- nonce, ownerNonce: null, ownerPid: null, ownerBirth: null, bootId: coordinationBootId(),
4462
- unknownChild: true, sealed: true, quiescent: false,
4463
- units, counts, at: nowMs, barrierAt: null,
4464
- barrierRevisions: { core: 0, graphql: 0 },
4535
+ ...newDebtGroup({ nonce, bootId: coordinationBootId(), unknownChild: true, at: nowMs }),
4536
+ units, counts,
4465
4537
  };
4466
4538
  for (const resource of RATE_RESOURCES) {
4467
4539
  state.debt[resource].unresolvedUnits = units[resource];
@@ -4528,25 +4600,57 @@ function debtGroupTarget(state, receipt, groupId = null) {
4528
4600
  return state.debtGroups[UNKNOWN_DEBT_GROUP_ID] ? governorId() : UNKNOWN_DEBT_GROUP_ID;
4529
4601
  }
4530
4602
 
4603
+ const ACCOUNTING_OVERFLOW = Object.freeze({ ok: false, reason: "accounting-overflow" });
4604
+
4605
+ // The one shape normalizeGovernorState accepts for a group; every site that
4606
+ // creates a group starts from it so a new field cannot be missed in one copy.
4607
+ function newDebtGroup({ nonce = governorId(), bootId, unknownChild, at, quiescent = false, barrierAt = null }) {
4608
+ return {
4609
+ nonce, ownerNonce: null, ownerPid: null, ownerBirth: null, bootId,
4610
+ unknownChild, sealed: true, quiescent,
4611
+ units: { core: 0, graphql: 0 }, counts: { core: 0, graphql: 0 },
4612
+ at, barrierAt, barrierRevisions: { core: 0, graphql: 0 },
4613
+ };
4614
+ }
4615
+
4616
+ // When age alone settles an unresolved group. Only a sealed group ages out;
4617
+ // an owner acknowledgement can settle either kind earlier.
4618
+ function debtGroupSettlesAt(group) {
4619
+ return group.sealed ? group.at + IDENTITY_UNCERTAIN_MAX_MS : Number.POSITIVE_INFINITY;
4620
+ }
4621
+
4622
+ function hasLiveProbeClaim(state, at) {
4623
+ return RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > at);
4624
+ }
4625
+
4626
+ // A journal with no free slot is a capacity condition, not an arithmetic one.
4627
+ // A retry can succeed once an unresolved group ages out or a live claim ends
4628
+ // and lets compaction run; the probe interval bounds the wait so a pane never
4629
+ // shows a retry hours away.
4630
+ function debtJournalFullFailure(state, nowMs) {
4631
+ const unblockAt = Math.min(
4632
+ ...Object.values(state.debtGroups).filter((group) => !group.quiescent).map(debtGroupSettlesAt),
4633
+ ...RATE_RESOURCES.map((resource) => state.probeClaims[resource]?.leaseUntil ?? Number.POSITIVE_INFINITY),
4634
+ nowMs + BUDGET_PROBE_MS,
4635
+ );
4636
+ return { ok: false, reason: "debt-journal-full", retryAt: Math.max(unblockAt, nowMs + 1000) };
4637
+ }
4638
+
4639
+ // Returns null when the charge is recorded, otherwise the failure to report.
4531
4640
  function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
4532
4641
  const units = Object.fromEntries(RATE_RESOURCES.map((resource) => [resource,
4533
4642
  reservationCost(receipt, resource, state.leases, nowMs)]));
4534
- if (RATE_RESOURCES.every((resource) => units[resource] === 0)) return true;
4643
+ if (RATE_RESOURCES.every((resource) => units[resource] === 0)) return null;
4535
4644
  const id = debtGroupTarget(state, receipt, groupId);
4536
- if (id === null) return false;
4645
+ if (id === null) return debtJournalFullFailure(state, nowMs);
4537
4646
  const incomingBootId = receipt.receipt?.bootId ?? coordinationBootId();
4538
4647
  const dedicated = id === groupId;
4539
- const group = state.debtGroups[id] ?? {
4540
- nonce: governorId(), ownerNonce: null, ownerPid: null, ownerBirth: null,
4541
- bootId: incomingBootId,
4542
- unknownChild: !dedicated || !receipt.receipt, sealed: true, quiescent: false,
4543
- units: { core: 0, graphql: 0 }, counts: { core: 0, graphql: 0 },
4544
- at: nowMs, barrierAt: null,
4545
- barrierRevisions: { core: 0, graphql: 0 },
4546
- };
4547
- if (group.quiescent) return false;
4648
+ const group = state.debtGroups[id] ?? newDebtGroup({
4649
+ bootId: incomingBootId, unknownChild: !dedicated || !receipt.receipt, at: nowMs,
4650
+ });
4651
+ if (group.quiescent) return ACCOUNTING_OVERFLOW;
4548
4652
  if (receipt.receipt && dedicated) {
4549
- if (group.ownerNonce !== null && group.ownerNonce !== receipt.receipt.ownerNonce) return false;
4653
+ if (group.ownerNonce !== null && group.ownerNonce !== receipt.receipt.ownerNonce) return ACCOUNTING_OVERFLOW;
4550
4654
  group.ownerNonce = receipt.receipt.ownerNonce;
4551
4655
  group.ownerPid = receipt.receipt.ownerPid;
4552
4656
  group.ownerBirth = receipt.receipt.ownerBirth;
@@ -4561,7 +4665,7 @@ function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
4561
4665
  !Number.isSafeInteger(group.units[resource] + amount) ||
4562
4666
  !Number.isSafeInteger(state.debt[resource].unresolvedCount + (amount > 0 ? 1 : 0)) ||
4563
4667
  !Number.isSafeInteger(state.debt[resource].revision + 1) ||
4564
- !Number.isSafeInteger(group.counts[resource] + (amount > 0 ? 1 : 0))) return false;
4668
+ !Number.isSafeInteger(group.counts[resource] + (amount > 0 ? 1 : 0))) return ACCOUNTING_OVERFLOW;
4565
4669
  }
4566
4670
  for (const resource of RATE_RESOURCES) {
4567
4671
  const amount = units[resource];
@@ -4575,7 +4679,7 @@ function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
4575
4679
  // which reconcileBootDebt may treat the group as settled.
4576
4680
  group.at = Math.max(group.at, nowMs);
4577
4681
  state.debtGroups[id] = group;
4578
- return true;
4682
+ return null;
4579
4683
  }
4580
4684
 
4581
4685
  function makeDebtGroupQuiescent(state, group, at) {
@@ -4602,23 +4706,25 @@ function makeDebtGroupQuiescent(state, group, at) {
4602
4706
  return true;
4603
4707
  }
4604
4708
 
4709
+ // Returns null when the receipt is retired, otherwise the failure to report.
4605
4710
  function retireControlReceiptToDebt(state, resource, at) {
4606
4711
  const control = state.controlReceipts[resource];
4607
- if (!control) return true;
4712
+ if (!control) return null;
4608
4713
  // A control receipt without an issued dispatch cannot have started HTTP.
4609
4714
  // Removing it also fences a late owner before it can issue a child.
4610
4715
  if (control.receipt.dispatches.length === 0) {
4611
4716
  state.controlReceipts[resource] = null;
4612
- return true;
4717
+ return null;
4613
4718
  }
4614
4719
  const generation = control.receipt.generation;
4615
- if (!transferReceiptToDebt(state, control, at, generation)) return false;
4720
+ const failure = transferReceiptToDebt(state, control, at, generation);
4721
+ if (failure) return failure;
4616
4722
  const group = state.debtGroups[generation];
4617
4723
  if (group && !group.unknownChild && group.ownerPid !== null &&
4618
4724
  group.ownerBirth !== null && group.bootId !== null &&
4619
- !makeDebtGroupQuiescent(state, group, at)) return false;
4725
+ !makeDebtGroupQuiescent(state, group, at)) return ACCOUNTING_OVERFLOW;
4620
4726
  state.controlReceipts[resource] = null;
4621
- return true;
4727
+ return null;
4622
4728
  }
4623
4729
 
4624
4730
  // A reboot proves an unknown owner's children are gone. So does age: a group's
@@ -4630,16 +4736,49 @@ function reconcileBootDebt(state, at) {
4630
4736
  for (const group of Object.values(state.debtGroups)) {
4631
4737
  if (group.quiescent) continue;
4632
4738
  const priorBoot = bootId !== null && group.bootId !== null && group.bootId !== bootId;
4633
- const outlivedWindow = group.sealed && at - group.at >= IDENTITY_UNCERTAIN_MAX_MS;
4739
+ const outlivedWindow = at >= debtGroupSettlesAt(group);
4634
4740
  if (!priorBoot && !outlivedWindow) continue;
4635
4741
  if (!makeDebtGroupQuiescent(state, group, at)) {
4636
- return { ok: false, reason: "accounting-overflow" };
4742
+ return ACCOUNTING_OVERFLOW;
4637
4743
  }
4638
4744
  changed = true;
4639
4745
  }
4640
4746
  return { ok: true, changed };
4641
4747
  }
4642
4748
 
4749
+ // Quiescent groups are frozen until a probe publication retires them, and a
4750
+ // publication needs a claim that first charges its control receipt to a group.
4751
+ // With the journal full of quiescent groups that charge has nowhere to go, so
4752
+ // the only operation that drains the journal is the one it blocks. Merging the
4753
+ // frozen groups keeps the totals and frees the slots. A claim compares the
4754
+ // snapshot it took against the groups it finds at publication, so the shape may
4755
+ // only change while no claim is live.
4756
+ function compactQuiescentDebtGroups(state, at) {
4757
+ if (Object.keys(state.debtGroups).length < DEBT_JOURNAL_COMPACT_AT || hasLiveProbeClaim(state, at)) {
4758
+ return { ok: true, changed: false };
4759
+ }
4760
+ const quiescent = Object.entries(state.debtGroups).filter(([, group]) => group.quiescent);
4761
+ let changed = false;
4762
+ for (const [bootId, entries] of Map.groupBy(quiescent, ([, group]) => group.bootId)) {
4763
+ if (entries.length < 2) continue;
4764
+ const groups = entries.map(([, group]) => group);
4765
+ const merged = newDebtGroup({ bootId, unknownChild: true, quiescent: true,
4766
+ at: Math.max(...groups.map((group) => group.at)),
4767
+ barrierAt: Math.max(0, ...groups.map((group) => group.barrierAt ?? 0)) });
4768
+ for (const resource of RATE_RESOURCES) {
4769
+ merged.units[resource] = groups.reduce((sum, group) => sum + group.units[resource], 0);
4770
+ merged.counts[resource] = groups.reduce((sum, group) => sum + group.counts[resource], 0);
4771
+ if (!Number.isSafeInteger(merged.units[resource]) ||
4772
+ !Number.isSafeInteger(merged.counts[resource])) return ACCOUNTING_OVERFLOW;
4773
+ merged.barrierRevisions[resource] = Math.max(...groups.map((group) => group.barrierRevisions[resource]));
4774
+ }
4775
+ for (const [id] of entries) delete state.debtGroups[id];
4776
+ state.debtGroups[merged.nonce] = merged;
4777
+ changed = true;
4778
+ }
4779
+ return { ok: true, changed };
4780
+ }
4781
+
4643
4782
  function compactGovernorReceipts(state, nowMs) {
4644
4783
  const sealed = new Set();
4645
4784
  const full = Object.keys(state.reservations).length >= GOVERNOR_MAX_RESERVATIONS;
@@ -4663,18 +4802,16 @@ function compactGovernorReceipts(state, nowMs) {
4663
4802
  const trial = structuredClone(state);
4664
4803
  for (const reservation of candidates) {
4665
4804
  const groupId = reservation.receipt?.generation ?? null;
4666
- if (debtGroupTarget(trial, reservation, groupId) === null) return { ok: true, changed: false };
4667
- if (!transferReceiptToDebt(trial, reservation, nowMs, groupId)) {
4668
- return { ok: false, reason: "accounting-overflow" };
4669
- }
4805
+ const failure = transferReceiptToDebt(trial, reservation, nowMs, groupId);
4806
+ if (failure?.reason === "debt-journal-full") return { ok: true, changed: false };
4807
+ if (failure) return failure;
4670
4808
  }
4671
4809
  }
4672
4810
  if (full) {
4673
4811
  for (const [id, reservation] of Object.entries(state.reservations)) {
4674
4812
  if (reservation.status !== "completed" || reservation.receipt) continue;
4675
- if (!transferReceiptToDebt(state, reservation, nowMs)) {
4676
- return { ok: false, reason: "accounting-overflow" };
4677
- }
4813
+ const failure = transferReceiptToDebt(state, reservation, nowMs);
4814
+ if (failure) return failure;
4678
4815
  delete state.reservations[id];
4679
4816
  changed = true;
4680
4817
  }
@@ -4695,9 +4832,9 @@ function compactGovernorReceipts(state, nowMs) {
4695
4832
  for (const generation of sealed) {
4696
4833
  for (const [id, reservation] of Object.entries(state.reservations)) {
4697
4834
  if (reservation.receipt?.generation !== generation) continue;
4698
- if (!(reservation.status === "started" && reservation.receipt.dispatches.length === 0) &&
4699
- !transferReceiptToDebt(state, reservation, nowMs, generation)) {
4700
- return { ok: false, reason: "accounting-overflow" };
4835
+ if (!(reservation.status === "started" && reservation.receipt.dispatches.length === 0)) {
4836
+ const failure = transferReceiptToDebt(state, reservation, nowMs, generation);
4837
+ if (failure) return failure;
4701
4838
  }
4702
4839
  delete state.reservations[id];
4703
4840
  state.ownerGenerations[reservation.leaseId] = governorId();
@@ -4706,7 +4843,7 @@ function compactGovernorReceipts(state, nowMs) {
4706
4843
  if (group && !group.unknownChild && group.ownerPid !== null &&
4707
4844
  group.ownerBirth !== null && group.bootId !== null &&
4708
4845
  !makeDebtGroupQuiescent(state, group, nowMs)) {
4709
- return { ok: false, reason: "accounting-overflow" };
4846
+ return ACCOUNTING_OVERFLOW;
4710
4847
  }
4711
4848
  }
4712
4849
  retireOwnerGenerations(state);
@@ -4792,7 +4929,7 @@ function writeGovernorState(path, state) {
4792
4929
  try {
4793
4930
  if (state?.version === GOVERNOR_STATE_VERSION) {
4794
4931
  if (!Number.isSafeInteger(state.revision) || state.revision === Number.MAX_SAFE_INTEGER) {
4795
- return { ok: false, reason: "accounting-overflow" };
4932
+ return ACCOUNTING_OVERFLOW;
4796
4933
  }
4797
4934
  state.revision += 1;
4798
4935
  }
@@ -4822,7 +4959,7 @@ function writeGovernorQuotaState(path, state, nowMs) {
4822
4959
  const normalized = normalizeGovernorState(state, nowMs, { prune: false });
4823
4960
  if (!normalized) return { ok: false, reason: "corrupt" };
4824
4961
  if (!governorAccountingSafe(normalized, nowMs)) {
4825
- return { ok: false, reason: "accounting-overflow" };
4962
+ return ACCOUNTING_OVERFLOW;
4826
4963
  }
4827
4964
  return writeGovernorState(path, normalized);
4828
4965
  }
@@ -5136,17 +5273,19 @@ function mutateGovernor(scope, nowMs, mutate) {
5136
5273
  const loaded = readGovernorState(scope.path, at, { persistMigration: false });
5137
5274
  if (!loaded.ok) return loaded;
5138
5275
  const state = loaded.value;
5139
- if (!governorAccountingSafe(state, at)) return { ok: false, reason: "accounting-overflow" };
5276
+ if (!governorAccountingSafe(state, at)) return ACCOUNTING_OVERFLOW;
5140
5277
  const oldBootReconciled = reconcileBootDebt(state, at);
5141
5278
  if (!oldBootReconciled.ok) return oldBootReconciled;
5142
5279
  const compacted = compactGovernorReceipts(state, at);
5143
5280
  if (!compacted.ok) return compacted;
5144
5281
  const bootReconciled = reconcileBootDebt(state, at);
5145
5282
  if (!bootReconciled.ok) return bootReconciled;
5283
+ const journal = compactQuiescentDebtGroups(state, at);
5284
+ if (!journal.ok) return journal;
5146
5285
  // A mutator can reject without writing. Keep an untouched migration so
5147
5286
  // that even that path upgrades the file once before releasing the lock.
5148
5287
  const migration = loaded.migrated || compacted.changed ||
5149
- oldBootReconciled.changed || bootReconciled.changed
5288
+ oldBootReconciled.changed || bootReconciled.changed || journal.changed
5150
5289
  ? structuredClone(state) : null;
5151
5290
  const returnAfterMigration = (result) => {
5152
5291
  if (migration === null) return result;
@@ -5155,7 +5294,7 @@ function mutateGovernor(scope, nowMs, mutate) {
5155
5294
  };
5156
5295
  const value = mutate(state, at);
5157
5296
  if (value?.ok === false && value.write !== true) return returnAfterMigration(value);
5158
- if (!governorAccountingSafe(state, at)) return returnAfterMigration({ ok: false, reason: "accounting-overflow" });
5297
+ if (!governorAccountingSafe(state, at)) return returnAfterMigration(ACCOUNTING_OVERFLOW);
5159
5298
  const normalized = normalizeGovernorState(state, at);
5160
5299
  if (!normalized) return returnAfterMigration({ ok: false, reason: "corrupt" });
5161
5300
  const written = writeGovernorState(scope.path, normalized);
@@ -5387,9 +5526,8 @@ function claimProbe(scope, leaseId, nowMs, resource) {
5387
5526
  if (resource === "graphql" && state.controlReceipts.graphql) {
5388
5527
  // The old claim's lease has expired. Keep an issued child charged as
5389
5528
  // debt, and fence the old capability so a new observer can make progress.
5390
- if (!retireControlReceiptToDebt(state, "graphql", at)) {
5391
- return { ok: false, reason: "accounting-overflow" };
5392
- }
5529
+ const failure = retireControlReceiptToDebt(state, "graphql", at);
5530
+ if (failure) return failure;
5393
5531
  }
5394
5532
  const nonce = randomUUID();
5395
5533
  const startedReservationIds = Object.entries(state.reservations)
@@ -5408,7 +5546,7 @@ function claimProbe(scope, leaseId, nowMs, resource) {
5408
5546
  const debtUnits = quiescentGroups.reduce((sum, group) => sum + group.units[resource], 0);
5409
5547
  const debtCount = quiescentGroups.reduce((sum, group) => sum + group.counts[resource], 0);
5410
5548
  if (!Number.isSafeInteger(debtUnits) || !Number.isSafeInteger(debtCount)) {
5411
- return { ok: false, reason: "accounting-overflow" };
5549
+ return ACCOUNTING_OVERFLOW;
5412
5550
  }
5413
5551
  const debtSnapshot = {
5414
5552
  units: debtUnits,
@@ -5615,7 +5753,7 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs, resource) {
5615
5753
  }
5616
5754
  if (changedResources.length === 0) return { ok: false, reason: "corrupt" };
5617
5755
  if (!retireClaimedQuiescentDebt(state, resource, claim)) {
5618
- return { ok: false, reason: "accounting-overflow" };
5756
+ return ACCOUNTING_OVERFLOW;
5619
5757
  }
5620
5758
  const completedBeforeClaim = Object.entries(state.reservations)
5621
5759
  .filter(([, reservation]) =>
@@ -5735,9 +5873,8 @@ function failProbeClaim(scope, leaseId, nonce, nowMs, resource) {
5735
5873
  const control = state.controlReceipts.graphql;
5736
5874
  if (control.receipt.dispatches.length === 0) state.controlReceipts.graphql = null;
5737
5875
  else if (control.receipt.dispatches.every((dispatch) => dispatch.terminalAt !== null)) {
5738
- if (!retireControlReceiptToDebt(state, "graphql", at)) {
5739
- return { ok: false, reason: "accounting-overflow" };
5740
- }
5876
+ const failure = retireControlReceiptToDebt(state, "graphql", at);
5877
+ if (failure) return failure;
5741
5878
  }
5742
5879
  }
5743
5880
  state.observers[resource] = {
@@ -6041,7 +6178,7 @@ function acknowledgeSealedGeneration(scope, capability, sealedNonce, nowMs) {
6041
6178
  reservation.receipt?.generation === capability.generation)) {
6042
6179
  return { ok: false, reason: "quiescence-unverified" };
6043
6180
  }
6044
- if (!makeDebtGroupQuiescent(state, group, at)) return { ok: false, reason: "accounting-overflow" };
6181
+ if (!makeDebtGroupQuiescent(state, group, at)) return ACCOUNTING_OVERFLOW;
6045
6182
  return { value: { status: "acknowledged", nonce: group.nonce } };
6046
6183
  });
6047
6184
  }
@@ -6300,13 +6437,16 @@ function governorHealth(result, nowMs = Date.now()) {
6300
6437
  if (!result?.ok) return { status: `unavailable (${result?.reason ?? "unknown"})`, leases: 0, resources: {} };
6301
6438
  const state = result.value;
6302
6439
  let status = "healthy";
6303
- if (RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > nowMs)) status = "waiting for probe";
6440
+ if (hasLiveProbeClaim(state, nowMs)) status = "waiting for probe";
6304
6441
  else if (RATE_RESOURCES.some((resource) => !state.budgets[resource] ||
6305
6442
  nowMs - state.budgets[resource].observedAt > budgetSnapshotTtl(resource))) status = "stale";
6306
6443
  else if (RATE_RESOURCES.some((resource) => state.budgets[resource].blockUntil > nowMs)) status = "blocked";
6444
+ const groups = Object.values(state.debtGroups);
6307
6445
  return {
6308
6446
  status,
6309
6447
  leases: Object.keys(state.leases).length,
6448
+ debtGroups: groups.length,
6449
+ quiescentGroups: groups.filter((group) => group.quiescent).length,
6310
6450
  resources: Object.fromEntries(RATE_RESOURCES.flatMap((resource) => {
6311
6451
  const budget = state.budgets[resource];
6312
6452
  return budget ? [[resource, {
@@ -6345,12 +6485,15 @@ function doctorCachedQuotaScope(host, { root = identityRegistryRoot(), nowMs = D
6345
6485
  try {
6346
6486
  if (!lstatSync(path).isFile()) return { ok: false, reason: "unsafe quota path", path };
6347
6487
  } catch (error) {
6348
- return { ok: false, reason: error?.code === "ENOENT" ? "cached quota missing" : "cached quota unreadable", path };
6488
+ if (error?.code !== "ENOENT") return { ok: false, reason: "cached quota unreadable", path };
6349
6489
  }
6350
- const result = readGovernorState(path, nowMs, { persistMigration: false });
6490
+ // The runtime's own guard classifies the file: a missing ledger for a quota
6491
+ // key the registry still lists is corrupt, never a fresh start, so doctor
6492
+ // reports the same reason the panes act on.
6493
+ const result = readIdentityQuotaState(registry, { path, quotaKey }, nowMs, { persistMigration: false });
6351
6494
  return result.ok && !result.missing
6352
6495
  ? { ok: true, result, path, quotaKey }
6353
- : { ok: false, reason: result.missing ? "cached quota missing" : result.reason, path };
6496
+ : { ok: false, reason: result.ok ? "cached quota missing" : result.reason, path };
6354
6497
  }
6355
6498
 
6356
6499
  async function retryGovernorMutation(run, { now, wait, deadline, signal }) {
@@ -6977,6 +7120,10 @@ async function openInBrowser(tabKey, item, signal, _governor = null, { run = nul
6977
7120
  // Returning quietly here is how a dead Enter key looks: the row has no usable
6978
7121
  // page, and the user is entitled to know that rather than press it again.
6979
7122
  if (!url) throw new Error("This row has no page to open yet; refresh and try again");
7123
+ return openBrowserUrl(url, signal, { run });
7124
+ }
7125
+
7126
+ async function openBrowserUrl(url, signal, { run = null } = {}) {
6980
7127
  const opener = pick(BROWSER_OPENERS, process.platform, null);
6981
7128
  if (!opener) throw new Error(`Opening a browser is not supported on ${process.platform}`);
6982
7129
  const [command, ...prefix] = opener;
@@ -8003,7 +8150,7 @@ function mapWebhookInvalidations({ event, payload, config, enterpriseHost = null
8003
8150
  ? config.targets.map((target) => ({ ...target, resources: TAB_KEYS }))
8004
8151
  : config.webhook.targets;
8005
8152
  let targets = mappedTargets.filter((target) =>
8006
- (!selectedHost || target.host === selectedHost) && (event === "installation" || names.has(target.repo)));
8153
+ (!selectedHost || target.host === selectedHost) && (event === "installation" || names.has(target.repo.toLowerCase())));
8007
8154
  if (["installation", "installation_repositories"].includes(event)) {
8008
8155
  targets = targets.filter((target) => {
8009
8156
  const provider = config.providers[target.provider];
@@ -8013,7 +8160,10 @@ function mapWebhookInvalidations({ event, payload, config, enterpriseHost = null
8013
8160
  }
8014
8161
  if (!selectedHost && event !== "installation") {
8015
8162
  const hostsByRepo = new Map();
8016
- for (const target of targets) hostsByRepo.set(target.repo, (hostsByRepo.get(target.repo) ?? new Set()).add(target.host));
8163
+ for (const target of targets) {
8164
+ const repo = target.repo.toLowerCase();
8165
+ hostsByRepo.set(repo, (hostsByRepo.get(repo) ?? new Set()).add(target.host));
8166
+ }
8017
8167
  if ([...hostsByRepo.values()].some((hosts) => hosts.size !== 1)) return { ok: false, reason: "target" };
8018
8168
  }
8019
8169
  if (targets.length === 0) return ["installation", "installation_repositories"].includes(event)
@@ -11196,6 +11346,11 @@ async function runDoctor({ probeEndpoints = false } = {}) {
11196
11346
  .filter(Boolean).sort((left, right) => right.at - left.at)[0] ?? null;
11197
11347
  const lastFailedRecovery = [...(recoveryJournal?.entries ?? [])].reverse()
11198
11348
  .find((entry) => entry.code !== "clear") ?? null;
11349
+ const ledgerPath = cachedQuota?.path ?? diagnosticScope?.path ?? null;
11350
+ // The journal cannot speak for a ledger that cannot be read, so the cached
11351
+ // read's own reason counts too; both use the governor's reason vocabulary.
11352
+ const hardRecovery = Boolean(ledgerPath) &&
11353
+ [currentRecovery?.code, cachedQuota?.reason].some((reason) => HARD_STORAGE_REASONS.includes(reason));
11199
11354
  const retryDates = RATE_RESOURCES.flatMap((resource) => {
11200
11355
  const active = recoveryJournal?.current[resource];
11201
11356
  const observer = (probeEndpoints ? governorResult : cachedQuota?.result)?.value?.observers[resource];
@@ -11252,11 +11407,16 @@ async function runDoctor({ probeEndpoints = false } = {}) {
11252
11407
  "",
11253
11408
  ...section("API governor"),
11254
11409
  field("cached scope", probeEndpoints ? "verified by probe" : cachedQuota?.ok ? "read-only cached identity" : `unavailable (${cachedQuota?.reason ?? "unknown"})`),
11255
- ...(cachedQuota?.path && !cachedQuota.ok
11256
- ? [field("state file", `${join(dirname(cachedQuota.path), "quota-[redacted].json")} (quota fingerprint withheld)`)]
11410
+ // The user has to find the file to move it.
11411
+ ...(ledgerPath && cachedQuota?.ok === false || hardRecovery
11412
+ ? [field("state file", `${join(dirname(ledgerPath), "quota-[redacted].json")} (quota fingerprint withheld)`)]
11257
11413
  : []),
11414
+ ...(hardRecovery ? [field("repair", LEDGER_REPAIR_ADVICE)] : []),
11258
11415
  field("status", governor.status),
11259
11416
  field("live leases", governor.leases),
11417
+ field("debt groups", governor.debtGroups == null
11418
+ ? "unavailable"
11419
+ : `${governor.debtGroups}/${GOVERNOR_MAX_DEBT_GROUPS} (${governor.quiescentGroups} quiescent)`),
11260
11420
  ...RATE_RESOURCES.map((resource) => {
11261
11421
  const detail = governor.resources[resource];
11262
11422
  return field(
@@ -11571,8 +11731,10 @@ const KEY_TABLE = [
11571
11731
  ["Enter", "Open the selected item or accept a prompt"],
11572
11732
  ["r", "Refresh the current tab when a safe grant is available"],
11573
11733
  ["R", "Resynchronize the current tab, ignoring cached validators and backoff"],
11734
+ ["s", "Actions: read-only setup check (standalone only; saved for five minutes)"],
11735
+ ["g", "Actions: open the selected repository Actions page"],
11574
11736
  ["w", "Adjust table column widths"],
11575
- ["?", "Show the keys (any key closes it)"],
11737
+ ["?", "Open / close contextual help; n / p changes page"],
11576
11738
  ["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
11577
11739
  ];
11578
11740
  const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
@@ -11594,6 +11756,8 @@ const HELP_PRIORITY = [
11594
11756
  "Tab / Shift+Tab",
11595
11757
  "PgUp / PgDn",
11596
11758
  "R",
11759
+ "s",
11760
+ "g",
11597
11761
  ];
11598
11762
 
11599
11763
  function helpLines(maxRows) {
@@ -15042,7 +15206,7 @@ function createAcquisitionEngine({
15042
15206
  ledger.debt[resource].unresolvedUnits + ledger.debt[resource].quiescentUnits +
15043
15207
  reservationCost(ledger.controlReceipts[resource], resource, ledger.leases, now()))]));
15044
15208
  if (Object.values(units).some((value) => !Number.isSafeInteger(value) || value < 0)) {
15045
- return { ok: false, reason: "accounting-overflow" };
15209
+ return ACCOUNTING_OVERFLOW;
15046
15210
  }
15047
15211
  return transact((state) => {
15048
15212
  const activeKeys = new Set(Object.values(state.subscriptions)
@@ -15623,6 +15787,14 @@ function coordinationNotice(reason) {
15623
15787
  const RECOVERY_JOURNAL_MAX_ENTRIES = 128;
15624
15788
  const RECOVERY_JOURNAL_MAX_BYTES = 128 * 1024;
15625
15789
  const RECOVERY_ENTRY_MAX_BYTES = 1024;
15790
+ // Reasons that no retry can clear: the ledger itself must be repaired. The
15791
+ // runtime keeps them sticky and doctor names the manual remedy for them.
15792
+ const HARD_STORAGE_REASONS = Object.freeze(["corrupt", "unwritable", "capacity", "accounting-overflow"]);
15793
+ // A source failure on a tab may be superseded by these coordination causes.
15794
+ const STICKY_OVER_SOURCE_REASONS = Object.freeze([...HARD_STORAGE_REASONS, "receipt-retry", "interrupted-request"]);
15795
+ const LEDGER_REPAIR_ADVICE = "quit every gh-glance pane, then move the state file, its .recovery.json and " +
15796
+ "registry.json out of that directory and relaunch; a fresh ledger starts, and at most the receipt and " +
15797
+ "debt units below (when shown) may be spent twice this hour";
15626
15798
  const RECOVERY_COPY = Object.freeze({
15627
15799
  busy: ["Coordinating with your other panes", "Retry automatically", "Other pane; retry"],
15628
15800
  stale: ["Coordinating with your other panes", "Retry automatically", "Other pane; retry"],
@@ -15632,6 +15804,7 @@ const RECOVERY_COPY = Object.freeze({
15632
15804
  corrupt: ["Local coordination is corrupt", "Run gh-glance --doctor; preserve the reported state file", "State corrupt; doctor"],
15633
15805
  capacity: ["Local coordination is full", "Run gh-glance --doctor; preserve the reported state file", "State full; doctor"],
15634
15806
  "accounting-overflow": ["Local coordination accounting overflow", "Run gh-glance --doctor; preserve the reported state file", "Overflow; doctor"],
15807
+ "debt-journal-full": ["Local coordination journal is full", "Retry automatically; older requests settle within an hour", "Journal full; retry"],
15635
15808
  "reservations-invalid": ["Local coordination reservation invalid", "Run gh-glance --doctor; preserve the reported state file", "Reservation; doctor"],
15636
15809
  "external-factor-invalid": ["Local coordination budget invalid", "Run gh-glance --doctor; preserve the reported state file", "Budget invalid; doctor"],
15637
15810
  "pacing-invalid": ["Local coordination pacing invalid", "Run gh-glance --doctor; preserve the reported state file", "Pacing invalid; doctor"],
@@ -15715,8 +15888,8 @@ function shouldClearRecoveryCause(cause, { sourceSuccess = false, tab = null, sc
15715
15888
 
15716
15889
  function recoveryRetryAt(decision, budget, observer, nowMs) {
15717
15890
  if (budget?.blockUntil > nowMs) return budget.blockUntil;
15718
- if (["reservations-invalid", "external-factor-invalid", "pacing-invalid", "budget-resource",
15719
- "accounting-overflow", "corrupt", "unwritable", "capacity"].includes(decision?.reason)) return null;
15891
+ if ([...HARD_STORAGE_REASONS, "reservations-invalid", "external-factor-invalid", "pacing-invalid",
15892
+ "budget-resource"].includes(decision?.reason)) return null;
15720
15893
  const resetHold = ["budget-reset", "reset", "rate-limit"].includes(decision?.reason);
15721
15894
  if (decision?.mode === "probe" && !resetHold) {
15722
15895
  return observer?.nextAt > nowMs ? observer.nextAt : null;
@@ -15760,12 +15933,14 @@ function cleanupQueueForResource(queue, resource) {
15760
15933
  return count;
15761
15934
  }
15762
15935
 
15763
- function presentRecovery(cause, { nowMs = Date.now(), cols = 80 } = {}) {
15936
+ function presentRecovery(cause, { nowMs = Date.now(), cols = 80, hasSnapshot } = {}) {
15764
15937
  if (!cause) return [];
15765
15938
  const width = Math.max(1, Number.isSafeInteger(cols) ? cols - 1 : 79);
15766
15939
  const resource = cause.resource === "graphql" ? "GraphQL" : cause.resource === "core" ? "Core" : "GitHub";
15767
- const age = Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
15768
- ? `cached ${formatDuration(nowMs - cause.sourceAt)}` : "cached age unavailable";
15940
+ const age = hasSnapshot === false ? "Not checked yet"
15941
+ : Number.isFinite(cause.sourceAt) && cause.sourceAt <= nowMs
15942
+ ? `cached ${formatDuration(nowMs - cause.sourceAt)}`
15943
+ : hasSnapshot === true ? "Last-check time unavailable" : "cached age unavailable";
15769
15944
  const retry = Number.isFinite(cause.retryAt)
15770
15945
  ? `; next ${statusInterval(cause.retryAt, nowMs)}` : "";
15771
15946
  const summary = cause.code === "legacy-unresolved" && Number.isSafeInteger(cause.debtUnits)
@@ -15800,6 +15975,189 @@ function presentRecovery(cause, { nowMs = Date.now(), cols = 80 } = {}) {
15800
15975
  `${cause.action}${retry}`.slice(0, width), age.slice(0, width)];
15801
15976
  }
15802
15977
 
15978
+ // Presentation consumes source observations, never receipt or cache-load clocks.
15979
+ function dashboardPresentation({ tab, rows = null, hasSnapshot = rows !== null,
15980
+ sourceAt = null, nowMs = Date.now(), overdue = false, onDemand = false,
15981
+ requestState = "idle", error = false, recovery = null, disconnected = false,
15982
+ securityNotes = [], securityAllUnavailable = false, target = null, diagnostic = null } = {}) {
15983
+ const label = TABS.find((candidate) => candidate.key === tab)?.label ?? "Repository";
15984
+ const empty = { actions: "No workflow runs found", issues: "No open issues",
15985
+ prs: "No open pull requests", security: "No alerts returned by the checked sources" };
15986
+ const lastEmpty = { actions: "Last check found no workflow runs.", issues: "Last check found no open issues.",
15987
+ prs: "Last check found no open pull requests.", security: "Last check returned no alerts from the checked sources." };
15988
+ const sourceCheck = !hasSnapshot ? "Not checked yet"
15989
+ : !Number.isFinite(sourceAt) || sourceAt > nowMs ? "Last-check time unavailable"
15990
+ : (() => {
15991
+ const date = new Date(sourceAt);
15992
+ const today = new Date(nowMs);
15993
+ const day = date.toDateString() === today.toDateString() ? ""
15994
+ : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} `;
15995
+ return `Checked ${day}${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
15996
+ })();
15997
+ const showTable = Array.isArray(rows) && rows.length > 0;
15998
+ const failed = Boolean(error || disconnected);
15999
+ const held = Boolean(recovery) || requestState === "waiting";
16000
+ let state = "ready";
16001
+ let title = showTable ? label : empty[tab] ?? label;
16002
+ let explanation = tab === "actions"
16003
+ ? "Actions shows automated jobs. A push starts a run only when a workflow has a matching trigger."
16004
+ : "GitHub returned this result for the selected repository.";
16005
+ let action = "Press r to refresh.";
16006
+ if (failed) {
16007
+ state = "failed";
16008
+ title = `Could not ${hasSnapshot ? "refresh" : "load"} ${label}`;
16009
+ } else if (held) {
16010
+ state = "waiting";
16011
+ title = hasSnapshot ? `Waiting to update ${label}` : `Waiting to check ${label}`;
16012
+ } else if (!hasSnapshot) {
16013
+ state = requestState === "loading" ? "loading" : "waiting";
16014
+ title = state === "loading" ? `Checking ${label}…` : `Waiting to check ${label}`;
16015
+ explanation = `Fetching this repository's ${tab === "actions" ? "workflow runs" : label.toLowerCase()}.`;
16016
+ } else if (overdue && !onDemand) {
16017
+ state = "overdue";
16018
+ title = `${label} needs an update`;
16019
+ } else if (onDemand) {
16020
+ state = "saved";
16021
+ title = `Saved ${label} view`;
16022
+ action = "Press r to check again. This view checks when opened or refreshed.";
16023
+ }
16024
+ if ((failed || held || state === "overdue" || state === "saved") && hasSnapshot && !showTable) {
16025
+ explanation = lastEmpty[tab] ?? "Showing the last checked result.";
16026
+ } else if ((failed || held) && !hasSnapshot) {
16027
+ explanation = "No successful check is available yet.";
16028
+ }
16029
+ if (recovery) {
16030
+ explanation = `${recovery.summary}. ${explanation}`;
16031
+ action = recovery.action + (Number.isFinite(recovery.retryAt)
16032
+ ? `; next ${statusInterval(recovery.retryAt, nowMs)}` : "");
16033
+ }
16034
+ if (tab === "security" && securityAllUnavailable) {
16035
+ if (!failed && !held) title = "No alert sources available";
16036
+ explanation = `${recovery ? `${recovery.summary}. ` : ""}No alert source returned data for this check.`;
16037
+ }
16038
+ return { state, title, explanation, sourceCheck, action, showTable, target,
16039
+ notes: [...(diagnostic ? [diagnostic] : []), ...(tab === "security" ? securityNotes : [])] };
16040
+ }
16041
+
16042
+ // Panels contain sanitized display text. Width follows terminal cells, including
16043
+ // wide Unicode characters; combining marks stay with the preceding character.
16044
+ function panelCellWidth(char) {
16045
+ const code = char.codePointAt(0);
16046
+ return /\p{Mark}/u.test(char) || code === 0x200d || code === 0xfe0f ? 0
16047
+ : code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
16048
+ code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7a3 ||
16049
+ code >= 0xf900 && code <= 0xfaff || code >= 0xfe10 && code <= 0xfe6f ||
16050
+ code >= 0xff00 && code <= 0xff60 || code >= 0xffe0 && code <= 0xffe6 || code >= 0x1f300) ? 2 : 1;
16051
+ }
16052
+
16053
+ function wrapPanelText(text, width) {
16054
+ const limit = Math.max(1, width);
16055
+ const lines = [];
16056
+ let line = "";
16057
+ let cells = 0;
16058
+ for (const char of safe(text)) {
16059
+ const size = panelCellWidth(char);
16060
+ if (cells + size > limit && line) {
16061
+ const space = line.lastIndexOf(" ");
16062
+ if (space > 0 && char !== " ") {
16063
+ lines.push(line.slice(0, space));
16064
+ line = line.slice(space + 1);
16065
+ // The retained word contains the same glyphs already measured above.
16066
+ cells = [...line].reduce((sum, glyph) => sum + panelCellWidth(glyph), 0);
16067
+ } else { lines.push(line.trimEnd()); line = ""; cells = 0; }
16068
+ }
16069
+ if (!line && char === " ") continue;
16070
+ line += char;
16071
+ cells += size;
16072
+ }
16073
+ if (line) lines.push(line.trimEnd());
16074
+ return lines;
16075
+ }
16076
+
16077
+ function contextualPanelLayout(view, { cols, rows } = {}) {
16078
+ const width = Math.max(1, cols - 5);
16079
+ const capacity = Math.max(1, rows - 6);
16080
+ const title = wrapPanelText(view.title, width);
16081
+ // Keep a short required remedy before explanation when the body is cramped.
16082
+ const action = capacity < 8 && /gh-glance --doctor/.test(view.action)
16083
+ ? "gh-glance --doctor" : view.action;
16084
+ const lines = [view.title, action, view.sourceCheck, ...view.notes, view.explanation]
16085
+ .flatMap((line) => wrapPanelText(line, width));
16086
+ // A long title cannot consume the only row available for the recovery action.
16087
+ if (title.length >= capacity && capacity > 1) {
16088
+ return [...title.slice(0, capacity - 1), ...wrapPanelText(action, width).slice(0, 1)];
16089
+ }
16090
+ return lines.slice(0, capacity);
16091
+ }
16092
+
16093
+ // Selection is already resolved for acquisition. Displaying it must not ask gh
16094
+ // to resolve it again, or imply that the selected account can access it.
16095
+ function dashboardTarget({ repository = effectiveRuntimeRepository(), host = effectiveRuntimeHost() } = {}) {
16096
+ // These identifiers have a strict ASCII grammar. Validate the entire value
16097
+ // instead of applying the shorter retention limit for remote display fields.
16098
+ const slug = typeof repository === "string" && REPO_PATTERN.exec(repository)?.[0] === repository
16099
+ ? repository : null;
16100
+ const selectedHost = normalizeHost(host);
16101
+ return { repository: slug, host: selectedHost,
16102
+ label: slug ? selectedHost && selectedHost !== "github.com" ? `${selectedHost}/${slug}` : slug
16103
+ : "Repository unresolved" };
16104
+ }
16105
+
16106
+ function clampHelpPage(index, count) {
16107
+ return Math.max(0, Math.min(Number.isSafeInteger(index) ? index : 0, Math.max(0, count - 1)));
16108
+ }
16109
+
16110
+ function contextHelpPages({ view, target, tab, remoteSetup = false }, keys = keyTableLines(), width = 75, height = 18) {
16111
+ const cols = Math.max(1, Math.trunc(width));
16112
+ const rows = Math.max(1, Math.trunc(height));
16113
+ const controlRows = cols >= 48 ? 1 : 2;
16114
+ const capacity = rows - controlRows;
16115
+ if (capacity < 1) return [[...wrapPanelText("q Quit", cols)].slice(0, rows)];
16116
+ const meaning = {
16117
+ actions: "Actions shows automated jobs. A push starts a run only when a workflow has a matching trigger.",
16118
+ issues: "Issues shows open issues in the selected repository.",
16119
+ prs: "Pull requests shows open requests to merge changes into the selected repository.",
16120
+ security: "Security shows alerts returned by the checked sources; unavailable sources can hide alerts.",
16121
+ };
16122
+ const title = remoteSetup ? "No GitHub remote found" : view.title;
16123
+ const action = remoteSetup ? "Close help with ?, then Enter to start gh repo create; or restart with gh-glance --repo owner/name."
16124
+ : view.action;
16125
+ // Wrap validated identity in bounded chunks so the remote-field sanitizer
16126
+ // cannot clip a long accepted target or consume its tail with a label prefix.
16127
+ const targetLines = [["Target", target.repository ?? target.label], ["Host", target.host ?? "unresolved"]]
16128
+ .flatMap(([label, value]) => {
16129
+ const text = `${label}: ${value}`;
16130
+ const wrapped = [];
16131
+ for (let offset = 0; offset < text.length; offset += MAX_FIELD_LENGTH) {
16132
+ wrapped.push(...wrapPanelText(text.slice(offset, offset + MAX_FIELD_LENGTH), cols));
16133
+ }
16134
+ return wrapped;
16135
+ });
16136
+ const context = [title, action, view.sourceCheck,
16137
+ ...targetLines,
16138
+ "Target selection does not confirm access.",
16139
+ ...(!target.repository ? ["Restart with gh-glance --repo owner/name."] : []),
16140
+ meaning[tab] === view.explanation ? null : meaning[tab], view.explanation, ...view.notes,
16141
+ "Other keys close help without performing their usual action.",
16142
+ "Keys (after closing help)", ...keys].filter(Boolean);
16143
+ const lines = context.flatMap((line) => wrapPanelText(line, cols));
16144
+ const chunks = [];
16145
+ // Narrow pages need room for navigation and quit. A compact first page keeps
16146
+ // the immediate state/remedy visible; all complete text follows unabridged.
16147
+ const priority = [title, action].flatMap((line) => wrapPanelText(line, cols));
16148
+ if (priority.length > capacity && capacity >= 2) {
16149
+ const shortAction = /gh-glance --doctor/.test(action) ? "gh-glance --doctor"
16150
+ : /gh auth/.test(action) ? "gh auth status" : remoteSetup ? "? then Enter"
16151
+ : /^Press r/.test(action) ? "Close ?, then r" : action;
16152
+ chunks.push([wrapPanelText(title, cols)[0], wrapPanelText(shortAction, cols)[0]]);
16153
+ }
16154
+ for (let offset = 0; offset < lines.length; offset += capacity) chunks.push(lines.slice(offset, offset + capacity));
16155
+ return chunks.map((chunk, index) => [...chunk,
16156
+ ...(controlRows === 1
16157
+ ? [`${index + 1}/${chunks.length} n next p prev ? close q/Esc/^C quit`]
16158
+ : [`${index + 1}/${chunks.length} n/p ?close`, "q/Esc/^C quit"])]);
16159
+ }
16160
+
15803
16161
  function recoveryJournalPath(scope) {
15804
16162
  return `${scope.path}.recovery.json`;
15805
16163
  }
@@ -16406,6 +16764,7 @@ const KEY_HINTS = [
16406
16764
  { label: "Refresh", keys: "r" },
16407
16765
  { label: "Width", keys: "w" },
16408
16766
  { label: "Quit", keys: "q" },
16767
+ { label: "Help", keys: "?" },
16409
16768
  ];
16410
16769
 
16411
16770
  function activeKeyHints({
@@ -16428,6 +16787,7 @@ function activeKeyHints({
16428
16787
  const REMOTE_SETUP_HINTS = [
16429
16788
  { label: "Create remote", keys: "Ent" },
16430
16789
  { label: "Quit", keys: "q" },
16790
+ { label: "Help", keys: "?" },
16431
16791
  ];
16432
16792
 
16433
16793
  const REMOTE_SETUP_LINES = [
@@ -16451,7 +16811,6 @@ const WIDE_STATE_WIDTH = REFRESH_STATUS_WIDTH + 1 + 12;
16451
16811
  // StatusBar receives the drawable frame width, which is one column narrower
16452
16812
  // than the terminal. A 44-cell threshold preserves the 45-column contract.
16453
16813
  const WIDE_STATE_MIN_COLS = 44;
16454
- const NOTICE_ROWS = 1;
16455
16814
 
16456
16815
  const REFRESH_STATUS_GLYPHS = Object.freeze({
16457
16816
  unicode: Object.freeze({
@@ -16473,7 +16832,7 @@ const REFRESH_STATUS_GLYPHS = Object.freeze({
16473
16832
  });
16474
16833
 
16475
16834
  function isMandatoryHint(hint) {
16476
- return hint?.label === "Refresh" || hint?.label === "Quit";
16835
+ return hint?.label === "Refresh" || hint?.label === "Quit" || hint?.label === "Help";
16477
16836
  }
16478
16837
 
16479
16838
  function refreshStatus({
@@ -16597,7 +16956,7 @@ function statusBarLayout({
16597
16956
  for (const [index, hint] of mandatory.entries()) {
16598
16957
  // A full label is optional; every essential key is not. Reserve one
16599
16958
  // separator and the key itself for each action still to come before taking
16600
- // the current action's long form. This keeps both r and q at the 24-column
16959
+ // the current action's long form. This keeps r, q and ? at the 24-column
16601
16960
  // terminal minimum, whose drawable frame is 23 cells wide.
16602
16961
  const remainingMinimum = mandatory.slice(index + 1).reduce(
16603
16962
  (total, candidate) => total + 1 + [...candidate.keys].length,
@@ -17006,10 +17365,13 @@ function App({ onCreateRemote = () => {} } = {}) {
17006
17365
  // The `?` overlay. Renders only on a keypress, so consecutive idle frames are
17007
17366
  // still byte-identical and the redraw suppression is untouched.
17008
17367
  const [showHelp, setShowHelp] = useState(false);
17368
+ const [helpPage, setHelpPage] = useState(0);
17009
17369
  // Set once, ICON_HINT_AFTER_MS into a first load that is still running. Never
17010
17370
  // reset: it only gates a line that a resolved tab stops rendering anyway, and
17011
17371
  // clearing it would cost a second state write for no visible difference.
17012
17372
  const [iconHintDue, setIconHintDue] = useState(false);
17373
+ const [actionsSetup, setActionsSetup] = useState({});
17374
+ const actionsSetupRef = useRef(null);
17013
17375
  const [errors, setErrors] = useState({ actions: null, issues: null, prs: null, security: null });
17014
17376
  const [failureContext, setFailureContext] = useState(null);
17015
17377
  const [loading, setLoading] = useState({ actions: false, issues: false, prs: false, security: false });
@@ -17064,6 +17426,9 @@ function App({ onCreateRemote = () => {} } = {}) {
17064
17426
  // transient, and they are the ones worth the space. Derived here at render
17065
17427
  // time rather than cached alongside the notes, because the unchanged-payload
17066
17428
  // short-circuit can skip parse() entirely and a cached string would go stale.
17429
+ // Each unsuccessful source contributes one note; successful sources contribute
17430
+ // none (truncation is separate). Count before collapsing disabled-source notes.
17431
+ const securityAllUnavailable = securityNotes.length === ALERT_SOURCES.length;
17067
17432
  const NOT_ENABLED = /not enabled/i;
17068
17433
  const disabledNotes = securityNotes.filter((n) => NOT_ENABLED.test(n));
17069
17434
  const otherNotes = securityNotes.filter((n) => !NOT_ENABLED.test(n));
@@ -17074,19 +17439,173 @@ function App({ onCreateRemote = () => {} } = {}) {
17074
17439
  ...otherNotes,
17075
17440
  ]
17076
17441
  : securityNotes;
17077
- // Counts the lines actually rendered, not the notes collected -- getting this
17078
- // wrong by one row is what makes ink repaint the whole frame.
17079
- const extraLines =
17080
- (activeRecovery || tabError || activeGovernorDecision?.coordinationError ||
17081
- activeGovernorDecision?.mode === "paused" || activeGovernorDecision?.disconnected ||
17082
- data[tab.key] !== null && !runtimeIdentityCoordinator?.current()
17083
- ? 3 : NOTICE_ROWS) +
17084
- (tab.key === "security" && !remoteSetup ? securityLines.length : 0);
17085
- // Reserve lines for: the tab bar and the divider under it (2), the panel's
17086
- // top and bottom edges (2), the column header and its separator (2), and
17087
- // the status line (1), plus a 1-line safety margin. Slack is absorbed by
17088
- // the spacer in the tree below.
17089
- const bodyRows = Math.max(1, rows - 8 - extraLines);
17442
+ const admittedCadenceRef = useRef({});
17443
+ // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
17444
+ // sleeping is exactly the gap this is meant to report, and a monotonic clock
17445
+ // does not advance across suspend. Never written on the failure path, or a
17446
+ // persistently failing tab would report itself fresh forever.
17447
+ const lastOkRef = useRef(
17448
+ Object.fromEntries(
17449
+ TABS.flatMap((candidate) => {
17450
+ const cachedTab = cachedEntry?.tabs[candidate.key];
17451
+ const cachedAge = collectorCachedSourceAge(cachedTab);
17452
+ const lastOk = cachedAge === null ? cachedTab?.lastOk : Date.now() - cachedAge;
17453
+ return lastOk == null ? [] : [[candidate.key, lastOk]];
17454
+ }),
17455
+ ),
17456
+ );
17457
+ const collectorSourceAgeRef = useRef(Object.fromEntries(TABS.flatMap((candidate) => {
17458
+ const cachedTab = cachedEntry?.tabs[candidate.key];
17459
+ const ageMs = collectorCachedSourceAge(cachedTab);
17460
+ return ageMs === null ? [] : [[candidate.key, { ageMs, measuredAt: performance.now() }]];
17461
+ })));
17462
+ const statusDisconnected = runtime.connect !== null
17463
+ ? activeGovernorDecision?.disconnected === true
17464
+ : data[tab.key] !== null && !runtimeIdentityCoordinator?.current();
17465
+ const items = data[tab.key];
17466
+ const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
17467
+ const coordinationError = activeGovernorDecision?.coordinationError && !remoteSetup;
17468
+ const coordinationReason = activeFailure ?? activeGovernorDecision?.reason ?? "unavailable";
17469
+ const noticeReason = activeRecovery?.code ??
17470
+ (tabError?.kind === "fetch" ? tabError.verdict : null) ??
17471
+ (coordinationError || activeGovernorDecision?.mode === "paused" ? coordinationReason : null) ??
17472
+ (statusDisconnected ? runtime.connect === null ? "identity-unavailable"
17473
+ : activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null);
17474
+ // A continuous fault stays sustained when its explanation becomes more
17475
+ // specific. Only losing the fault (or switching tabs) restarts the debounce.
17476
+ const coordinationCondition = !remoteSetup && noticeReason ? tab.key : null;
17477
+ useEffect(() => {
17478
+ setVisibleCoordinationCondition(null);
17479
+ if (coordinationCondition === null) return;
17480
+ const timer = setTimeout(
17481
+ () => setVisibleCoordinationCondition(coordinationCondition),
17482
+ COORDINATION_NOTICE_AFTER_MS,
17483
+ );
17484
+ return () => clearTimeout(timer);
17485
+ }, [coordinationCondition]);
17486
+ const showCoordinationNotice = coordinationCondition !== null &&
17487
+ visibleCoordinationCondition === coordinationCondition;
17488
+ const noticeResource = activeResource;
17489
+ const noticeSourceAt = collectorSourceAgeRef.current[tab.key]
17490
+ ? now.getTime() - collectorSourceAgeRef.current[tab.key].ageMs -
17491
+ Math.max(0, performance.now() - collectorSourceAgeRef.current[tab.key].measuredAt)
17492
+ : lastOkRef.current[tab.key] ?? null;
17493
+ const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
17494
+ reason: noticeReason === "other" ? "network-outage" : noticeReason,
17495
+ resource: noticeResource, sourceAt: noticeSourceAt,
17496
+ retryAt: activeGovernorDecision?.retryAt ?? activeGovernorDecision?.resetMs ??
17497
+ activeGovernorDecision?.notBefore ?? null,
17498
+ origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
17499
+ }) : null);
17500
+ const recoveryLines = showCoordinationNotice && noticeCause
17501
+ ? presentRecovery(noticeCause, { nowMs: now.getTime(), cols, hasSnapshot: items !== null })
17502
+ : [!remoteSetup && displayError ? displayError : ""];
17503
+ const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
17504
+ // Data can be arbitrarily old without anything on screen saying so: a failing
17505
+ // poll only surfaces on the tab you have selected, and after a laptop sleep
17506
+ // every pane is plausible and wrong. Threshold-gated and minute-granular on
17507
+ // purpose -- see STALE_AFTER_MS.
17508
+ // Read from the ref written on every successful poll, not from meta.at, which
17509
+ // only moves when the *payload* changes -- see lastOkRef. Reading a ref during
17510
+ // render lags by one render, which is harmless here because `now` advances on
17511
+ // its own and the label is minute-granular by design.
17512
+ const collectorAge = collectorSourceAgeRef.current[tab.key];
17513
+ const sourceAgeMs = collectorAge
17514
+ ? collectorAge.ageMs + Math.max(0, performance.now() - collectorAge.measuredAt)
17515
+ : null;
17516
+ const lastOk = sourceAgeMs === null ? lastOkRef.current[tab.key] : now.getTime() - sourceAgeMs;
17517
+ const staleFor = sourceAgeMs ?? (lastOk == null ? null : now.getTime() - lastOk);
17518
+ const staleAt = freshnessDeadline({
17519
+ lastOk,
17520
+ refreshMs: runtime.refreshMs,
17521
+ grantedMs: admittedCadenceRef.current[tab.key]?.grantedMs,
17522
+ governorDecision: activeGovernorDecision,
17523
+ currentEpochs: governorEpochs,
17524
+ });
17525
+ const staleLabel =
17526
+ staleFor != null && staleAt != null && now.getTime() > staleAt
17527
+ ? formatDuration(staleFor)
17528
+ : null;
17529
+ // A quiet dashboard's display clock can precede a newly completed request.
17530
+ // Compare source clocks with actual render time, without adding a timer.
17531
+ const presentationNow = Date.now();
17532
+ const selectedTarget = dashboardTarget();
17533
+ const presentation = dashboardPresentation({
17534
+ tab: tab.key, rows: items, hasSnapshot: items !== null,
17535
+ sourceAt: sourceAgeMs === null ? lastOk : presentationNow - sourceAgeMs,
17536
+ nowMs: presentationNow, overdue: Boolean(staleLabel),
17537
+ onDemand: runtime.background === "on-demand" && tab.key !== "actions" &&
17538
+ items !== null && !loading[tab.key] && !activeRequestStatus,
17539
+ requestState: loading[tab.key] || activeRequestStatus ? "loading"
17540
+ : items === null && waiting[tab.key] ? "waiting" : "idle",
17541
+ error: tabError || activeFailure, recovery: noticeCause, diagnostic: displayError,
17542
+ disconnected: statusDisconnected, securityNotes: securityLines, securityAllUnavailable,
17543
+ target: selectedTarget.label,
17544
+ });
17545
+ const setupView = tab.key === "actions" ? actionsSetupPresentation(actionsSetup, presentationNow) : null;
17546
+ if (tab.key === "actions") {
17547
+ const url = actionsPageUrl(selectedTarget);
17548
+ if (runtime.connect !== null && setupView.title && (statusDisconnected || noticeCause || tabError)) {
17549
+ setupView.action = `${statusDisconnected ? "Restore connection;" : presentation.action} g Actions; ? Help`;
17550
+ }
17551
+ const showSetupPanel = !presentation.showTable && !remoteSetup &&
17552
+ ((presentation.state === "ready" && !showCoordinationNotice) || runtime.connect !== null);
17553
+ presentation.notes.push(...(setupView.title && !showSetupPanel ? [setupView.title] : []), ...setupView.notes,
17554
+ "Close help with ?, then press s for a read-only setup check or g to open Actions.",
17555
+ ...(url ? [url] : ["Restart with gh-glance --repo owner/name."]));
17556
+ if (showSetupPanel) {
17557
+ if (setupView.title) {
17558
+ presentation.notes.push(`Runs: ${presentation.title}`, presentation.action);
17559
+ presentation.title = setupView.title;
17560
+ }
17561
+ if (setupView.title) presentation.sourceCheck = `Runs: ${presentation.sourceCheck}`;
17562
+ if (setupView.title || presentation.state === "ready") presentation.action = setupView.action;
17563
+ }
17564
+ }
17565
+ if (USING_NERD_ICONS && iconHintDue && items === null && !tabError) {
17566
+ presentation.notes.push("Icons blank? GH_GLANCE_ICONS=unicode");
17567
+ }
17568
+ const panelLines = presentation.showTable ? [] : contextualPanelLayout(presentation, { cols, rows });
17569
+
17570
+ const tableVisible = Array.isArray(items) && items.length > 0;
17571
+ const tableNoticeCapacity = Math.max(0, rows - 9);
17572
+ const needsTableContext = ["failed", "overdue", "waiting"].includes(presentation.state);
17573
+ const shortAction = /gh-glance --doctor/.test(presentation.action) ? "gh-glance --doctor"
17574
+ : /gh auth/.test(presentation.action) ? "Run gh auth status" : presentation.action;
17575
+ const tableNotices = needsTableContext
17576
+ ? tableNoticeCapacity < 4
17577
+ ? tableNoticeCapacity === 1 ? [shortAction]
17578
+ : showCoordinationNotice && noticeCause
17579
+ ? [noticeCause.summary, shortAction, presentation.sourceCheck]
17580
+ : [shortAction, presentation.sourceCheck, presentation.title]
17581
+ : [presentation.title, presentation.sourceCheck, ...recoveryLines.filter(Boolean)]
17582
+ : recoveryLines;
17583
+ const noticeLines = [...new Set([...(showCoordinationNotice ? tableNotices : []),
17584
+ ...(setupView?.title ? [setupView.title, setupView.action, ...setupView.notes] : []),
17585
+ ...tableNotices])].slice(0, tableNoticeCapacity);
17586
+ const visibleSecurityLines = tab.key === "security"
17587
+ ? securityLines.slice(0, Math.max(0, tableNoticeCapacity - noticeLines.length)) : [];
17588
+ const extraLines = !showHelp && !remoteSetup && tableVisible
17589
+ ? noticeLines.length + visibleSecurityLines.length : 0;
17590
+ // Tab strip, divider, frame edges, footer and terminal guard use six rows.
17591
+ // Table headers alone need two more. The same budget drives navigation.
17592
+ const bodyRows = Math.max(1, rows - 6 - (tableVisible && !showHelp ? 2 : 0) - extraLines);
17593
+ let helpPages = [];
17594
+ if (showHelp) {
17595
+ const fullDiagnostic = formatTabError(tabError, failureContext);
17596
+ const helpView = { ...presentation, notes: [
17597
+ ...(fullDiagnostic ? [fullDiagnostic] : []),
17598
+ ...presentation.notes.filter((note) => note !== displayError),
17599
+ ] };
17600
+ helpPages = contextHelpPages({ view: helpView, target: selectedTarget,
17601
+ tab: tab.key, remoteSetup }, keyTableLines(), cols - 5, bodyRows);
17602
+ }
17603
+ const visibleHelpPage = clampHelpPage(helpPage, helpPages.length);
17604
+ const helpPageRef = useRef({ index: 0, count: 0 });
17605
+ helpPageRef.current = { index: visibleHelpPage, count: helpPages.length };
17606
+ useEffect(() => {
17607
+ if (helpPage !== visibleHelpPage) setHelpPage(visibleHelpPage);
17608
+ }, [helpPage, visibleHelpPage]);
17090
17609
 
17091
17610
  // Read at fetch time rather than being a hook dependency, so dragging the
17092
17611
  // pane wider doesn't cancel and restart in-flight requests -- the next tick
@@ -17109,28 +17628,8 @@ function App({ onCreateRemote = () => {} } = {}) {
17109
17628
  ANIMATE && tab.key === "actions" && (data.actions ?? []).some((r) => r.status === "in_progress");
17110
17629
 
17111
17630
  const inFlightRef = useRef({});
17112
- const admittedCadenceRef = useRef({});
17113
17631
  const rawRef = useRef({});
17114
17632
  const entityRef = useRef(new Map());
17115
- // Last *successful* poll per tab, wall-clock. Wall-clock on purpose: a laptop
17116
- // sleeping is exactly the gap this is meant to report, and a monotonic clock
17117
- // does not advance across suspend. Never written on the failure path, or a
17118
- // persistently failing tab would report itself fresh forever.
17119
- const lastOkRef = useRef(
17120
- Object.fromEntries(
17121
- TABS.flatMap((candidate) => {
17122
- const cachedTab = cachedEntry?.tabs[candidate.key];
17123
- const cachedAge = collectorCachedSourceAge(cachedTab);
17124
- const lastOk = cachedAge === null ? cachedTab?.lastOk : Date.now() - cachedAge;
17125
- return lastOk == null ? [] : [[candidate.key, lastOk]];
17126
- }),
17127
- ),
17128
- );
17129
- const collectorSourceAgeRef = useRef(Object.fromEntries(TABS.flatMap((candidate) => {
17130
- const cachedTab = cachedEntry?.tabs[candidate.key];
17131
- const ageMs = collectorCachedSourceAge(cachedTab);
17132
- return ageMs === null ? [] : [[candidate.key, { ageMs, measuredAt: performance.now() }]];
17133
- })));
17134
17633
  // Keep server source timestamps separate from client-local age estimates.
17135
17634
  // An SSH receipt's age is measured on the client and cannot prove a new
17136
17635
  // source observation when a cached snapshot is delivered again.
@@ -17426,6 +17925,25 @@ function App({ onCreateRemote = () => {} } = {}) {
17426
17925
  const [openRequests] = useState(() => createOpenRequestRegistry());
17427
17926
  useEffect(() => () => openRequests.abortAll(), [openRequests]);
17428
17927
 
17928
+ function openActionsPage() {
17929
+ const target = dashboardTarget();
17930
+ const url = actionsPageUrl(target);
17931
+ if (!url) {
17932
+ setActionsSetup((state) => ({ ...state, message: "Restart with gh-glance --repo owner/name." }));
17933
+ return;
17934
+ }
17935
+ const accessKey = runtimeIdentityCoordinator?.current()?.accessKey;
17936
+ openRequests.start(`actions-page:${url}`, ({ signal }) => openBrowserUrl(url, signal))?.then(() => {
17937
+ if (actionsPageUrl() === url && runtimeIdentityCoordinator?.current()?.accessKey === accessKey) {
17938
+ setActionsSetup((state) => ({ ...state, message: null, title: null, action: null }));
17939
+ }
17940
+ }).catch((err) => {
17941
+ if (err?.name === "AbortError" || actionsPageUrl() !== url ||
17942
+ runtimeIdentityCoordinator?.current()?.accessKey !== accessKey) return;
17943
+ setActionsSetup((state) => ({ ...state, title: "Actions page unavailable", action: "g Retry; ? Details", message: `Could not open Actions: ${shortErr(err)}. Press g to retry.` }));
17944
+ });
17945
+ }
17946
+
17429
17947
  function openSelected() {
17430
17948
  const { items, key: currentKey, tabKey } = navRef.current;
17431
17949
  const item = items.find((i) => itemKey(i) === currentKey);
@@ -17480,13 +17998,19 @@ function App({ onCreateRemote = () => {} } = {}) {
17480
17998
  flushWidthPreferences();
17481
17999
  exit();
17482
18000
  } else if (showHelpRef.current) {
17483
- // Any key dismisses -- except quit, handled above, which must never be
17484
- // swallowed by a modal in a full-screen app. Deliberately does not fall
17485
- // through to the binding the key would normally trigger: closing the
17486
- // overlay is the whole intent of that press.
17487
- setShowHelp(false);
18001
+ // Paging belongs to the modal. Every other ordinary key closes it and
18002
+ // is consumed; quit remains global and width mode retains its ownership.
18003
+ if (input === "n" || input === "p") {
18004
+ setHelpPage(clampHelpPage(helpPageRef.current.index + (input === "n" ? 1 : -1),
18005
+ helpPageRef.current.count));
18006
+ } else setShowHelp(false);
17488
18007
  } else if (input === "?") {
18008
+ setHelpPage(0);
17489
18009
  setShowHelp(true);
18010
+ } else if (input === "s" && TABS[activeIndexRef.current].key === "actions") {
18011
+ void actionsSetupRef.current?.();
18012
+ } else if (input === "g" && TABS[activeIndexRef.current].key === "actions") {
18013
+ openActionsPage();
17490
18014
  } else if (input === "w") {
17491
18015
  enterWidthMode();
17492
18016
  } else if (key.downArrow || input === "j") {
@@ -17510,7 +18034,7 @@ function App({ onCreateRemote = () => {} } = {}) {
17510
18034
  // which is exactly when the key is most likely to be pressed.
17511
18035
  const request = manualRefreshRequest(refreshIntent(input));
17512
18036
  if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
17513
- } else if (input >= "1" && input <= String(TABS.length)) {
18037
+ } else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
17514
18038
  setActiveIndex(Number(input) - 1);
17515
18039
  } else if (key.tab && key.shift) {
17516
18040
  setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
@@ -17615,14 +18139,16 @@ function App({ onCreateRemote = () => {} } = {}) {
17615
18139
  reason, resource, at, retryAt, sourceAt: lastOkRef.current[key] ?? null,
17616
18140
  origin, tab: origin === "source" ? key : null,
17617
18141
  });
17618
- const hardFailure = ["corrupt", "unwritable", "capacity", "accounting-overflow"].includes(candidate.code);
18142
+ const hardFailure = HARD_STORAGE_REASONS.includes(candidate.code);
18143
+ // Contention does not prove that previously unreadable storage recovered.
18144
+ // The existing healthy-control/source-success paths clear that remedy.
18145
+ if (!hardFailure && HARD_STORAGE_REASONS.includes(previous?.code)) return;
17619
18146
  if (!hardFailure && previous?.code === "receipt-retry" &&
17620
18147
  terminalizationBacklogForResource(terminalizationBacklog, scope?.hash, resource) > 0) return;
17621
18148
  if (!hardFailure && previous?.code === "request-queue" &&
17622
18149
  cleanupQueueForResource(cleanupQueue, resource) > 0) return;
17623
18150
  if (previous?.origin === "source" && previous.tab === key && origin === "coordination" &&
17624
- !["corrupt", "unwritable", "capacity", "accounting-overflow",
17625
- "receipt-retry", "interrupted-request"].includes(candidate.code)) return;
18151
+ !STICKY_OVER_SOURCE_REASONS.includes(candidate.code)) return;
17626
18152
  if (previous?.code === candidate.code && previous?.retryAt === candidate.retryAt &&
17627
18153
  previous?.origin === candidate.origin && previous?.tab === candidate.tab &&
17628
18154
  at - previous.at < 60_000) return;
@@ -17715,6 +18241,7 @@ function App({ onCreateRemote = () => {} } = {}) {
17715
18241
  // scheduler out, and the Enter hint hands off to `gh repo create`.
17716
18242
  function enterRemoteSetup() {
17717
18243
  if (!runtimeHasNoRepositoryTarget()) return false;
18244
+ retireSetup();
17718
18245
  const failure = toTabError(new Error(NO_REMOTE_ERROR_TEXT));
17719
18246
  setErrors(Object.fromEntries(TABS.map((t) => [t.key, failure])));
17720
18247
  return true;
@@ -17734,8 +18261,11 @@ function App({ onCreateRemote = () => {} } = {}) {
17734
18261
  if (!refreshed?.ok && !tabReady) {
17735
18262
  const resource = RATE_RESOURCES.find((candidate) => tabRequestCost(key)[candidate] > 0 &&
17736
18263
  snapshot.value.observers[candidate]?.outcome !== "healthy");
18264
+ // A rejected mutation writes nothing, so the observer's stored nextAt
18265
+ // predates the failure; a failure that names its own retry time wins.
17737
18266
  pauseCoordination(key, refreshed?.reason, "observer", false,
17738
- resource ? snapshot.value.observers[resource]?.nextAt : null);
18267
+ Number.isFinite(refreshed?.retryAt) ? refreshed.retryAt
18268
+ : resource ? snapshot.value.observers[resource]?.nextAt : null);
17739
18269
  return;
17740
18270
  }
17741
18271
  publishGovernorEpochs(snapshot);
@@ -18110,7 +18640,7 @@ function App({ onCreateRemote = () => {} } = {}) {
18110
18640
  .finally(() => {
18111
18641
  inFlightRef.current[key] = false;
18112
18642
  delete inFlightStartedAt[key];
18113
- if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
18643
+ if (!currentAccess() && !setupFlight && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
18114
18644
  manualInFlight.delete(key);
18115
18645
  onSettled?.();
18116
18646
  if (!cancelled) {
@@ -18136,6 +18666,23 @@ function App({ onCreateRemote = () => {} } = {}) {
18136
18666
  }
18137
18667
 
18138
18668
  const leaseId = governorId();
18669
+ let setupFlight = null;
18670
+ let setupGeneration = 0;
18671
+ let setupPartition = null;
18672
+ let setupState = {};
18673
+ function publishSetup(next) {
18674
+ setupState = next;
18675
+ if (!cancelled) setActionsSetup(next);
18676
+ }
18677
+ function retireSetup() {
18678
+ setupGeneration += 1;
18679
+ if (setupFlight) {
18680
+ setupFlight.retired = true;
18681
+ setupFlight.controller.abort();
18682
+ }
18683
+ setupPartition = null;
18684
+ publishSetup({});
18685
+ }
18139
18686
  const pending = new Map();
18140
18687
  const cleanupQueue = new Set();
18141
18688
  let lastWallAt = Date.now();
@@ -18258,6 +18805,7 @@ function App({ onCreateRemote = () => {} } = {}) {
18258
18805
  }
18259
18806
  }
18260
18807
 
18808
+ actionsSetupRef.current = () => publishSetup({ title: "Standalone only", action: "g Actions; ? Help", message: "Setup checks are available in standalone mode. Press g to open Actions." });
18261
18809
  if (runtime.connect !== null) {
18262
18810
  const repository = effectiveRuntimeRepository({ remoteUrls: runtimeRemoteUrls });
18263
18811
  const host = effectiveRuntimeHost({ remoteUrls: runtimeRemoteUrls });
@@ -18651,13 +19199,14 @@ function App({ onCreateRemote = () => {} } = {}) {
18651
19199
  }
18652
19200
 
18653
19201
  function retireCurrentScope() {
19202
+ retireSetup();
18654
19203
  if (!cleanupScope || !registeredScopeHash) return;
18655
19204
  for (const key of [...pending.keys()]) cancelPending(key, cleanupScope);
18656
19205
  for (const subscription of acquisitionSubscriptions.values()) {
18657
19206
  acquisitionEngine.unsubscribe(subscription.id);
18658
19207
  }
18659
19208
  acquisitionSubscriptions.clear();
18660
- if (!Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
19209
+ if (!setupFlight && !Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
18661
19210
  }
18662
19211
 
18663
19212
  // discardData separates the two reasons this runs. An account *change* must
@@ -18733,6 +19282,7 @@ function App({ onCreateRemote = () => {} } = {}) {
18733
19282
  const activeTab = TABS[activeIndexRef.current].key;
18734
19283
  const kept = maintainControlLease(scope, leaseId, runtime.refreshMs, activeTab, nowMs);
18735
19284
  if (kept.ok) return scope;
19285
+ retireSetup();
18736
19286
  registeredScopeHash = null;
18737
19287
  if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
18738
19288
  return null;
@@ -19126,6 +19676,85 @@ function App({ onCreateRemote = () => {} } = {}) {
19126
19676
  } else await transport;
19127
19677
  return { persisted: true, retry: false, kind, key };
19128
19678
  }
19679
+ actionsSetupRef.current = async () => {
19680
+ if (cancelled || setupFlight) return;
19681
+ const nowMs = Date.now();
19682
+ const target = dashboardTarget();
19683
+ if (remoteSetupRef.current || !actionsPageUrl(target)) {
19684
+ publishSetup({ message: "Restart with gh-glance --repo owner/name." });
19685
+ return;
19686
+ }
19687
+ const capturedScope = ensureScope(nowMs);
19688
+ if (!capturedScope) {
19689
+ publishSetup({ ...setupState, error: "Setup check unavailable while account coordination recovers.",
19690
+ retryAt: actionsSetupRetryAt(nowMs) });
19691
+ return;
19692
+ }
19693
+ const partition = JSON.stringify(["standalone", target.host, target.repository, capturedScope.accessKey]);
19694
+ if (setupPartition !== partition) { retireSetup(); setupPartition = partition; }
19695
+ if (setupState.retryAt > nowMs) { publishSetup({ ...setupState }); return; }
19696
+ if (actionsSetupFresh(setupState.result, nowMs)) {
19697
+ publishSetup({ ...setupState, saved: true, error: null, message: null });
19698
+ return;
19699
+ }
19700
+ const generation = setupGeneration;
19701
+ const flight = { controller: new AbortController(), scope: { ...capturedScope, identityProvider: null }, retired: false };
19702
+ setupFlight = flight;
19703
+ const setupSignal = AbortSignal.any([controller.signal, flight.controller.signal]);
19704
+ const current = () => !cancelled && !setupSignal.aborted && generation === setupGeneration &&
19705
+ setupPartition === partition && identity()?.accessKey === capturedScope.accessKey &&
19706
+ actionsPageUrl() === actionsPageUrl(target) && !remoteSetupRef.current;
19707
+ publishSetup({ ...setupState, busy: true, error: null, saved: false, message: null });
19708
+ try {
19709
+ const admitted = await runAdmittedOperation({ scope: capturedScope, leaseId,
19710
+ operation: "setup:actions-workflows", priority: "manual", signal: setupSignal,
19711
+ waitMs: GOVERNOR_ADMISSION_WAIT_MS,
19712
+ run: async (signal) => {
19713
+ const response = await ghApi(actionsSetupArgs(target), {
19714
+ operation: "setup:actions-workflows", signal, includeHeaders: true,
19715
+ });
19716
+ return { ...response, observation: parseActionsSetup(response.body, response.headers), checkedAt: Date.now() };
19717
+ },
19718
+ });
19719
+ if (!current()) return;
19720
+ if (!admitted.ok) {
19721
+ const error = admitted.error;
19722
+ const failedAt = Date.now();
19723
+ let snapshot = inspectGovernor(capturedScope, failedAt);
19724
+ const throttle = classifyThrottle({ ...error?.apiResponse, stderr: errText(error) });
19725
+ let holdUntil = 0;
19726
+ if (classify(error) === "rate-limited" || throttle.kind !== "none") {
19727
+ const budget = snapshot.value?.budgets?.core;
19728
+ const resetMs = throttle.kind === "primary" ? throttle.resetMs
19729
+ : throttle.kind === "secondary" ? transportCooldownDeadline({ ...throttle, at: failedAt })
19730
+ : budget?.resetMs ?? null;
19731
+ holdUntil = resetMs ?? 0;
19732
+ handleRateLimitBlocks("actions", [{ resource: "core", resetMs,
19733
+ epoch: budget?.epoch ?? null }], failedAt);
19734
+ snapshot = inspectGovernor(capturedScope, failedAt);
19735
+ }
19736
+ const retryAt = actionsSetupRetryAt(failedAt, Math.max(
19737
+ holdUntil, snapshot.value?.budgets?.core?.blockUntil ?? 0, admitted.decision?.retryAt ?? 0,
19738
+ admitted.decision?.value?.retryAt ?? 0, admitted.decision?.value?.notBefore ?? 0,
19739
+ admitted.decision?.value?.resetMs ?? 0,
19740
+ error?.retryAt ?? 0, failedAt + githubAppResponseRetryAfter(error?.apiResponse, failedAt)));
19741
+ publishSetup({ ...setupState, busy: false, error: shortErr(error), retryAt });
19742
+ return;
19743
+ }
19744
+ publishSetup({ result: { ...admitted.value.observation, checkedAt: admitted.value.checkedAt }, busy: false });
19745
+ if (terminalizationBacklogForResource(terminalizationBacklog, capturedScope.hash, "core") > 0) {
19746
+ recordRuntimeRecovery("actions", "receipt-retry");
19747
+ }
19748
+ } finally {
19749
+ if (setupFlight === flight) setupFlight = null;
19750
+ if ((cancelled || (flight.retired && flight.scope.hash !== scope?.hash)) &&
19751
+ !Object.values(inFlightRef.current).some(Boolean)) {
19752
+ releaseLease(flight.scope, leaseId);
19753
+ }
19754
+ if (current() && setupState.busy) publishSetup({ ...setupState, busy: false });
19755
+ }
19756
+ };
19757
+
19129
19758
  fetchTabRef.current = (key, { force = false, kind = force ? "manual" : "active" } = {}) => {
19130
19759
  if (kind === "tab-switch") {
19131
19760
  // A newly active subscription is not evidence of quiet: the tab you
@@ -19513,7 +20142,9 @@ function App({ onCreateRemote = () => {} } = {}) {
19513
20142
  acquisitionEngine.unsubscribe(subscription.id);
19514
20143
  }
19515
20144
  acquisitionSubscriptions.clear();
19516
- if (cleanupScope && registeredScopeHash) releaseLease(cleanupScope, leaseId);
20145
+ retireSetup();
20146
+ actionsSetupRef.current = null;
20147
+ if (cleanupScope && registeredScopeHash && !setupFlight) releaseLease(cleanupScope, leaseId);
19517
20148
  if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
19518
20149
  if (contextCoordinatorRef.current === coordinator) contextCoordinatorRef.current = null;
19519
20150
  // `cancelled` stops state updates from a promise that already resolved;
@@ -19551,9 +20182,6 @@ function App({ onCreateRemote = () => {} } = {}) {
19551
20182
  TABS.map((t) => [t.key, data[t.key] == null && !errors[t.key] && loading[t.key]]),
19552
20183
  );
19553
20184
  const anyFirstLoad = Object.values(firstLoad).some(Boolean);
19554
- const statusDisconnected = runtime.connect !== null
19555
- ? activeGovernorDecision?.disconnected === true
19556
- : data[tab.key] !== null && !runtimeIdentityCoordinator?.current();
19557
20185
  let semanticStatus = refreshStatus({
19558
20186
  widthMode: resizeRef.current.active,
19559
20187
  remoteSetup,
@@ -19593,45 +20221,6 @@ function App({ onCreateRemote = () => {} } = {}) {
19593
20221
  return () => clearTimeout(timer);
19594
20222
  }, [anyFirstLoad, iconHintDue]);
19595
20223
 
19596
- const items = data[tab.key];
19597
- const displayError = formatTabErrorForWidth(tabError, failureContext, Math.max(1, cols - 5));
19598
- const coordinationError = activeGovernorDecision?.coordinationError && !remoteSetup;
19599
- const coordinationReason = activeFailure ?? activeGovernorDecision?.reason ?? "unavailable";
19600
- const noticeReason = activeRecovery?.code ??
19601
- (tabError?.kind === "fetch" ? tabError.verdict : null) ??
19602
- (coordinationError || activeGovernorDecision?.mode === "paused" ? coordinationReason : null) ??
19603
- (statusDisconnected ? runtime.connect === null ? "identity-unavailable"
19604
- : activeGovernorDecision?.reason === "app-auth" ? "app-auth" : "disconnected" : null);
19605
- // A continuous fault stays sustained when its explanation becomes more
19606
- // specific. Only losing the fault (or switching tabs) restarts the debounce.
19607
- const coordinationCondition = !remoteSetup && noticeReason ? tab.key : null;
19608
- useEffect(() => {
19609
- setVisibleCoordinationCondition(null);
19610
- if (coordinationCondition === null) return;
19611
- const timer = setTimeout(
19612
- () => setVisibleCoordinationCondition(coordinationCondition),
19613
- COORDINATION_NOTICE_AFTER_MS,
19614
- );
19615
- return () => clearTimeout(timer);
19616
- }, [coordinationCondition]);
19617
- const showCoordinationNotice = coordinationCondition !== null &&
19618
- visibleCoordinationCondition === coordinationCondition;
19619
- const noticeResource = activeResource;
19620
- const noticeSourceAt = collectorSourceAgeRef.current[tab.key]
19621
- ? now.getTime() - collectorSourceAgeRef.current[tab.key].ageMs -
19622
- Math.max(0, performance.now() - collectorSourceAgeRef.current[tab.key].measuredAt)
19623
- : lastOkRef.current[tab.key] ?? null;
19624
- const noticeCause = activeRecovery ?? (noticeReason ? recoveryCause({
19625
- reason: noticeReason === "other" ? "network-outage" : noticeReason,
19626
- resource: noticeResource, sourceAt: noticeSourceAt,
19627
- retryAt: activeGovernorDecision?.retryAt ?? activeGovernorDecision?.resetMs ??
19628
- activeGovernorDecision?.notBefore ?? null,
19629
- origin: tabError ? "source" : "coordination", tab: tabError ? tab.key : null,
19630
- }) : null);
19631
- const noticeLines = showCoordinationNotice && noticeCause
19632
- ? presentRecovery(noticeCause, { nowMs: now.getTime(), cols })
19633
- : [!remoteSetup && displayError ? displayError : ""];
19634
- const noticeTone = showCoordinationNotice ? ATTENTION : displayError ? ERROR_TEXT : undefined;
19635
20224
  const spin = SPINNER[frame % SPINNER.length];
19636
20225
 
19637
20226
  const counts = Object.fromEntries(
@@ -19673,37 +20262,12 @@ function App({ onCreateRemote = () => {} } = {}) {
19673
20262
  TABS.map((t) => [t.key, Boolean(errors[t.key]) && errors[t.key]?.verdict !== "no-remote"]),
19674
20263
  );
19675
20264
 
19676
- // Data can be arbitrarily old without anything on screen saying so: a failing
19677
- // poll only surfaces on the tab you have selected, and after a laptop sleep
19678
- // every pane is plausible and wrong. Threshold-gated and minute-granular on
19679
- // purpose -- see STALE_AFTER_MS.
19680
- // Read from the ref written on every successful poll, not from meta.at, which
19681
- // only moves when the *payload* changes -- see lastOkRef. Reading a ref during
19682
- // render lags by one render, which is harmless here because `now` advances on
19683
- // its own and the label is minute-granular by design.
19684
- const collectorAge = collectorSourceAgeRef.current[tab.key];
19685
- const sourceAgeMs = collectorAge
19686
- ? collectorAge.ageMs + Math.max(0, performance.now() - collectorAge.measuredAt)
19687
- : null;
19688
- const lastOk = sourceAgeMs === null ? lastOkRef.current[tab.key] : now.getTime() - sourceAgeMs;
19689
- const staleFor = sourceAgeMs ?? (lastOk == null ? null : now.getTime() - lastOk);
19690
- const staleAt = freshnessDeadline({
19691
- lastOk,
19692
- refreshMs: runtime.refreshMs,
19693
- grantedMs: admittedCadenceRef.current[tab.key]?.grantedMs,
19694
- governorDecision: activeGovernorDecision,
19695
- currentEpochs: governorEpochs,
19696
- });
19697
- const staleLabel =
19698
- staleFor != null && staleAt != null && now.getTime() > staleAt
19699
- ? formatDuration(staleFor)
19700
- : null;
19701
20265
  if (staleLabel && (["watching", "shared"].includes(semanticStatus.kind) ||
19702
20266
  activeFailure && semanticStatus.kind === "paused" &&
19703
20267
  activeGovernorDecision?.cause)) {
19704
20268
  semanticStatus = refreshStatus({
19705
20269
  stale: true,
19706
- disconnected: data[tab.key] !== null && !runtimeIdentityCoordinator?.current(),
20270
+ disconnected: statusDisconnected,
19707
20271
  });
19708
20272
  }
19709
20273
 
@@ -19713,6 +20277,7 @@ function App({ onCreateRemote = () => {} } = {}) {
19713
20277
  semanticStatus = refreshStatus({ onDemand: true });
19714
20278
  }
19715
20279
 
20280
+
19716
20281
  // A settled on-demand view has no data wake to advance its visible age.
19717
20282
  // Tick only its display clock; source timestamps and acquisition stay idle.
19718
20283
  const showCachedAge = semanticStatus.kind === "cached" && staleFor !== null;
@@ -19763,7 +20328,7 @@ function App({ onCreateRemote = () => {} } = {}) {
19763
20328
  // Bottom-right of the frame, lazygit style: how much of the tab you can
19764
20329
  // currently see out of how much there is.
19765
20330
  const countLabel =
19766
- items == null
20331
+ !tableVisible
19767
20332
  ? null
19768
20333
  : tabOffset > 0
19769
20334
  ? `${tabOffset + 1}-${tabOffset + visibleItems.length} of ${counts[tab.key]}`
@@ -19777,22 +20342,9 @@ function App({ onCreateRemote = () => {} } = {}) {
19777
20342
  // terminal is doing the clipping.
19778
20343
  const frameCols = Math.max(1, cols - 1);
19779
20344
 
19780
- // Name the repository in the top edge when it was chosen explicitly, because
19781
- // then it is not the one you would guess from the working directory. Nothing
19782
- // on screen said which repo a pane was watching, and the documented workflow is
19783
- // several panes side by side plus `--repo` from anywhere -- so telling them
19784
- // apart meant quitting and running --doctor. Only the explicit cases are shown:
19785
- // resolving the *inferred* repo would cost a subprocess call at startup for a
19786
- // label that, by definition, names the directory you are already sitting in.
19787
- //
19788
- // GH_REPO is read straight from the environment and never went through
19789
- // parseRepoTarget, so unlike runtime.repo it is unvalidated -- safe() before it
19790
- // is drawn, on the same rule as every other string this app does not own.
19791
- // Dropped before the tab name rather than with it: PanelEdge drops its whole
19792
- // label when it cannot seat one, so a single concatenated string would take the
19793
- // tab name down with the target at ordinary widths.
19794
- const target = runtime.repo ?? safe(process.env.GH_REPO ?? "");
19795
- const withTarget = target ? `${tab.label} · ${target}` : tab.label;
20345
+ // The existing resolver also supplies inferred targets without another lookup.
20346
+ // Complete target and host remain reachable in help when this edge is short.
20347
+ const withTarget = `${tab.label} · ${selectedTarget.label}`;
19796
20348
  const topLabel = frameCols - 3 - withTarget.length >= 0 ? withTarget : tab.label;
19797
20349
 
19798
20350
  // Fixed columns deliberately do not shrink, so BRANCH and TIME stay readable
@@ -19822,8 +20374,8 @@ function App({ onCreateRemote = () => {} } = {}) {
19822
20374
  // avoid, and it was reachable simply by dragging a sidebar narrow. The guard
19823
20375
  // sits after usableSize(), which substitutes DEFAULT_COLS for a 0 reported
19824
20376
  // mid-resize, so a transient zero cannot be mistaken for a genuinely tiny pane.
19825
- const tooNarrow = frameCols < MIN_COMPACT_WIDTH;
19826
- const fullHeaderVisible = !compact && !showHelp && !tooNarrow && !remoteSetup;
20377
+ const tooNarrow = tableVisible && frameCols < MIN_COMPACT_WIDTH;
20378
+ const fullHeaderVisible = tableVisible && !compact && !showHelp && !tooNarrow && !remoteSetup;
19827
20379
  const selectedWidthKey = selectedWidthKeyByTab[tab.key] ?? null;
19828
20380
  const selectedWidthColumn = effectiveHeader?.find(
19829
20381
  (column) => column.key === selectedWidthKey,
@@ -19913,23 +20465,22 @@ function App({ onCreateRemote = () => {} } = {}) {
19913
20465
  // recovers immediately -- this is a render-time branch and touches no state.
19914
20466
  // Short enough to survive at the widths it actually appears at -- a message
19915
20467
  // about the pane being too narrow that is itself truncated would be a joke.
19916
- // Clamped to bodyRows so it can never push the frame past `rows` and
19917
- // trigger the full repaint the height reservation exists to prevent, and
19918
- // truncate-end so it degrades rather than wraps in a narrow pane. Every
19919
- // glyph in KEY_TABLE is plain ASCII, which is what keeps it safe here.
20468
+ // Help is cell-wrapped and paginated within bodyRows, with navigation
20469
+ // and quit reserved on every page. Rendering never adds another wrap.
19920
20470
  showHelp &&
19921
- helpLines(bodyRows)
20471
+ (helpPages[visibleHelpPage] ?? [])
19922
20472
  .map((line, i) => e(Text, { key: `help${i}`, wrap: "truncate-end" }, line)),
19923
20473
  !showHelp &&
19924
20474
  tooNarrow &&
19925
20475
  e(Text, { dimColor: true, wrap: "truncate-end" }, "too narrow"),
19926
- ...noticeLines.map((line, index) =>
20476
+ ...(showHelp || !tableVisible ? [] : noticeLines).map((line, index) =>
19927
20477
  e(Text, { key: `recovery:${index}`, color: noticeTone, wrap: "truncate-end" }, line)),
19928
20478
  !showHelp &&
19929
20479
  !tooNarrow &&
19930
20480
  !remoteSetup &&
20481
+ tableVisible &&
19931
20482
  tab.key === "security" &&
19932
- securityLines.map((note, i) =>
20483
+ visibleSecurityLines.map((note, i) =>
19933
20484
  e(Text, { key: i, dimColor: true, wrap: "truncate-end" }, note),
19934
20485
  ),
19935
20486
  !showHelp &&
@@ -19952,6 +20503,7 @@ function App({ onCreateRemote = () => {} } = {}) {
19952
20503
  !showHelp &&
19953
20504
  !tooNarrow &&
19954
20505
  !remoteSetup &&
20506
+ tableVisible &&
19955
20507
  e(MemoHeaderCells, {
19956
20508
  cells: header,
19957
20509
  selectedWidthKey: resizeRef.current.active ? selectedWidthKey : null,
@@ -19978,49 +20530,23 @@ function App({ onCreateRemote = () => {} } = {}) {
19978
20530
  }),
19979
20531
  );
19980
20532
  }),
19981
- // Distinguishes "still fetching" from "resolved and empty" in the body,
19982
- // which is the difference between a dashboard that looks hung and one
19983
- // that looks correct. Driven by "never resolved" rather than by the
19984
- // loading flag, which also covers a manual refresh and made a
19985
- // settled-empty tab swap its message. Suppressed entirely when a tab has
19986
- // an error and has never resolved: the error line directly above already
19987
- // says what happened, and "no runs" underneath it reads as a fact about the
19988
- // repository rather than the absence of an answer.
19989
- !showHelp &&
19990
- !tooNarrow &&
19991
- !remoteSetup &&
19992
- visibleItems.length === 0 &&
19993
- !(tabError && items == null) &&
19994
- e(
19995
- Text,
19996
- { dimColor: true },
19997
- firstLoad[tab.key]
19998
- ? `${showSpinner ? spin : SPINNER[0]} loading ${tab.label.toLowerCase()}…` +
19999
- // Appended only once a first fetch has been running for
20000
- // ICON_HINT_AFTER_MS, so an ordinary start never shows it: the
20001
- // remedy is for someone looking at a pane of blank boxes, and by
20002
- // then they have been looking long enough to want an explanation.
20003
- // Suppressed once icons are already ASCII, since then there is
20004
- // nothing to fix.
20005
- (USING_NERD_ICONS && iconHintDue ? " (icons blank? GH_GLANCE_ICONS=unicode)" : "")
20006
- : semanticStatus.kind === "paused" || waiting[tab.key]
20007
- ? "waiting for API budget…"
20008
- : tab.key === "security" && securityNotes.length === ALERT_SOURCES.length
20009
- ? "no alert sources available"
20010
- : `no ${tab.countLabel}`,
20011
- ),
20533
+ !showHelp && !remoteSetup && !tableVisible &&
20534
+ panelLines.map((line, index) => e(Text, {
20535
+ key: `context:${index}`, wrap: "truncate-end",
20536
+ bold: index === 0, color: index === 0 ? TITLE_COLOR : undefined,
20537
+ }, line)),
20012
20538
  // Pushes the panel's bottom edge down to the foot of the pane, so the
20013
20539
  // frame stays put instead of closing up under the column headers on a
20014
20540
  // tab that only has a handful of rows.
20015
20541
  e(Box, { flexGrow: 1 }),
20016
20542
  ),
20017
- e(PanelEdge, { width: frameCols, top: false, label: countLabel, labelColor: BORDER_COLOR }),
20543
+ e(PanelEdge, { width: frameCols, top: false, label: showHelp ? null : countLabel, labelColor: BORDER_COLOR }),
20018
20544
  e(StatusBar, {
20019
20545
  status: semanticStatus,
20020
20546
  detail: activeGovernorDecision,
20021
20547
  spin: showSpinner ? spin : null,
20022
- stale: semanticStatus.kind === "cached" && staleFor != null
20023
- ? formatDuration(Math.max(0, staleFor)) : staleLabel,
20548
+ stale: semanticStatus.kind === "cached" && Number.isFinite(staleFor) && staleFor >= 0
20549
+ ? formatDuration(staleFor) : staleLabel,
20024
20550
  nowMs: now.getTime(),
20025
20551
  interactive,
20026
20552
  cols: frameCols,
@@ -20375,6 +20901,9 @@ export {
20375
20901
  GOVERNOR_MAX_LEASES,
20376
20902
  GOVERNOR_MAX_INTENTS,
20377
20903
  GOVERNOR_MAX_RESERVATIONS,
20904
+ GOVERNOR_MAX_DEBT_GROUPS,
20905
+ DEBT_JOURNAL_COMPACT_AT,
20906
+ UNKNOWN_DEBT_GROUP_ID,
20378
20907
  GOVERNOR_LOCK_ORPHAN_MS,
20379
20908
  GOVERNOR_LOCK_WAIT_MS,
20380
20909
  GOVERNOR_PROBE_DRAIN_MS,
@@ -20390,6 +20919,7 @@ export {
20390
20919
  normalizeGovernorState,
20391
20920
  serializeGovernorState,
20392
20921
  readGovernorState,
20922
+ readIdentityQuotaState,
20393
20923
  writeGovernorState,
20394
20924
  writeGovernorQuotaState,
20395
20925
  pidIsDead,
@@ -20465,6 +20995,12 @@ export {
20465
20995
  ghApi,
20466
20996
  actionsRunsArgs,
20467
20997
  actionsWorkflowsArgs,
20998
+ actionsSetupArgs,
20999
+ parseActionsSetup,
21000
+ actionsSetupFresh,
21001
+ actionsSetupRetryAt,
21002
+ actionsPageUrl,
21003
+ openBrowserUrl,
20468
21004
  parseActionsBodies,
20469
21005
  parseActionsRuns,
20470
21006
  parseWorkflowCatalog,
@@ -20633,6 +21169,12 @@ export {
20633
21169
  collectorSourceAdvanced,
20634
21170
  cleanupQueueForResource,
20635
21171
  presentRecovery,
21172
+ dashboardPresentation,
21173
+ contextualPanelLayout,
21174
+ dashboardTarget,
21175
+ contextHelpPages,
21176
+ clampHelpPage,
21177
+ wrapPanelText,
20636
21178
  recordRecoveryTransition,
20637
21179
  safeRecordRecoveryTransition,
20638
21180
  readRecoveryTransitions,