@promptctl/cc-candybar 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.5.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.5.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.5.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.5.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.7.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.7.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.7.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.7.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -280,6 +280,21 @@ export const DEFAULT_DSL_CONFIG = {
280
280
  default: 0,
281
281
  },
282
282
 
283
+ // Forge PR/MR — the daemon's git provider resolves the branch's open PR via
284
+ // gh/glab and projects it here. Declaring any of these turns on the network
285
+ // lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
286
+ // was asked but couldn't answer (auth/network) — distinct from "no PR"
287
+ // (every field empty). prNumber 0 (default) ⇒ no open PR.
288
+ "git.prNumber": {
289
+ kind: "input",
290
+ path: "git.prNumber",
291
+ type: "number",
292
+ default: 0,
293
+ },
294
+ "git.prState": { kind: "input", path: "git.prState", default: "" },
295
+ "git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
296
+ "git.prError": { kind: "input", path: "git.prError", default: "" },
297
+
283
298
  // Prompt-cache expiry — epoch seconds, projected by the cache provider.
284
299
  // Same unit/shape as block/weekly resetsAt so the cacheTimer segment
285
300
  // composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
@@ -410,6 +425,17 @@ export const DEFAULT_DSL_CONFIG = {
410
425
  type: "number",
411
426
  default: -1,
412
427
  },
428
+ // Recent burn-rate trend: a comma-delimited series of total-lane tok/s the
429
+ // daemon folds from its sample ring (render-payload.ts). A series cannot
430
+ // cross the scalar var-system seam, so it travels as a string the
431
+ // `sparkline` helper decodes. Default "" is the genuine "no history yet"
432
+ // form (the helper renders nothing); the segment gates on it being present.
433
+ "speed.history": {
434
+ kind: "input",
435
+ path: "speed.history",
436
+ type: "string",
437
+ default: "",
438
+ },
413
439
 
414
440
  // Context — daemon fetches via ContextProvider; contextLeftPercentage.
