@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/dist/index.mjs +74 -74
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +59 -0
- package/src/daemon/cache/git.ts +98 -2
- package/src/daemon/cache/session-usage-store.ts +50 -14
- package/src/daemon/render-payload.ts +98 -1
- package/src/proc/launch.ts +4 -0
- package/src/segments/git.ts +228 -0
- package/src/template-engine/engine.ts +2 -1
- package/src/template-engine/funcs.ts +17 -0
- package/src/template-engine/sparkline.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
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]
|
package/src/daemon/cache/git.ts
CHANGED
|
@@ -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)
|
|
65
|
-
//
|
|
66
|
-
// this store only remembers and
|
|
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
|
|
206
|
-
// a derivative of the SAME token totals
|
|
207
|
-
// baseline (
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
};
|
package/src/proc/launch.ts
CHANGED
|
@@ -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",
|