gh-glance 0.18.0 → 0.18.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 +33 -1
  2. package/README.md +16 -13
  3. package/index.mjs +264 -57
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.1] - 2026-10-31
11
+
12
+ ### Fixed
13
+
14
+ - A collector that serves several repositories through one provider gives each
15
+ target its own immutable request scope, so one repository's rows can no
16
+ longer be fetched or published under another (#163). Security capability
17
+ backoff is now scoped by host and repository, so one repository's disabled
18
+ feature no longer hides another's alerts or resets its backoff ladder (#165).
19
+ - A secondary rate limit no longer blocks a resource until the hourly primary
20
+ reset; it stays under the shared transport cooldown (#164). A transport paused
21
+ after repeated throttles now recovers on `r` or after the primary reset, and
22
+ recovery never shortens a server-supplied Retry-After (#164).
23
+ - A GraphQL observer claim no longer delays core-only work, and the reverse
24
+ (#169). Governor waits that change nothing no longer rewrite the ledger (#174).
25
+ - Collector Issues and PR results compare as unchanged when their rows are the
26
+ same, so quiet queries reach the quiet cadence instead of polling at the
27
+ floor (#166). Adopting a peer's snapshot updates the local unchanged sentinel
28
+ (#167).
29
+ - A Security batch that only replayed capability backoff no longer advances the
30
+ success time or completes webhook refresh work (#168).
31
+ - A same-epoch collector reconnect shows Connected again without waiting for a
32
+ newer generation (#170), and followers no longer re-send unchanged snapshots
33
+ (#173).
34
+ - The Actions clock advances in collector mode, and collector source age counts
35
+ time spent suspended (#171).
36
+ - The inferred display cache is keyed by the repository resolved from the
37
+ remote, so a changed remote in the same directory no longer restores another
38
+ repository's rows (#172). Existing directory-only cache entries start cold
39
+ once.
40
+
10
41
  ## [0.18.0] - 2026-10-29
11
42
 
12
43
  ### Added
@@ -1361,7 +1392,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
1361
1392
  - The `main` field from `package.json`. It advertised the file as importable,
1362
1393
  but importing it took over the terminal or exited the host process.
1363
1394
 
1364
- [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.18.0...HEAD
1395
+ [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.18.1...HEAD
1396
+ [0.18.1]: https://github.com/juan294/gh-glance/compare/v0.18.0...v0.18.1
1365
1397
  [0.18.0]: https://github.com/juan294/gh-glance/compare/v0.17.1...v0.18.0
1366
1398
  [0.17.1]: https://github.com/juan294/gh-glance/compare/v0.17.0...v0.17.1
1367
1399
  [0.17.0]: https://github.com/juan294/gh-glance/compare/v0.16.2...v0.17.0
package/README.md CHANGED
@@ -673,14 +673,15 @@ $XDG_CONFIG_HOME/gh-glance/dashboard-cache.json
673
673
  ```
674
674
 
675
675
  The cache is scoped to the repository, host, provider, and effective access
676
- partition. In inferred standalone mode, the host and current working directory
677
- also form the identity, so rows from one checkout or credential context cannot
678
- appear in another. Human-provider access includes one-way digests of the
679
- selected credential and its authorization generation; App-provider access also
680
- binds the configured repository and permission restrictions. Raw credentials
681
- are never stored. It retains at most 32 recent targets and 60 rows per tab; a
682
- shortened cached tab keeps its `+` marker rather than presenting the saved count
683
- as exact.
676
+ partition. In inferred standalone mode, the host, the current working directory
677
+ and the repository resolved offline from its git remote form the identity, so
678
+ rows from one checkout, remote or credential context cannot appear in another.
679
+ A directory whose remote now names a different repository starts cold.
680
+ Human-provider access includes one-way digests of the selected credential and
681
+ its authorization generation; App-provider access also binds the configured
682
+ repository and permission restrictions. Raw credentials are never stored. It
683
+ retains at most 32 recent targets and 60 rows per tab; a shortened cached tab
684
+ keeps its `+` marker rather than presenting the saved count as exact.
684
685
 
685
686
  Only successfully parsed, non-blind observations replace saved rows. A failed
686
687
  request can add a live error or `?` marker, but it cannot turn known Security
@@ -945,10 +946,12 @@ After a sustained hold, the notice names the Core or GraphQL cause, the next
945
946
  automatic retry or required action, and the age of the last complete source
946
947
  observation. A successful budget check, cache load, or heartbeat cannot make
947
948
  old rows fresh. A validated unchanged `304` response does count as a new source
948
- observation. Unknown older spend remains charged until owner/child quiescence
949
- and a later matching observer establish safe retirement. A verified new system
950
- boot can establish that boundary for otherwise unidentifiable older children;
951
- the app does not assume a restart or reset alone refunded the charge.
949
+ observation. A Security check answered only from capability backoff sends no
950
+ request and does not count. Unknown older spend remains charged until
951
+ owner/child quiescence and a later matching observer establish safe retirement.
952
+ A verified new system boot can establish that boundary for otherwise
953
+ unidentifiable older children; the app does not assume a restart or reset alone
954
+ refunded the charge.
952
955
 
953
956
  The enforceable boundary is admission from fresh, conservatively debited
954
957
  evidence. Another program can spend after the probe, and standalone panes in a
@@ -1165,7 +1168,7 @@ GH_GLANCE_ICONS=ascii gh-glance
1165
1168
  | `Local coordination journal is full` | Every slot for unsettled or recently settled requests is in use. The app retries by itself, and older requests settle after about an hour. Nothing needs repair. `gh-glance --doctor` shows the occupancy as `debt groups`. |
1166
1169
  | `Local coordination` corruption, overflow, full storage, or unsafe permissions | Run `gh-glance --doctor`; its read-only report gives the exact cause and redacted local file location when known. For a hard failure it also prints a `repair` line: quit every pane, move the state file, its `.recovery.json` and `registry.json` aside, then relaunch. Otherwise repair storage or permissions and let the app retry. Preserve corrupt and overflow evidence for diagnosis. |
1167
1170
  | `Saving request result` or `Recovering interrupted request` | The original request may have consumed quota. The app keeps its charge, retries settlement without repeating HTTP, and waits for safe observer evidence before retiring uncertain debt. |
1168
- | `GitHub request limit` | A shared secondary hold preserves GitHub's retry deadline. Wait for the shown time; if the repeated-limit circuit explicitly asks, press `r` once to retry. Repeated refresh cannot bypass a hold. |
1171
+ | `GitHub request limit` | A shared secondary hold preserves GitHub's retry deadline and does not block the resource until the hourly reset. Wait for the shown time. After repeated limits the transport pauses: press `r` (or `R`) once to resume it, or wait for the primary reset, after which one pane resumes it. Resuming never shortens GitHub's retry deadline, and repeated refresh cannot bypass a hold. |
1169
1172
  | `Collector connection unavailable` or `GitHub App authorization unavailable` | Restore the selected collector connection or App authorization. The mode does not switch to independent requests. Cached rows remain labeled with their source age. |
1170
1173
  | `Shared HTTP request or cooldown in progress` | Panes share up to three admitted requests per host, FIFO starts with at least 250 ms spacing, and one cooldown. The pool is full, a queued request is ahead, or a server-sent `Retry-After` is still running. Normal pacing, not an error. |
1171
1174
  | `Verified identity unavailable; waiting to retry` | The account behind your credential has not been confirmed yet -- not signed in, a denied or malformed proof, or a slow credential helper. Check `gh auth status`. Retries are rate-limited, so this clears on its own rather than immediately. |
package/index.mjs CHANGED
@@ -761,6 +761,9 @@ async function runGh(args, { signal, operation, input = null, execute = null,
761
761
  let control = null;
762
762
  const identityCoordinator = bound?.identityCoordinator ?? runtimeIdentityCoordinator;
763
763
  const requestNow = typeof bound?.now === "function" ? bound.now : Date.now;
764
+ if (identityCoordinator && bound && operation === "budget-core-observer") {
765
+ retryTransportAfterReset(identityCoordinator, bound, requestNow);
766
+ }
764
767
  const permit = identityCoordinator && !local
765
768
  ? await acquireIdentityHttpPermit(identityCoordinator, { signal, now: requestNow,
766
769
  operationDeadline: CONTROL_OPERATIONS.includes(operation) ? undefined : bound?.receiptCapability?.deadline,
@@ -2555,13 +2558,14 @@ async function fetchGraphqlList(kind, mapRow, {
2555
2558
  const incomplete = merged.incomplete || truncatedWalk;
2556
2559
  // Serialized rows are the payload identity the unchanged-frame suppression
2557
2560
  // and the cache both compare on, so it must cover exactly what is rendered --
2558
- // including the markers that say the list is not all of it.
2559
- const raw = JSON.stringify({
2560
- v: GRAPHQL_QUERY_VERSION,
2561
- totalCount,
2562
- incomplete,
2563
- hasNextPage: merged.hasNextPage,
2561
+ // including the markers that say the list is not all of it. It is the shared
2562
+ // representation a stored snapshot rebuilds, so a collector producer that
2563
+ // compares against its normalized previous snapshot can see "unchanged".
2564
+ // `truncated` is what pollResultTransition derives from the limit below.
2565
+ const raw = acquisitionContentRaw(kind, {
2564
2566
  rows: merged.rows,
2567
+ truncated: incomplete || merged.rows.length >= LIST_LIMIT,
2568
+ hasNextPage: merged.hasNextPage,
2565
2569
  });
2566
2570
  return {
2567
2571
  raw: raw === previousRaw ? previousRaw : raw,
@@ -4187,6 +4191,19 @@ function classifyThrottle({ status = null, headers = null, graphqlErrors = null,
4187
4191
  return { kind: "none" };
4188
4192
  }
4189
4193
 
4194
+ // A "rate limit" message does not say which limit. Secondary limits are already
4195
+ // held by the shared transport cooldown; publishing one as a resource block until
4196
+ // the sampled primary reset would hold every pane for the rest of the window.
4197
+ // Only evidence classified as secondary is excluded. Stderr alone ("API rate
4198
+ // limit exceeded" with no headers) classifies as none and stays primary.
4199
+ function secondaryThrottleEvidence(error) {
4200
+ if (error?.coordinationReason === "throttle-paused") return true;
4201
+ const response = nativeCiThrottleEvidence.get(error) ?? error?.apiResponse ??
4202
+ parseGhApiResponse(error?.stdout ?? "");
4203
+ return classifyThrottle({ status: response.status, headers: response.headers, stderr: errText(error) })
4204
+ .kind === "secondary";
4205
+ }
4206
+
4190
4207
  function transportCooldownDeadline({ retryAfter, status, secondary, at, attempts = 0 }) {
4191
4208
  const supplied = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
4192
4209
  ? at + Number(retryAfter) * 1000
@@ -4271,17 +4288,40 @@ function releaseIdentityHttpPermit(coordinator, permit, error = null, now = Date
4271
4288
  return result;
4272
4289
  }
4273
4290
 
4274
- // The explicit retry a paused transport waits for. Clears the ladder and the
4275
- // deadline together: a person choosing to retry is choosing to spend the next
4276
- // request finding out, which is exactly the recovery election the pause defers.
4277
- function retryThrottledTransport(root, host, { now = Date.now } = {}) {
4291
+ // The explicit retry, or the reset-triggered recovery, a paused transport waits
4292
+ // for. Clears the ladder and the pause but never the deadline: a server-supplied
4293
+ // Retry-After is honoured exactly (ADR 0003), so the next request still waits it
4294
+ // out. The registry lock is the election: only the first caller resumes a given
4295
+ // pause. A reset recovers only a pause whose last throttle predates that reset,
4296
+ // so one primary window elects at most one recovery.
4297
+ function retryThrottledTransport(root, host, { now = Date.now, resetAt = null } = {}) {
4298
+ const at = now();
4278
4299
  return withIdentityRegistry(root, (state) => {
4279
4300
  const transport = state.hosts[host];
4280
4301
  if (!transport?.throttle?.paused) return { ok: true, value: { resumed: false } };
4302
+ if (resetAt !== null &&
4303
+ !(Number.isFinite(resetAt) && at >= resetAt && transport.throttle.lastAt < resetAt)) {
4304
+ return { ok: true, value: { resumed: false } };
4305
+ }
4281
4306
  transport.throttle = emptyTransportThrottle();
4282
- transport.cooldownUntil = Math.min(transport.cooldownUntil, now());
4283
- return { ok: true, value: { resumed: true } };
4284
- }, { now: now() });
4307
+ return { ok: true, value: { resumed: true, cooldownUntil: transport.cooldownUntil } };
4308
+ }, { now: at });
4309
+ }
4310
+
4311
+ function retryPausedTransport(coordinator, options = {}) {
4312
+ const host = coordinator?.current?.()?.host;
4313
+ if (typeof coordinator?.root !== "string" || !host) return { ok: true, value: { resumed: false } };
4314
+ return retryThrottledTransport(coordinator.root, host, options);
4315
+ }
4316
+
4317
+ // Only the pane holding the core observer claim runs budget-core-observer, so the
4318
+ // claim elects the one request that finds out whether the limit has lifted.
4319
+ function retryTransportAfterReset(coordinator, scope, now = Date.now) {
4320
+ const at = now();
4321
+ const snapshot = inspectGovernor({ ...scope, identityProvider: null }, at);
4322
+ const resetAt = snapshot.ok ? snapshot.value.budgets.core?.resetMs : null;
4323
+ if (!Number.isFinite(resetAt) || at < resetAt) return { ok: true, value: { resumed: false } };
4324
+ return retryPausedTransport(coordinator, { now: () => at, resetAt });
4285
4325
  }
4286
4326
 
4287
4327
  function identityCoordinationMessage(reason) {
@@ -5363,7 +5403,7 @@ function readGovernorState(path, nowMs, { persistMigration = true } = {}) {
5363
5403
  if (Buffer.byteLength(raw) > GOVERNOR_MAX_LEDGER_BYTES) return { ok: false, reason: "capacity" };
5364
5404
  const parsed = JSON.parse(raw);
5365
5405
  const normalized = normalizeGovernorState(parsed, nowMs);
5366
- if (normalized) return { ok: true, value: normalized };
5406
+ if (normalized) return { ok: true, value: normalized, bytes: raw };
5367
5407
  if (Number.isSafeInteger(parsed?.version) && parsed.version > GOVERNOR_STATE_VERSION) {
5368
5408
  return { ok: false, reason: "version-mismatch" };
5369
5409
  }
@@ -5785,6 +5825,13 @@ function mutateGovernor(scope, nowMs, mutate) {
5785
5825
  if (!governorAccountingSafe(state, at)) return returnAfterMigration(ACCOUNTING_OVERFLOW);
5786
5826
  const normalized = normalizeGovernorState(state, at);
5787
5827
  if (!normalized) return returnAfterMigration({ ok: false, reason: "corrupt" });
5828
+ // A wait or decision read that changed nothing must not bump the revision
5829
+ // and replace the file: compare the would-be bytes, as the identity
5830
+ // registry does. Migrations and explicit rejected writes always commit.
5831
+ if (migration === null && value?.ok !== false && typeof loaded.bytes === "string" &&
5832
+ serializeGovernorState(normalized) === loaded.bytes) {
5833
+ return { ok: true, value: value?.value };
5834
+ }
5788
5835
  const written = commitGovernorMigration(scope.path, normalized, loaded.migration);
5789
5836
  if (!written.ok) return written;
5790
5837
  return value?.ok === false ? { ok: false, reason: value.reason } : { ok: true, value: value?.value };
@@ -6512,9 +6559,11 @@ function startReservation(scope, reservationId, nowMs) {
6512
6559
  return { ok: false, reason: "stale", write: Boolean(reservation) };
6513
6560
  }
6514
6561
  if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
6515
- // Any live observer claim defers a start: the claim owns the shared HTTP
6516
- // permit for its window, so starting here would contend for it.
6562
+ // A live observer claim defers only work that charges its resource. The
6563
+ // other resource's observer says nothing about this counter, and the shared
6564
+ // HTTP pool, start gap and host cooldown still bound the two together.
6517
6565
  const liveClaim = RATE_RESOURCES
6566
+ .filter((resource) => reservation.costs[resource] > 0)
6518
6567
  .map((resource) => state.probeClaims[resource])
6519
6568
  .filter((claim) => claim && claim.leaseUntil > at)
6520
6569
  .sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
@@ -7240,9 +7289,15 @@ function mergeAlertRows(groups) {
7240
7289
  // mid-session is picked up within the hour.
7241
7290
  const alertBackoff = new Map();
7242
7291
 
7243
- function backoffStorageKey(key) {
7244
- const access = requestIdentityStorage.getStore()?.accessKey ?? runtimeIdentityCoordinator?.current()?.accessKey;
7245
- return access ? `${access}\0${key}` : key;
7292
+ // A capability verdict is a statement about one repository on one host, so the
7293
+ // bound host and repository are part of the key: one collector credential can
7294
+ // serve several repositories, and one repository's 403 must not replay for
7295
+ // (or be cleared by) another.
7296
+ function backoffStorageKey(key, accessKey = null) {
7297
+ const access = accessKey ?? requestIdentityStorage.getStore()?.accessKey ??
7298
+ runtimeIdentityCoordinator?.current()?.accessKey;
7299
+ const scoped = `${effectiveRuntimeHost() ?? ""}\0${effectiveRuntimeRepository()?.toLowerCase() ?? ""}\0${key}`;
7300
+ return access ? `${access}\0${scoped}` : scoped;
7246
7301
  }
7247
7302
 
7248
7303
  function backoffActive(key, now) {
@@ -7428,6 +7483,7 @@ async function fetchAlertSource(source, signal, now, {
7428
7483
  rest200,
7429
7484
  rest304,
7430
7485
  verdict,
7486
+ secondaryThrottle: verdict === "rate-limited" && secondaryThrottleEvidence(err),
7431
7487
  allNotModified: false,
7432
7488
  stagedEntities: new Map(),
7433
7489
  observations,
@@ -7499,7 +7555,15 @@ async function fetchSecurity(signal, {
7499
7555
  requestMetrics,
7500
7556
  graphqlSpent: 0,
7501
7557
  measuredSuccess: parts.every((part) => part.verdict === "ok"),
7502
- rateLimited: parts.some((part) => part.verdict === "rate-limited"),
7558
+ // Whether this batch observed the source at all: some source made a real
7559
+ // request and GitHub answered it (200/304, or a current capability refusal).
7560
+ // A batch in which every source replayed its backoff made no request, so it
7561
+ // must not advance freshness or complete webhook work (plan D6, ADR 0005).
7562
+ // With no sources (an App granted no Security permission) there is nothing
7563
+ // to observe, and the configuration answer keeps its existing behavior.
7564
+ sourceObserved: parts.length === 0 || parts.some((part) => part.httpRequests > 0 &&
7565
+ (part.verdict === "ok" || part.verdict === "unavailable")),
7566
+ rateLimited: parts.some((part) => part.verdict === "rate-limited" && !part.secondaryThrottle),
7503
7567
  stagedEntities,
7504
7568
  observations: parts.flatMap((part) => part.observations ?? []),
7505
7569
  capabilities,
@@ -7774,7 +7838,16 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
7774
7838
  lowerBound += Math.max(0, now() - checkpoint.clientCheckpointAt);
7775
7839
  }
7776
7840
  let measuredAt = monotonicNow();
7777
- const current = () => lowerBound + Math.max(0, monotonicNow() - measuredAt);
7841
+ // The monotonic clock does not advance across a suspend, so positive wall
7842
+ // elapsed time also bounds the age. A forward wall step can only overstate
7843
+ // staleness; the high-water mark keeps a backward step from shrinking it.
7844
+ let wallMeasuredAt = now();
7845
+ let reported = lowerBound;
7846
+ const current = () => {
7847
+ reported = Math.max(reported, lowerBound +
7848
+ Math.max(0, monotonicNow() - measuredAt, now() - wallMeasuredAt));
7849
+ return reported;
7850
+ };
7778
7851
  return {
7779
7852
  observe({ lastSuccessAt, serverNow }) {
7780
7853
  if (!Number.isFinite(lastSuccessAt) || !Number.isFinite(serverNow)) return current();
@@ -7784,6 +7857,8 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
7784
7857
  else lowerBound = Math.max(elapsed, candidate);
7785
7858
  sourceAt = sourceAt === null ? lastSuccessAt : Math.max(sourceAt, lastSuccessAt);
7786
7859
  measuredAt = monotonicNow();
7860
+ wallMeasuredAt = now();
7861
+ reported = lowerBound;
7787
7862
  return lowerBound;
7788
7863
  },
7789
7864
  current,
@@ -10045,7 +10120,11 @@ async function createCollectorService({ config, pathOptions = {}, runtime, platf
10045
10120
  }
10046
10121
 
10047
10122
  function collectorPublicationFromResult(resource, result, previous, demand, completedAt, state = {}) {
10048
- const transition = pollResultTransition({
10123
+ // A Security batch that only replayed capability backoff made no request. It
10124
+ // republishes the previous observation unchanged, with its success time, so
10125
+ // neither freshness nor webhook completion can advance on it.
10126
+ const observed = result.sourceObserved !== false;
10127
+ const transition = !observed ? { kind: "unchanged", completedAt } : pollResultTransition({
10049
10128
  key: resource,
10050
10129
  previousRaw: previous?.raw ?? null,
10051
10130
  raw: result.raw,
@@ -10074,7 +10153,8 @@ function collectorPublicationFromResult(resource, result, previous, demand, comp
10074
10153
  },
10075
10154
  raw: transition.kind === "changed" ? transition.nextRaw : previous.raw,
10076
10155
  entities,
10077
- lastSuccessAt: completedAt,
10156
+ lastSuccessAt: observed ? completedAt : previous.lastSuccessAt,
10157
+ sourceObserved: observed,
10078
10158
  lastChangedAt,
10079
10159
  nextDueAt: completedAt + pollPolicyInterval({
10080
10160
  tab: resource,
@@ -10181,11 +10261,13 @@ function createCollectorAcquisitionRuntime({
10181
10261
  providerContexts.set(target.provider, context);
10182
10262
  }
10183
10263
  let identity;
10264
+ let credentialEnvironment = null;
10265
+ let capabilities = null;
10184
10266
  if (context.definition.type === "github-app") {
10185
10267
  const resolved = await context.coordinator.resolve({ signal });
10186
10268
  identity = resolved.identity;
10187
- context.credentialEnvironment = resolved.environment;
10188
- context.capabilities = resolved.capabilities;
10269
+ credentialEnvironment = resolved.environment;
10270
+ capabilities = resolved.capabilities;
10189
10271
  if (context.identity?.accessKey !== identity.accessKey) {
10190
10272
  const persisted = persistGitHubAppIdentity(context.coordinator.root, identity, now());
10191
10273
  if (!persisted.ok) throw new Error("collector provider unavailable");
@@ -10194,13 +10276,10 @@ function createCollectorAcquisitionRuntime({
10194
10276
  const resolved = await context.coordinator.refresh();
10195
10277
  if (!resolved.ok) throw new Error("collector provider unavailable");
10196
10278
  identity = resolved.value;
10197
- context.credentialEnvironment = null;
10198
- context.capabilities = null;
10199
10279
  context.readBudgets = readBudgets;
10200
10280
  }
10201
10281
  if (identity.host !== target.host) throw new Error("collector provider unavailable");
10202
- context.identity = identity;
10203
- context.scope = {
10282
+ const scope = Object.freeze({
10204
10283
  ...createQuotaScope(identity, {
10205
10284
  root: context.coordinator.root,
10206
10285
  now,
@@ -10210,9 +10289,28 @@ function createCollectorAcquisitionRuntime({
10210
10289
  repository: target.repo,
10211
10290
  providerType: context.definition.type,
10212
10291
  ...(executeGh ? { executeGh } : {}),
10213
- ...(context.credentialEnvironment ? { credentialEnvironment: context.credentialEnvironment } : {}),
10214
- };
10215
- return context;
10292
+ ...(credentialEnvironment ? { credentialEnvironment } : {}),
10293
+ });
10294
+ // The provider context keeps only provider-wide state (coordinator,
10295
+ // lease, budget reader, latest access identity). Its scope is used to
10296
+ // release the provider lease on close, never to bind a request.
10297
+ context.identity = identity;
10298
+ context.credentialEnvironment = credentialEnvironment;
10299
+ context.capabilities = capabilities;
10300
+ context.scope = { ...scope };
10301
+ // Each target operation gets its own immutable binding, captured once, so
10302
+ // another target's resolution cannot redirect it after an await.
10303
+ return Object.freeze({
10304
+ coordinator: context.coordinator,
10305
+ definition: context.definition,
10306
+ leaseId: context.leaseId,
10307
+ readBudgets: context.readBudgets,
10308
+ onObserverPublished: context.onObserverPublished,
10309
+ identity,
10310
+ scope,
10311
+ credentialEnvironment,
10312
+ capabilities,
10313
+ });
10216
10314
  }
10217
10315
 
10218
10316
  async function defaultProduce({ target, resource, demand, snapshot, claim, markStarted, provider, signal, force }) {
@@ -10444,9 +10542,17 @@ function createCollectorAcquisitionRuntime({
10444
10542
  item.invalidations.clear();
10445
10543
  }
10446
10544
 
10545
+ // Identifies one delivered generation for one engine subscription and
10546
+ // binding. A rebind or a new subscription (a new connection) changes it.
10547
+ function collectorDeliveryKey(subscriptionId, bindingRevision, snapshot) {
10548
+ return Number.isSafeInteger(snapshot?.generation)
10549
+ ? `${subscriptionId}\0${bindingRevision}\0${snapshot.generation}` : null;
10550
+ }
10551
+
10447
10552
  function deliverCollectorSnapshot(item, snapshot, bindingRevision, { publication = false } = {}) {
10448
10553
  if (bindingRevision !== item.bindingRevision) return false;
10449
10554
  item.nextDueAt = snapshot.nextDueAt;
10555
+ item.lastDeliveryKey = collectorDeliveryKey(item.id, bindingRevision, snapshot);
10450
10556
  if (Number.isSafeInteger(snapshot.generation)) item.lastDeliveredGeneration = snapshot.generation;
10451
10557
  if (Number.isFinite(snapshot.lastSuccessAt)) item.lastDeliveredSuccessAt = snapshot.lastSuccessAt;
10452
10558
  if (publication && item.requestedAt !== null && snapshot.lastSuccessAt >= item.requestedAt) {
@@ -10454,13 +10560,21 @@ function createCollectorAcquisitionRuntime({
10454
10560
  }
10455
10561
  item.onSnapshot(snapshot);
10456
10562
  if (!publication || !Number.isSafeInteger(snapshot.generation)) return true;
10563
+ let awaitingObservation = false;
10457
10564
  for (const waiter of item.invalidations) {
10458
10565
  if (bindingRevision !== waiter.bindingRevision || waiter.requiredGeneration === null ||
10459
10566
  snapshot.generation < waiter.requiredGeneration) continue;
10567
+ // A newer generation that republished an older observation (a Security
10568
+ // capability-backoff replay) is not API validation of the webhook. Leave
10569
+ // the waiter for the next real observation on the ordinary cadence.
10570
+ if (Number.isFinite(waiter.invalidatedAt) && !(snapshot.lastSuccessAt >= waiter.invalidatedAt)) {
10571
+ awaitingObservation = true;
10572
+ continue;
10573
+ }
10460
10574
  item.invalidations.delete(waiter);
10461
10575
  waiter.resolve(snapshot);
10462
10576
  }
10463
- if (item.invalidations.size > 0 && !closed && !item.closed && demandEnabled(item.demand)) {
10577
+ if (item.invalidations.size > 0 && !awaitingObservation && !closed && !item.closed && demandEnabled(item.demand)) {
10464
10578
  queueMicrotask(() => startPoll(item, { manual: true }));
10465
10579
  }
10466
10580
  return true;
@@ -10531,11 +10645,15 @@ function createCollectorAcquisitionRuntime({
10531
10645
  retryIn = 1;
10532
10646
  return;
10533
10647
  }
10534
- item.provider = refreshedProvider;
10648
+ const provider = refreshedProvider;
10649
+ item.provider = provider;
10535
10650
  const ownership = await engine.refresh(subscriptionId, { force: manual });
10536
10651
  if (!ownership.ok) { item.onHold?.("disconnected"); retryIn = 1_000; return; }
10537
10652
  if (ownership.value.role !== "producer") {
10538
- if (ownership.value.snapshot) {
10653
+ // A follower wake re-reads the shared record; re-send only a
10654
+ // generation this subscription has not already delivered.
10655
+ if (ownership.value.snapshot && item.lastDeliveryKey !==
10656
+ collectorDeliveryKey(subscriptionId, bindingRevision, ownership.value.snapshot)) {
10539
10657
  deliverCollectorSnapshot(item, ownership.value.snapshot, bindingRevision);
10540
10658
  }
10541
10659
  if (ownership.value.reason !== "fresh") {
@@ -10566,7 +10684,7 @@ function createCollectorAcquisitionRuntime({
10566
10684
  active: ownership.value.demand?.active || item.requestedAt !== null },
10567
10685
  snapshot: ownership.value.snapshot,
10568
10686
  claim: { ...claim, state: item },
10569
- provider: item.provider,
10687
+ provider,
10570
10688
  signal: item.controller.signal,
10571
10689
  force,
10572
10690
  markStarted: async (receipt) => {
@@ -10576,11 +10694,11 @@ function createCollectorAcquisitionRuntime({
10576
10694
  return started;
10577
10695
  },
10578
10696
  }));
10579
- const produced = item.provider?.scope
10580
- ? await requestIdentityStorage.run(item.provider.scope, produceBound)
10697
+ const produced = provider?.scope
10698
+ ? await requestIdentityStorage.run(provider.scope, produceBound)
10581
10699
  : await produceBound();
10582
- if (item.provider?.definition?.type === "github-app" &&
10583
- item.provider.coordinator.current()?.accessKey !== claim.accessKey) {
10700
+ if (provider?.definition?.type === "github-app" &&
10701
+ provider.coordinator.current()?.accessKey !== claim.accessKey) {
10584
10702
  engine.refresh(subscriptionId, { claim, failure: {
10585
10703
  hold: "disconnected",
10586
10704
  requestMetrics: produced.requestMetrics ?? {},
@@ -10610,13 +10728,16 @@ function createCollectorAcquisitionRuntime({
10610
10728
  const published = await engine.refresh(subscriptionId, { claim, publish: produced });
10611
10729
  if (!published.ok) { item.onHold?.("disconnected"); retryIn = 1_000; }
10612
10730
  else {
10613
- item.provider?.coordinator?.markAuthorized?.();
10731
+ provider?.coordinator?.markAuthorized?.();
10732
+ // A replay answers the request as far as backoff allows without
10733
+ // advancing the success time that would otherwise clear it.
10734
+ if (produced.sourceObserved === false) item.requestedAt = null;
10614
10735
  retryIn = Math.max(1, published.value.snapshot.nextDueAt - now());
10615
10736
  }
10616
10737
  } catch (error) {
10617
- if (item.provider?.definition?.type === "github-app" && error?.apiResponse?.status === 401 &&
10738
+ if (provider?.definition?.type === "github-app" && error?.apiResponse?.status === 401 &&
10618
10739
  !error.appCredentialInvalidated) {
10619
- item.provider.coordinator.invalidate("unauthorized");
10740
+ provider.coordinator.invalidate("unauthorized");
10620
10741
  }
10621
10742
  const hold = error?.notStarted ? "shared-wait" : acquisitionFailureHold(error);
10622
10743
  if (claim && error?.notStarted) engine.refresh(subscriptionId, { cancel: claim });
@@ -10625,7 +10746,7 @@ function createCollectorAcquisitionRuntime({
10625
10746
  requestMetrics: error?.requestMetrics ?? {},
10626
10747
  } });
10627
10748
  else engine.setHold(subscriptionId, hold, { resource: item.resource, accessKey: item.identity.accessKey });
10628
- item.onHold?.(item.provider?.definition?.type === "github-app" &&
10749
+ item.onHold?.(provider?.definition?.type === "github-app" &&
10629
10750
  (error?.apiResponse?.status === 401 || error?.message === "GitHub App token unavailable" ||
10630
10751
  classify(error) === "auth-problem")
10631
10752
  ? "app-auth" : hold);
@@ -10713,7 +10834,7 @@ function createCollectorAcquisitionRuntime({
10713
10834
  const item = { target, resource, demand: normalizedDemand, onSnapshot, onHold, id: null, identity: null,
10714
10835
  provider: null, polling: false, pollPhase: null, pollPromise: null, controller: null,
10715
10836
  initializePromise: null, closed: false, timer: null, queuedRefresh: null, requestedAt: null,
10716
- bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null,
10837
+ bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null, lastDeliveryKey: null,
10717
10838
  lastDeliveredSuccessAt: null, promotionGeneration: null, promotionAt: null };
10718
10839
  items.add(item);
10719
10840
  void startInitialize(item);
@@ -10748,7 +10869,10 @@ function createCollectorAcquisitionRuntime({
10748
10869
  }
10749
10870
  return updated;
10750
10871
  },
10751
- refresh(force = false) { return startPoll(item, { manual: true, force: force === true }); },
10872
+ refresh(force = false) {
10873
+ retryPausedTransport(item.provider?.scope?.identityCoordinator);
10874
+ return startPoll(item, { manual: true, force: force === true });
10875
+ },
10752
10876
  inspect() {
10753
10877
  if (!item.id) return { source: "local collector", status: "initializing", resource: item.resource };
10754
10878
  const inspected = engine.inspect(item.id);
@@ -10825,6 +10949,7 @@ function createCollectorAcquisitionRuntime({
10825
10949
  }
10826
10950
  item.invalidations.add({
10827
10951
  requiredGeneration,
10952
+ invalidatedAt: now(),
10828
10953
  bindingRevision: item.bindingRevision,
10829
10954
  accessRemoved: normalized.accessRemoved,
10830
10955
  accessOnly: normalized.accessRemoved && !demandEnabled(item.demand),
@@ -10863,7 +10988,9 @@ function createCollectorAcquisitionRuntime({
10863
10988
  for (const entry of targetInitializations.values()) entry.controller.abort();
10864
10989
  await Promise.allSettled([...items].flatMap((item) =>
10865
10990
  [item.initializePromise, item.pollPromise].filter(Boolean)));
10866
- for (const item of items) if (item.id) engine.unsubscribe(item.id);
10991
+ // Per-target operation contexts are frozen; drop them so a retained
10992
+ // handle does not keep the credential environment reachable.
10993
+ for (const item of items) { if (item.id) engine.unsubscribe(item.id); item.provider = null; }
10867
10994
  items.clear();
10868
10995
  engine.close();
10869
10996
  for (const context of providerContexts.values()) {
@@ -11001,7 +11128,13 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
11001
11128
  resourceForId: (id) => subscriptions.get(id)?.resource ?? null,
11002
11129
  onSnapshot(snapshot, frame) {
11003
11130
  const item = subscriptions.get(frame.id);
11004
- if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation <= item.generation) return;
11131
+ if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation < item.generation) return;
11132
+ // A same-epoch reconnect keeps the generation fence, and the server
11133
+ // replays its cached generation for the new subscription. The first
11134
+ // equal-generation snapshot on a connection acknowledges that
11135
+ // connection; later repeats on it are not new deliveries.
11136
+ if (snapshot.generation === item.generation && item.connectionAcknowledged) return;
11137
+ item.connectionAcknowledged = true;
11005
11138
  item.generation = snapshot.generation;
11006
11139
  item.controlGeneration = Math.max(item.controlGeneration, snapshot.generation);
11007
11140
  const sourceAgeMs = item.sourceAge.observe({
@@ -11036,6 +11169,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
11036
11169
  item.generation = -1;
11037
11170
  item.controlGeneration = -1;
11038
11171
  }
11172
+ item.connectionAcknowledged = false;
11039
11173
  write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
11040
11174
  resource: item.resource, demand: item.demand });
11041
11175
  flushRefresh(item);
@@ -11129,7 +11263,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
11129
11263
  return { ok: false, reason: "invalid" };
11130
11264
  }
11131
11265
  const item = { ...subscription,
11132
- onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, pendingRefresh: null,
11266
+ onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, connectionAcknowledged: false, pendingRefresh: null,
11133
11267
  sourceAge: createCollectorSourceAgeTracker({ now, monotonicNow, checkpoint: sourceCheckpoint }) };
11134
11268
  subscriptions.set(id, item);
11135
11269
  if (welcomed) write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
@@ -13833,8 +13967,10 @@ function authCacheIdentity({
13833
13967
  }
13834
13968
 
13835
13969
  // Explicit targets are stable across working directories. In inferred mode the
13836
- // directory is the only repository identity available without making another
13837
- // GitHub call -- exactly the call that cannot succeed when this cache is needed.
13970
+ // directory and the remote slug, both resolved offline without the GitHub call
13971
+ // that cannot succeed when this cache is needed, form the identity: a changed
13972
+ // remote or a reused checkout path must not restore another repository's rows.
13973
+ // A null slug (no remote, or remotes that disagree) is its own key.
13838
13974
  // JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
13839
13975
  function dashboardCacheTarget({
13840
13976
  repo = null,
@@ -13842,11 +13978,13 @@ function dashboardCacheTarget({
13842
13978
  host = null,
13843
13979
  cwd = process.cwd(),
13844
13980
  account = authCacheIdentity(),
13981
+ remoteUrls = [],
13845
13982
  } = {}) {
13846
13983
  const target = repo || ghRepo;
13847
13984
  return target
13848
13985
  ? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
13849
- : JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd), account });
13986
+ : JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd),
13987
+ remote: resolveEffectiveRepository({ remoteUrls }), account });
13850
13988
  }
13851
13989
 
13852
13990
  function sshCollectorCacheIdentity(alias) {
@@ -13932,6 +14070,33 @@ function normalizeCachedItem(tabKey, item) {
13932
14070
  return null;
13933
14071
  }
13934
14072
 
14073
+ // The one "unchanged" identity for an acquired result. Fetchers emit it, the
14074
+ // shared store rebuilds it from a validated snapshot, and adoption carries it,
14075
+ // so a producer comparing against a stored snapshot sees the same string it
14076
+ // would have produced itself. It covers what the tab renders: the rows through
14077
+ // the stored-snapshot validator, the markers that say the list is incomplete
14078
+ // and, for Security, the coverage notes. Timestamps are excluded on purpose.
14079
+ function acquisitionContentRaw(resource, { rows, truncated = false, hasNextPage = false, notes = [], blind = false }) {
14080
+ return JSON.stringify({
14081
+ v: GRAPHQL_QUERY_VERSION,
14082
+ resource,
14083
+ truncated: truncated === true,
14084
+ hasNextPage: hasNextPage === true,
14085
+ notes: resource === "security" ? notes : [],
14086
+ blind: resource === "security" && blind === true,
14087
+ rows: rows.map((row) => normalizeCachedItem(resource, row)),
14088
+ });
14089
+ }
14090
+
14091
+ // A pane's unchanged sentinel must describe the rows it displays. Adopting a
14092
+ // peer's different content without moving it let an A, B, A sequence compare
14093
+ // the third result against A and republish B as "unchanged". A snapshot that
14094
+ // does not carry the shared representation clears it, which costs one parse.
14095
+ function adoptedAcquisitionRaw(currentRaw, snapshot, sameContent) {
14096
+ if (sameContent) return currentRaw;
14097
+ return typeof snapshot?.raw === "string" ? snapshot.raw : null;
14098
+ }
14099
+
13935
14100
  function normalizeDashboardCacheEntry(entry) {
13936
14101
  if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
13937
14102
  const tabs = {};
@@ -14558,6 +14723,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
14558
14723
  const content = JSON.stringify(rows);
14559
14724
  const contentDigest = createHash("sha256").update(content).digest("hex");
14560
14725
  if (raw.raw === undefined && raw.contentDigest !== contentDigest) return null;
14726
+ const securityNotes = raw.securityNotes.filter((note) => typeof note === "string").map(safe);
14561
14727
  return {
14562
14728
  schema: 1,
14563
14729
  producerEpoch,
@@ -14565,7 +14731,8 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
14565
14731
  generation,
14566
14732
  rows,
14567
14733
  pageInfo,
14568
- raw: content,
14734
+ raw: acquisitionContentRaw(query.resource, { rows, truncated: raw.meta.truncated,
14735
+ hasNextPage: pageInfo?.hasNextPage, notes: securityNotes, blind: raw.securityBlind }),
14569
14736
  contentDigest,
14570
14737
  entities,
14571
14738
  lastSuccessAt: raw.lastSuccessAt,
@@ -14574,7 +14741,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
14574
14741
  hold: null,
14575
14742
  capabilities,
14576
14743
  meta: { at: raw.meta.at, truncated: raw.meta.truncated },
14577
- securityNotes: raw.securityNotes.filter((note) => typeof note === "string").map(safe),
14744
+ securityNotes,
14578
14745
  securityBlind: raw.securityBlind,
14579
14746
  };
14580
14747
  }
@@ -18525,6 +18692,7 @@ function App({ onCreateRemote = () => {} } = {}) {
18525
18692
  host: runtimeIdentityCoordinator?.current()?.host ?? effectiveRuntimeHost(),
18526
18693
  cwd: process.cwd(),
18527
18694
  account: sshCacheIdentity ?? runtimeIdentityCoordinator?.current()?.accessKey ?? "unverified",
18695
+ remoteUrls: runtimeRemoteUrls,
18528
18696
  }),
18529
18697
  );
18530
18698
  const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
@@ -19522,6 +19690,7 @@ function App({ onCreateRemote = () => {} } = {}) {
19522
19690
  } else if (input==='d' && TABS[activeIndexRef.current].key==='actions') {
19523
19691
  void ciEvidenceRef.current?.('open');
19524
19692
  } else if (featureRef.current?.mode==='ci' && input==='r') {
19693
+ retryPausedTransport(runtimeIdentityCoordinator);
19525
19694
  ciControllerRef.current?.abort();attentionControllerRef.current?.abort();jevOperationRef.current=null;runtimeJevSession?.clear();setFeature(null);void fetchTabRef.current?.('actions',manualRefreshRequest(refreshIntent(input)));
19526
19695
  } else if (featureRef.current?.mode==='focus' && input==='x') {
19527
19696
  jevOperationRef.current=null;const current=featureRef.current;
@@ -19570,6 +19739,9 @@ function App({ onCreateRemote = () => {} } = {}) {
19570
19739
  // validators: `r` on a quiet repository answers 304 and costs nothing,
19571
19740
  // which is exactly when the key is most likely to be pressed.
19572
19741
  const request = manualRefreshRequest(refreshIntent(input));
19742
+ // The explicit retry a paused transport waits for. A collector client has
19743
+ // no local coordinator; its collector resumes on the refresh it receives.
19744
+ if (request) retryPausedTransport(runtimeIdentityCoordinator);
19573
19745
  if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
19574
19746
  } else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
19575
19747
  setActiveIndex(Number(input) - 1);
@@ -20054,6 +20226,17 @@ function App({ onCreateRemote = () => {} } = {}) {
20054
20226
  setSecurityBlind(true);
20055
20227
  return;
20056
20228
  }
20229
+ // Every Security source replayed its capability backoff: no request
20230
+ // was made, so nothing was observed. Keep the displayed rows, the
20231
+ // freshness clock and the peers' snapshot as they are (plan D6), and
20232
+ // let the ordinary cadence retry. The request itself is answered.
20233
+ if (result?.sourceObserved === false) {
20234
+ await cancelAcquisition(acquisition);
20235
+ requestedTabs.delete(key);
20236
+ rescheduleTab(key, completedAt);
20237
+ setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
20238
+ return;
20239
+ }
20057
20240
  // The shared nonce/generation is the publication fence. A superseded
20058
20241
  // owner must not update any local freshness, rows, cadence, or cache.
20059
20242
  const publicationView = stagedAcquisitionPublicationView({
@@ -20152,7 +20335,9 @@ function App({ onCreateRemote = () => {} } = {}) {
20152
20335
  const recovery = requestFailureRecovery(err, verdict);
20153
20336
  recordRuntimeRecovery(key, recovery.reason, { origin: recovery.origin, retryAt });
20154
20337
  }
20155
- if (verdict === "rate-limited") {
20338
+ // A secondary limit is held by the shared transport cooldown, not by a
20339
+ // resource block until the primary reset.
20340
+ if (verdict === "rate-limited" && !secondaryThrottleEvidence(err)) {
20156
20341
  const snapshot = inspectGovernor(scope, Date.now());
20157
20342
  const costs = tabRequestCost(key);
20158
20343
  const blocks = [];
@@ -20304,6 +20489,10 @@ function App({ onCreateRemote = () => {} } = {}) {
20304
20489
  entityRef.current.set(entity.key, { etag: entity.etag, body: entity.body });
20305
20490
  }
20306
20491
  const sameRows = JSON.stringify(dataRef.current[key]) === JSON.stringify(snapshot.rows);
20492
+ const sameContent = sameRows && (key !== "security" ||
20493
+ JSON.stringify(securityNotesRef.current) === JSON.stringify(snapshot.securityNotes ?? []) &&
20494
+ securityBlindRef.current === (snapshot.securityBlind === true));
20495
+ rawRef.current[key] = adoptedAcquisitionRaw(rawRef.current[key], snapshot, sameContent);
20307
20496
  if (!sameRows) setData((current) => ({ ...current, [key]: snapshot.rows }));
20308
20497
  setMeta((current) => current[key]?.at === snapshotMeta.at &&
20309
20498
  current[key]?.truncated === snapshotMeta.truncated
@@ -20312,7 +20501,7 @@ function App({ onCreateRemote = () => {} } = {}) {
20312
20501
  const accessKey = identity()?.accessKey;
20313
20502
  if (accessKey) {
20314
20503
  for (const [sourceKey, capability] of Object.entries(snapshot.capabilities ?? {})) {
20315
- alertBackoff.set(`${accessKey}\0${sourceKey}`, {
20504
+ alertBackoff.set(backoffStorageKey(sourceKey, accessKey), {
20316
20505
  ...capability,
20317
20506
  until: performance.now() + Math.max(0, capability.until - Date.now()),
20318
20507
  });
@@ -20863,7 +21052,7 @@ function App({ onCreateRemote = () => {} } = {}) {
20863
21052
  recoveryRef.current = savedRecovery.status === "healthy"
20864
21053
  ? { ...savedRecovery.current } : { core: null, graphql: null };
20865
21054
  setRecoveryCauses(recoveryRef.current);
20866
- const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey });
21055
+ const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey, remoteUrls: runtimeRemoteUrls });
20867
21056
  // The mount-time target had to guess the account, and guesses
20868
21057
  // "unverified" whenever the bounded warm resolution above did not land
20869
21058
  // in time -- so a session that has rows on disk can start with none.
@@ -21985,6 +22174,21 @@ function App({ onCreateRemote = () => {} } = {}) {
21985
22174
  return () => clearInterval(timer);
21986
22175
  }, [showCachedAge]);
21987
22176
 
22177
+ // Collector mode returns from the acquisition effect before any standalone
22178
+ // wake can advance `now`, which froze running TIME and every UPDATED age
22179
+ // while connected. Apply the standalone rule from a display-only timer: every
22180
+ // second while a run is in progress, otherwise at most once a minute (a same
22181
+ // Date leaves React's render bail-out intact). It never feeds freshness.
22182
+ const collectorDisplayClock = runtime.connect !== null;
22183
+ useEffect(() => {
22184
+ if (!collectorDisplayClock) return;
22185
+ const timer = setInterval(() => setNow((previous) => {
22186
+ const at = Date.now();
22187
+ return hasInProgressRef.current || at - previous.getTime() >= 60_000 ? new Date(at) : previous;
22188
+ }), 1000);
22189
+ return () => clearInterval(timer);
22190
+ }, [collectorDisplayClock]);
22191
+
21988
22192
  const allItems = items ?? [];
21989
22193
  const tabOffsetRaw = offset[tab.key] ?? 0;
21990
22194
  const selectedKey = selected[tab.key] ?? null;
@@ -22637,12 +22841,14 @@ export {
22637
22841
  maintainControlLease,
22638
22842
  claimProbe,
22639
22843
  classifyThrottle,
22844
+ secondaryThrottleEvidence,
22640
22845
  applyTransportCooldown,
22641
22846
  clearTransportThrottle,
22642
22847
  emptyTransportThrottle,
22643
22848
  throttleLadderMs,
22644
22849
  transportCooldownDeadline,
22645
22850
  retryThrottledTransport,
22851
+ retryPausedTransport,
22646
22852
  THROTTLE_LADDER_MS,
22647
22853
  THROTTLE_PAUSE_AFTER,
22648
22854
  MANUAL_GRANT_STREAK_LIMIT,
@@ -22806,6 +23012,7 @@ export {
22806
23012
  saveDashboardCache,
22807
23013
  mergeDashboardCacheSnapshots,
22808
23014
  nextSecurityRaw,
23015
+ adoptedAcquisitionRaw,
22809
23016
  shouldCheckpointFreshness,
22810
23017
  ACQUISITION_STORE_VERSION,
22811
23018
  ACQUISITION_CLAIM_TTL_MS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "A live-refreshing GitHub dashboard for your terminal: Actions, Issues, Pull Requests, and Security alerts in one narrow pane",
5
5
  "type": "module",
6
6
  "bin": {