415
441
  "context.totalTokens": {
@@ -542,6 +568,24 @@ export const DEFAULT_DSL_CONFIG = {
542
568
  fg: "foreground",
543
569
  when: '{{ ne .git.branch "" }}',
544
570
  },
571
+ // Git PR/MR — the branch's open pull/merge request as a clickable link.
572
+ // OPT-IN: declared but NOT in the default root (it adds a network gh/glab
573
+ // call). Add "gitPr" to a container's children to enable it. The `{{ link
574
+ // url text }}` emits ONE OSC-8 region carrying the https PR url, so the
575
+ // CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
576
+ // [LAW:no-silent-failure] Three render states from the data: an open PR
577
+ // (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
578
+ // renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
579
+ // no PR (both empty) leaves the `when` gate false and the segment absent.
580
+ gitPr: {
581
+ template:
582
+ '{{ if ne .git.prUrl "" }}' +
583
+ '{{ link .git.prUrl (printf " ⇆ #%v " .git.prNumber) }}' +
584
+ "{{ else }} ⚠ PR {{ end }}",
585
+ bg: "surface-active",
586
+ fg: "foreground",
587
+ when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
588
+ },
545
589
  // Quick-action tray — copy the session id / cwd, open the project dir /
546
590
  // transcript in the editor. [LAW:locality-or-seam] The glyph is the
547
591
  // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
@@ -623,6 +667,21 @@ export const DEFAULT_DSL_CONFIG = {
623
667
  fg: "foreground",
624
668
  when: "{{ gt .session.tokens 0 }}",
625
669
  },
670
+ // Burn-rate sparkline: the recent total-lane tok/s trend as a unicode
671
+ // mini-graph. Declared-but-opt-in (NOT in the default root, like speed /
672
+ // block / weekly): a user adds `tokenSparkline` to their layout. The
673
+ // `sparkline` helper decodes the daemon-owned series and draws it; `24`
674
+ // caps the glyph count to the cell, showing the live tail of the ring. The
675
+ // segment's fg colors the whole graph (no per-glyph color). Gated on the
676
+ // history being present so the cell never renders empty (the series needs
677
+ // two samples before its first bar). [LAW:effects-at-boundaries] — all the
678
+ // history lives in the daemon ring, the template only draws.
679
+ tokenSparkline: {
680
+ template: " ⚡ {{ sparkline .speed.history 24 }} ",
681
+ bg: "panel",
682
+ fg: "foreground",
683
+ when: '{{ ne .speed.history "" }}',
684
+ },
626
685
  // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
627
686
  // to 0, so an expired cache renders "cold" (and reads red via the ≤8
628
687
  // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { GitService, type GitInfo } from "../../segments/git";
4
- import { ok, type Outcome } from "../../utils/outcome";
3
+ import { GitService, type GitInfo, type PullRequest } from "../../segments/git";
4
+ import { ABSENT, ok, type Outcome } from "../../utils/outcome";
5
5
  import { debug } from "../../utils/logger";
6
6
  import { WatcherRegistry, type WatcherHandle } from "./watchers";
7
7
 
@@ -44,6 +44,17 @@ const DEFAULT_TTL_MS = 30_000;
44
44
  const DEFAULT_MAX_ENTRIES = 64;
45
45
  const SANITY_INTERVAL_MS = 5 * 60_000;
46
46
 
47
+ // [LAW:decomposition] The forge PR lookup is a network resource with a
48
+ // different lifecycle than local git state: it changes ~twice in a PR's life
49
+ // and the fs watchers on .git/HEAD|index say nothing about remote PR state. So
50
+ // it gets its OWN cache + TTL — independent of the 30s GitInfo TTL — so the
51
+ // per-render git fetch stays all-local and the gh/glab spawn happens at most
52
+ // once per window (or on branch switch, via the branch in the key). A `failed`
53
+ // lookup caches for a SHORTER window so a transient forge outage is retried
54
+ // soon, not pinned to the bar for the full ok-TTL.
55
+ const PR_TTL_OK_MS = 5 * 60_000;
56
+ const PR_TTL_FAIL_MS = 45_000;
57
+
47
58
  // Options applied to subscribe()'s internal getInfo() call. var-system's six
48
59
  // GitField values (branch, sha, dirty, ahead, behind, stash) all project from
49
60
  // these flags — keep them in lockstep with the projection in
@@ -60,6 +71,11 @@ interface MtimeSnapshot {
60
71
  index: number;
61
72
  }
62
73
 
74
+ interface PrCacheEntry {
75
+ pr: Outcome<PullRequest>;
76
+ computedAt: number;
77
+ }
78
+
63
79
  interface GitCacheEntry {
64
80
  info: GitInfo;
65
81
  computedAt: number;
@@ -147,6 +163,16 @@ export class GitDataProvider extends GitService {
147
163
  // here; subsequent concurrent callers await the same promise and resolve in
148
164
  // lockstep.
149
165
  private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
166
+ // [LAW:one-source-of-truth] The forge PR cache, keyed by `repoRoot|branch`
167
+ // (a branch switch is a new key, so its PR is fetched fresh; the old branch's
168
+ // entry ages out). Separate from `entries` because it carries its own TTL.
169
+ private readonly prCache = new Map<string, PrCacheEntry>();
170
+ // [LAW:single-enforcer] Coalesce concurrent PR misses on the same key — same
171
+ // role as fetchInFlight for the GitInfo path, but for the network forge call.
172
+ private readonly prFetchInFlight = new Map<
173
+ string,
174
+ Promise<Outcome<PullRequest>>
175
+ >();
150
176
  // [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
151
177
  // `refreshing` holds the repoRoots whose refresh loop is currently
152
178
  // executing; `refreshAgain` is the trailing-edge flag: if a new
@@ -292,6 +318,15 @@ export class GitDataProvider extends GitService {
292
318
  if (outcome.kind !== "ok") return outcome;
293
319
  const info = outcome.value;
294
320
 
321
+ // [LAW:decomposition] The inner GitService never resolves the PR — the
322
+ // cache layer owns that lookup so it can give it an independent TTL. On the
323
+ // 30s GitInfo refetch the prCache is almost always warm, so attaching the
324
+ // PR here is a memory read; the gh/glab spawn only fires when the prCache
325
+ // entry has aged past its (outcome-dependent) TTL or the branch changed.
326
+ if (options.showPullRequest) {
327
+ info.pullRequest = await this.getPullRequestCached(repoRoot, info.branch);
328
+ }
329
+
295
330
  // Drop any prior entry for this exact key before re-inserting (so we
296
331
  // release its watcher refcount cleanly).
297
332
  this.dropEntry(key);
@@ -315,6 +350,63 @@ export class GitDataProvider extends GitService {
315
350
  return outcome;
316
351
  }
317
352
 
353
+ // [LAW:single-enforcer] The one read path for a branch's PR. Reads the remote
354
+ // (cheap local git) so the cache key reflects every input the PR value
355
+ // depends on — `repoRoot|branch|remote` — and a re-pointed origin is a fresh
356
+ // key, not a stale hit ([LAW:one-source-of-truth]). TTL is outcome-dependent
357
+ // (a `failed` retries sooner). Concurrent misses coalesce through
358
+ // prFetchInFlight. The forge dispatch delegates to inner.resolvePullRequest —
359
+ // this layer is cache + lifecycle only, never forge knowledge.
360
+ private async getPullRequestCached(
361
+ repoRoot: string,
362
+ branch: string,
363
+ ): Promise<Outcome<PullRequest>> {
364
+ // [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
365
+ // surface; only `absent` (no remote configured) means "no forge PR".
366
+ const remote = await this.inner.getRemoteOriginUrl(repoRoot);
367
+ if (remote.kind === "failed") return remote;
368
+ if (remote.kind === "absent") return ABSENT;
369
+ const remoteUrl = remote.value;
370
+
371
+ const key = `${repoRoot}|${branch}|${remoteUrl}`;
372
+ const now = Date.now();
373
+
374
+ const existing = this.prCache.get(key);
375
+ if (existing) {
376
+ const ttl = existing.pr.kind === "failed" ? PR_TTL_FAIL_MS : PR_TTL_OK_MS;
377
+ if (now - existing.computedAt < ttl) {
378
+ // LRU touch so the active branch's PR survives eviction.
379
+ this.prCache.delete(key);
380
+ this.prCache.set(key, existing);
381
+ return existing.pr;
382
+ }
383
+ }
384
+
385
+ const pending = this.prFetchInFlight.get(key);
386
+ if (pending) return pending;
387
+
388
+ const promise = this.inner
389
+ .resolvePullRequest(repoRoot, remoteUrl)
390
+ .then((pr) => {
391
+ this.prCache.set(key, { pr, computedAt: Date.now() });
392
+ this.evictPrIfNeeded();
393
+ return pr;
394
+ })
395
+ .finally(() => {
396
+ this.prFetchInFlight.delete(key);
397
+ });
398
+ this.prFetchInFlight.set(key, promise);
399
+ return promise;
400
+ }
401
+
402
+ private evictPrIfNeeded(): void {
403
+ while (this.prCache.size > this.maxEntries) {
404
+ const oldest = this.prCache.keys().next().value;
405
+ if (oldest === undefined) break;
406
+ this.prCache.delete(oldest);
407
+ }
408
+ }
409
+
318
410
  // [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
319
411
  // outcome into the GitInfo|null the var-system callback contract expects.
320
412
  // `failed` is logged HERE — the one log site for this consumption path
@@ -548,6 +640,10 @@ export class GitDataProvider extends GitService {
548
640
  this.refreshing.clear();
549
641
  this.refreshAgain.clear();
550
642
  this.fetchInFlight.clear();
643
+ // In-flight PR fetches resolve naturally; clearing the maps means the next
644
+ // caller starts fresh (the prCache is rebuilt cold like every other cache).
645
+ this.prCache.clear();
646
+ this.prFetchInFlight.clear();
551
647
  if (this.ownsWatchers) this.watchers.closeAll();
552
648
  }
553
649
  }
@@ -61,14 +61,24 @@ export interface SpeedSample {
61
61
  readonly atMs: number;
62
62
  }
63
63
 
64
- // The prior observation (absent on the very first render of a session) and the
65
- // one just taken. The pure rate projection lives at the render-payload boundary;
66
- // this store only remembers and reports the pair.
64
+ // The prior observation (absent on the very first render of a session), the one
65
+ // just taken, and the recent ring (oldest→newest, INCLUDING `cur`). The pure
66
+ // projections live at the render-payload boundary; this store only remembers and
67
+ // reports. [LAW:one-source-of-truth] `prev === samples[samples.length - 2]` — the
68
+ // tok/s baseline and the burn-rate history fold from the SAME owned ring, not two
69
+ // parallel stores. tok/s reads the last pair; the sparkline reads every pair.
67
70
  export interface SpeedObservation {
68
71
  readonly prev?: SpeedSample;
69
72
  readonly cur: SpeedSample;
73
+ readonly samples: readonly SpeedSample[];
70
74
  }
71
75
 
76
+ // How many recent samples the burn-rate ring retains per session. A render-cadence
77
+ // trend, not an archive: enough to fill a wide sparkline cell, capped so an
78
+ // idle-but-alive session can't grow it without bound. The window the sparkline
79
+ // draws is a tail slice of this (the `width` arg), so this only sets the ceiling.
80
+ const SPEED_RING_CAPACITY = 64;
81
+
72
82
  function speedSampleOf(
73
83
  breakdown: TokenBreakdown | null,
74
84
  atMs: number,
@@ -202,11 +212,13 @@ export class SessionUsageStore {
202
212
  // first seed completes every later read awaits an already-settled promise —
203
213
  // zero rescan. A rejected seed is dropped so the next read retries.
204
214
  private readonly seeded = new Map<string, Promise<void>>();
205
- // [LAW:one-source-of-truth] The prior tok/s observation per session. tok/s is
206
- // a derivative of the SAME token totals the records map already owns; the
207
- // baseline (prev counts + time) lives HERE, not in a parallel counter. One
208
- // call to observeSpeed advances it; the pure delta math is render-payload's.
209
- private readonly speedSamples = new Map<string, SpeedSample>();
215
+ // [LAW:one-source-of-truth] The recent tok/s observations per session, a
216
+ // bounded ring (oldest→newest). tok/s is a derivative of the SAME token totals
217
+ // the records map already owns; the baseline (prior counts + time) is the ring's
218
+ // last element, not a parallel counter. The burn-rate sparkline folds over the
219
+ // whole ring; tok/s folds over its final pair. One call to observeSpeed appends;
220
+ // the pure delta math is render-payload's.
221
+ private readonly speedRings = new Map<string, SpeedSample[]>();
210
222
  // [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
211
223
  // for the speed sample. Concurrent renders observing the SAME transcript state
212
224
  // (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
@@ -356,9 +368,33 @@ export class SessionUsageStore {
356
368
  const breakdown =
357
369
  record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
358
370
  const cur = speedSampleOf(breakdown, nowMs);
359
- const prev = this.speedSamples.get(sessionId);
360
- this.speedSamples.set(sessionId, cur);
361
- return ok({ ...(prev !== undefined && { prev }), cur });
371
+ const ring = this.speedRings.get(sessionId) ?? [];
372
+ // [LAW:no-ambient-temporal-coupling] Observation time (atMs, the render
373
+ // clock) owns ring order NOT ingest-completion order. Two concurrent
374
+ // observes with different mtimes don't coalesce in speedFlight and each
375
+ // awaits ingest before this mutation, so a plain append would record
376
+ // samples in whichever-ingest-settled-first order and invert oldest→newest.
377
+ // prev is the latest sample strictly before this observation; the post-
378
+ // insert sort by atMs makes the ring's order independent of completion
379
+ // order. (get→insert→set is synchronous after the await, so each resumed
380
+ // continuation mutates atomically — no lost update.)
381
+ let prev: SpeedSample | undefined;
382
+ for (const s of ring) {
383
+ if (s.atMs < cur.atMs && (prev === undefined || s.atMs > prev.atMs)) {
384
+ prev = s;
385
+ }
386
+ }
387
+ ring.push(cur);
388
+ ring.sort((a, b) => a.atMs - b.atMs);
389
+ // Drop oldest (smallest atMs ⇒ ring[0]) beyond the cap — a tail window,
390
+ // not an archive.
391
+ if (ring.length > SPEED_RING_CAPACITY) ring.shift();
392
+ this.speedRings.set(sessionId, ring);
393
+ return ok({
394
+ ...(prev !== undefined && { prev }),
395
+ cur,
396
+ samples: [...ring],
397
+ });
362
398
  });
363
399
  }
364
400
 
@@ -494,7 +530,7 @@ export class SessionUsageStore {
494
530
  for (const [sid, record] of this.entries) {
495
531
  if (now - record.lastSeenAt > this.staleAgeMs) {
496
532
  this.entries.delete(sid);
497
- this.speedSamples.delete(sid);
533
+ this.speedRings.delete(sid);
498
534
  dropped++;
499
535
  }
500
536
  }
@@ -510,7 +546,7 @@ export class SessionUsageStore {
510
546
  const oldest = this.entries.keys().next().value;
511
547
  if (oldest === undefined) break;
512
548
  this.entries.delete(oldest);
513
- this.speedSamples.delete(oldest);
549
+ this.speedRings.delete(oldest);
514
550
  dlog("info", `usageStore evict ${oldest}`);
515
551
  }
516
552
  }
@@ -522,6 +558,6 @@ export class SessionUsageStore {
522
558
  }
523
559
  this.entries.clear();
524
560
  this.seeded.clear();
525
- this.speedSamples.clear();
561
+ this.speedRings.clear();
526
562
  }
527
563
  }
@@ -98,6 +98,16 @@ export interface GitPayload {
98
98
  readonly status?: string;
99
99
  readonly operation?: string;
100
100
  readonly timeSinceCommit?: number;
101
+ // Forge PR/MR. [LAW:no-silent-failure] Unlike every other git field (where
102
+ // `failed` collapses to a missing key), the PR's failure is surfaced as
103
+ // `prError` so the segment can render a VISIBLY DISTINCT marker — a forge
104
+ // outage must not look like "no PR". The three render-distinguishable states:
105
+ // open PR (prNumber/prState/prUrl present), lookup failed (prError present),
106
+ // no PR (all absent → segment when-gated off).
107
+ readonly prNumber?: number;
108
+ readonly prState?: string;
109
+ readonly prUrl?: string;
110
+ readonly prError?: string;
101
111
  }
102
112
 
103
113
  export interface SessionPayload {
@@ -130,6 +140,14 @@ export interface SpeedPayload {
130
140
  readonly input?: number;
131
141
  readonly output?: number;
132
142
  readonly total?: number;
143
+ // [LAW:one-type-per-behavior] The recent burn-rate trend: a delimited series
144
+ // of total-lane tok/s over the store's sample ring, for the `sparkline` helper.
145
+ // It rides the speed lane because it folds from the SAME observation the three
146
+ // instantaneous rates do, but it is INDEPENDENTLY optional — a session that
147
+ // burst then went idle has no current rate yet still has a history to draw. It
148
+ // travels as a string because a series cannot cross the scalar var-system seam;
149
+ // the helper decodes it. Absent (no measurable in-window pair) → missing → "".
150
+ readonly history?: string;
133
151
  }
134
152
 
135
153
  export interface BlockPayload {
@@ -320,6 +338,42 @@ function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
320
338
  };
321
339
  }
322
340
 
341
+ // [LAW:effects-at-boundaries] Pure fold of the observation's sample ring into the
342
+ // burn-rate history string. Each adjacent pair becomes one total-lane tok/s; an
343
+ // un-projectable pair (no new tokens, or a gap outside the sample window) is a
344
+ // real ZERO-throughput interval, not absence — the series is a string of numbers,
345
+ // and 0 is the honest value for "burned nothing here" ([LAW:no-silent-failure] —
346
+ // the gap is reported, not dropped to misalign the graph). Fewer than two samples
347
+ // ⇒ no pair ⇒ undefined (the whole field drops to the "" default).
348
+ function projectSpeedHistory(obs: SpeedObservation): string | undefined {
349
+ const { samples } = obs;
350
+ const rates: number[] = [];
351
+ for (let i = 1; i < samples.length; i++) {
352
+ const prev = samples[i - 1]!;
353
+ const cur = samples[i]!;
354
+ // [LAW:no-silent-failure] Only an in-window interval is a measurable reading.
355
+ // An out-of-window pair — samples too close to time a rate reliably, or a
356
+ // stale gap spanning idle time — is UNMEASURABLE, not zero burn, so it is
357
+ // SKIPPED rather than fabricated as a 0 bar that would read as real activity.
358
+ // projectTokensPerSecond stays the single formula+window authority (same
359
+ // module constants); this classifier disambiguates its undefined: out-of-
360
+ // window ⇒ skip, in-window ⇒ undefined means a non-positive token delta, a
361
+ // genuine zero-burn reading that stays 0.
362
+ const deltaMs = cur.atMs - prev.atMs;
363
+ if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
364
+ continue;
365
+ const rate = projectTokensPerSecond(
366
+ prev.total,
367
+ prev.atMs,
368
+ cur.total,
369
+ cur.atMs,
370
+ );
371
+ rates.push(rate ?? 0);
372
+ }
373
+ if (rates.length === 0) return undefined;
374
+ return rates.join(",");
375
+ }
376
+
323
377
  // ─── Builder ─────────────────────────────────────────────────────────────────
324
378
 
325
379
  // ─── Config-driven provider gating ───────────────────────────────────────────
@@ -472,6 +526,12 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
472
526
  ...(has("git.repoName") && { showRepoName: true }),
473
527
  ...(has("git.operation") && { showOperation: true }),
474
528
  ...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
529
+ // Any PR field laid out turns on the (network) forge lookup. Keep these in
530
+ // lockstep with the projected `git.pr*` fields below.
531
+ ...((has("git.prNumber") ||
532
+ has("git.prState") ||
533
+ has("git.prUrl") ||
534
+ has("git.prError")) && { showPullRequest: true }),
475
535
  };
476
536
  }
477
537
 
@@ -641,8 +701,22 @@ export async function buildRenderPayload(
641
701
  // that data here at the edge. Absent observation (lane skipped/failed) or no
642
702
  // projectable lane → no `speed` key → every lane reads its -1 default.
643
703
  const speedObs = take(speed);
644
- const speedPayload =
704
+ // [LAW:dataflow-not-control-flow] The instantaneous rates and the burn-rate
705
+ // history fold independently from one observation — either can be present
706
+ // without the other (a fresh burst has rates but a one-sample history; an
707
+ // idle-after-burst session has a history but no current rate). Merge whatever
708
+ // each yields; the whole `speed` key drops only when both are absent.
709
+ const speedRates =
645
710
  speedObs !== undefined ? projectSpeed(speedObs) : undefined;
711
+ const speedHistory =
712
+ speedObs !== undefined ? projectSpeedHistory(speedObs) : undefined;
713
+ const speedPayload =
714
+ speedRates !== undefined || speedHistory !== undefined
715
+ ? {
716
+ ...speedRates,
717
+ ...(speedHistory !== undefined && { history: speedHistory }),
718
+ }
719
+ : undefined;
646
720
 
647
721
  // [LAW:one-source-of-truth] The theme variable surfaces the session's
648
722
  // resolved theme so the toolbar/tray DSL templates can encode it into
@@ -809,6 +883,28 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
809
883
  const upstream = field("upstream", info.upstream);
810
884
  const repoName = field("repoName", info.repoName);
811
885
 
886
+ // [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
887
+ // `failed` lookup is NOT dropped to a missing key (which the template can't
888
+ // tell apart from "no PR"). It is BOTH logged AND surfaced as `prError` so
889
+ // the segment renders a distinct marker. `absent` is still a missing key (no
890
+ // PR / no forge → segment off). The reason is the gate value the template
891
+ // tests; the same reason is logged for the operator.
892
+ const pr = info.pullRequest;
893
+ const prFields: {
894
+ prNumber?: number;
895
+ prState?: string;
896
+ prUrl?: string;
897
+ prError?: string;
898
+ } = {};
899
+ if (pr?.kind === "ok") {
900
+ prFields.prNumber = pr.value.number;
901
+ prFields.prState = pr.value.state;
902
+ prFields.prUrl = pr.value.url;
903
+ } else if (pr?.kind === "failed") {
904
+ failures.push(`git.pr: ${pr.reason}`);
905
+ prFields.prError = pr.reason;
906
+ }
907
+
812
908
  return {
813
909
  git: {
814
910
  branch: info.branch,
@@ -829,6 +925,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
829
925
  ...(stash !== undefined && { stash }),
830
926
  ...(upstream !== undefined && { upstream }),
831
927
  ...(repoName !== undefined && { repoName }),
928
+ ...prFields,
832
929
  },
833
930
  failures,
834
931
  };
@@ -31,6 +31,10 @@ import type { LaunchStatsHandle } from "./stats-handle";
31
31
  // pattern. [LAW:no-mode-explosion]: no per-site escape hatch.
32
32
  export const LAUNCH_CATEGORIES = [
33
33
  "git",
34
+ // Forge CLIs (gh / glab) for the PR/MR lookup. A network-bound spawn,
35
+ // separate from "git" so daemon-stats attributes it independently and a
36
+ // future rate limit can target it without throttling local git.
37
+ "forge",
34
38
  "user-shell",
35
39
  "tmux",
36
40
  "click.pbcopy",