@promptctl/cc-candybar 1.4.0 → 1.6.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 +76 -76
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +80 -0
- package/src/daemon/cache/git.ts +98 -2
- package/src/daemon/cache/session-usage-store.ts +81 -0
- package/src/daemon/render-payload.ts +186 -35
- package/src/proc/launch.ts +4 -0
- package/src/segments/git.ts +228 -0
|
@@ -25,7 +25,10 @@ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
|
|
|
25
25
|
import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
|
|
26
26
|
import { cacheExpiresAt } from "../segments/cache.js";
|
|
27
27
|
import type { DaemonLogger } from "./log.js";
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
SessionUsageStore,
|
|
30
|
+
SpeedObservation,
|
|
31
|
+
} from "./cache/session-usage-store.js";
|
|
29
32
|
import type { ContextProvider } from "../segments/context.js";
|
|
30
33
|
import type { MetricsProvider } from "../segments/metrics.js";
|
|
31
34
|
import type { TmuxService } from "../segments/tmux.js";
|
|
@@ -65,6 +68,7 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
65
68
|
readonly session?: SessionPayload;
|
|
66
69
|
readonly today?: TodayPayload;
|
|
67
70
|
readonly burn?: BurnPayload;
|
|
71
|
+
readonly speed?: SpeedPayload;
|
|
68
72
|
readonly block?: BlockPayload;
|
|
69
73
|
readonly weekly?: WeeklyPayload;
|
|
70
74
|
readonly cache?: CachePayload;
|
|
@@ -94,6 +98,16 @@ export interface GitPayload {
|
|
|
94
98
|
readonly status?: string;
|
|
95
99
|
readonly operation?: string;
|
|
96
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;
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
export interface SessionPayload {
|
|
@@ -115,6 +129,19 @@ export interface BurnPayload {
|
|
|
115
129
|
readonly costPerHour?: number;
|
|
116
130
|
}
|
|
117
131
|
|
|
132
|
+
// [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
|
|
133
|
+
// second on each of three lanes (prompt-side input, generated output, their
|
|
134
|
+
// total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
|
|
135
|
+
// while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
|
|
136
|
+
// live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
|
|
137
|
+
// idle between turns, or a too-stale prior sample) travels as a missing field to
|
|
138
|
+
// the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
|
|
139
|
+
export interface SpeedPayload {
|
|
140
|
+
readonly input?: number;
|
|
141
|
+
readonly output?: number;
|
|
142
|
+
readonly total?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
export interface BlockPayload {
|
|
119
146
|
readonly nativeUtilization: number;
|
|
120
147
|
readonly resetsAt: number;
|
|
@@ -200,6 +227,15 @@ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
|
|
|
200
227
|
// $/hr is dominated by a single turn rather than a sustained burn.
|
|
201
228
|
const MIN_BURN_SECONDS = 60;
|
|
202
229
|
|
|
230
|
+
// tok/s is a delta between two successive render observations. The wall-time
|
|
231
|
+
// between them must clear a tiny floor (the clock has to have advanced — below
|
|
232
|
+
// it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
|
|
233
|
+
// wider than this means the prior sample predates an idle stretch, so the rate
|
|
234
|
+
// would be diluted by dead time. Both bounds → no reading (re-baseline silently
|
|
235
|
+
// on the next render) rather than a misleading number. [LAW:no-silent-failure]
|
|
236
|
+
const MIN_SPEED_SAMPLE_MS = 50;
|
|
237
|
+
const MAX_SPEED_SAMPLE_MS = 10 * 1000;
|
|
238
|
+
|
|
203
239
|
/**
|
|
204
240
|
* Linearly extrapolate a rate-limit window's utilization to its 100% cap.
|
|
205
241
|
* The window started `windowMs` before `resetsAtSec`; elapsed time and the
|
|
@@ -236,6 +272,64 @@ export function projectCostPerHour(
|
|
|
236
272
|
return (cost * 3600) / durationSeconds;
|
|
237
273
|
}
|
|
238
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Instantaneous tokens-per-second between two successive render observations of
|
|
277
|
+
* one cumulative token count. Returns undefined when the sample window is too
|
|
278
|
+
* small or too large to be honest (see the floor/ceiling constants) or when the
|
|
279
|
+
* count did not advance (idle / between turns — a true 0 over a real window is
|
|
280
|
+
* reported as 0, but a flat count carries no throughput to report). A real
|
|
281
|
+
* positive rate is always >= 0, so callers use -1 as the absence default — 0
|
|
282
|
+
* tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
|
|
283
|
+
*/
|
|
284
|
+
export function projectTokensPerSecond(
|
|
285
|
+
prevTokens: number,
|
|
286
|
+
prevMs: number,
|
|
287
|
+
curTokens: number,
|
|
288
|
+
nowMs: number,
|
|
289
|
+
): number | undefined {
|
|
290
|
+
const deltaMs = nowMs - prevMs;
|
|
291
|
+
if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
|
|
292
|
+
return undefined;
|
|
293
|
+
const deltaTokens = curTokens - prevTokens;
|
|
294
|
+
if (deltaTokens <= 0) return undefined;
|
|
295
|
+
return (deltaTokens * 1000) / deltaMs;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// [LAW:effects-at-boundaries] Pure fold of one speed observation into the
|
|
299
|
+
// payload's three rate lanes. No baseline (first render of a session) or every
|
|
300
|
+
// lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
|
|
301
|
+
// each lane that projects contributes its rate, each that doesn't is a missing
|
|
302
|
+
// field → the -1 default → "—".
|
|
303
|
+
function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
|
|
304
|
+
const { prev, cur } = obs;
|
|
305
|
+
if (prev === undefined) return undefined;
|
|
306
|
+
const input = projectTokensPerSecond(
|
|
307
|
+
prev.input,
|
|
308
|
+
prev.atMs,
|
|
309
|
+
cur.input,
|
|
310
|
+
cur.atMs,
|
|
311
|
+
);
|
|
312
|
+
const output = projectTokensPerSecond(
|
|
313
|
+
prev.output,
|
|
314
|
+
prev.atMs,
|
|
315
|
+
cur.output,
|
|
316
|
+
cur.atMs,
|
|
317
|
+
);
|
|
318
|
+
const total = projectTokensPerSecond(
|
|
319
|
+
prev.total,
|
|
320
|
+
prev.atMs,
|
|
321
|
+
cur.total,
|
|
322
|
+
cur.atMs,
|
|
323
|
+
);
|
|
324
|
+
if (input === undefined && output === undefined && total === undefined)
|
|
325
|
+
return undefined;
|
|
326
|
+
return {
|
|
327
|
+
...(input !== undefined && { input }),
|
|
328
|
+
...(output !== undefined && { output }),
|
|
329
|
+
...(total !== undefined && { total }),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
239
333
|
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
240
334
|
|
|
241
335
|
// ─── Config-driven provider gating ───────────────────────────────────────────
|
|
@@ -388,6 +482,12 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
|
|
|
388
482
|
...(has("git.repoName") && { showRepoName: true }),
|
|
389
483
|
...(has("git.operation") && { showOperation: true }),
|
|
390
484
|
...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
|
|
485
|
+
// Any PR field laid out turns on the (network) forge lookup. Keep these in
|
|
486
|
+
// lockstep with the projected `git.pr*` fields below.
|
|
487
|
+
...((has("git.prNumber") ||
|
|
488
|
+
has("git.prState") ||
|
|
489
|
+
has("git.prUrl") ||
|
|
490
|
+
has("git.prError")) && { showPullRequest: true }),
|
|
391
491
|
};
|
|
392
492
|
}
|
|
393
493
|
|
|
@@ -428,6 +528,11 @@ export async function buildRenderPayload(
|
|
|
428
528
|
const wants = (prefix: string): boolean =>
|
|
429
529
|
anyPathStartsWith(neededInputPaths, prefix);
|
|
430
530
|
|
|
531
|
+
// [LAW:single-enforcer] One clock read feeds every projection this render —
|
|
532
|
+
// the ETA extrapolations below AND the tok/s sample window in the speed lane,
|
|
533
|
+
// so an ETA, a reset countdown, and a throughput figure all agree on "now".
|
|
534
|
+
const nowMs = (deps.clock ?? (() => new Date()))().getTime();
|
|
535
|
+
|
|
431
536
|
// [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
|
|
432
537
|
// lane is ONE shape: "needed → call provider (whose contract is to never
|
|
433
538
|
// reject — the catch makes the lane total against bugs, mapping a throw
|
|
@@ -443,40 +548,57 @@ export async function buildRenderPayload(
|
|
|
443
548
|
? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
|
|
444
549
|
: Promise.resolve(ABSENT);
|
|
445
550
|
|
|
446
|
-
const [
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
() => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
462
|
-
),
|
|
463
|
-
lane("today", wants("today"), () =>
|
|
464
|
-
deps.usageStore.getTodayInfo(hookData),
|
|
551
|
+
const [
|
|
552
|
+
gitOutcome,
|
|
553
|
+
usage,
|
|
554
|
+
today,
|
|
555
|
+
context,
|
|
556
|
+
metrics,
|
|
557
|
+
tmuxSession,
|
|
558
|
+
cacheExpiry,
|
|
559
|
+
speed,
|
|
560
|
+
] = await Promise.all([
|
|
561
|
+
lane("git", wants("git"), () =>
|
|
562
|
+
deps.gitProvider.getGitInfo(
|
|
563
|
+
cwd ?? hookData.workspace?.current_dir,
|
|
564
|
+
gitOptionsFromClosure(neededInputPaths),
|
|
565
|
+
hookData.workspace?.project_dir,
|
|
465
566
|
),
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
567
|
+
),
|
|
568
|
+
// [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
|
|
569
|
+
// a derivative of session cost and metrics duration — so wanting `burn`
|
|
570
|
+
// pulls in exactly the two lanes it is folded from.
|
|
571
|
+
lane(
|
|
572
|
+
"session",
|
|
573
|
+
wants("session.cost") || wants("session.tokens") || wants("burn"),
|
|
574
|
+
() => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
575
|
+
),
|
|
576
|
+
lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
|
|
577
|
+
lane("context", wants("context"), () =>
|
|
578
|
+
deps.contextProvider.getContextInfo(hookData),
|
|
579
|
+
),
|
|
580
|
+
lane("metrics", wants("metrics") || wants("burn"), () =>
|
|
581
|
+
deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
|
|
582
|
+
),
|
|
583
|
+
lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
|
|
584
|
+
// Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
|
|
585
|
+
// seam, so it runs alongside the other providers and stays in the shared
|
|
586
|
+
// in-flight budget rather than blocking the event loop on sync fs.
|
|
587
|
+
lane("cache", wants("cache"), () =>
|
|
588
|
+
cacheExpiresAt(hookData.transcript_path),
|
|
589
|
+
),
|
|
590
|
+
// [LAW:one-source-of-truth] tok/s folds from the SAME store the session
|
|
591
|
+
// lane reads — observeSpeed both reports the prior sample and records this
|
|
592
|
+
// render's, so it must run every render the speed segment is laid out (the
|
|
593
|
+
// first establishes the baseline that the second projects from).
|
|
594
|
+
lane("speed", wants("speed"), () =>
|
|
595
|
+
deps.usageStore.observeSpeed(
|
|
596
|
+
hookData.session_id,
|
|
597
|
+
hookData.transcript_path,
|
|
598
|
+
nowMs,
|
|
478
599
|
),
|
|
479
|
-
|
|
600
|
+
),
|
|
601
|
+
]);
|
|
480
602
|
// [LAW:effects-at-boundaries] The projections are pure folds returning data
|
|
481
603
|
// (payload fragment + failure descriptions); the log effect happens once,
|
|
482
604
|
// here, at the edge. `take` is the total fold for the single-value lanes:
|
|
@@ -505,8 +627,6 @@ export async function buildRenderPayload(
|
|
|
505
627
|
// the formatter func — a duplicate code path was retired.)
|
|
506
628
|
const fiveHour = hookData.rate_limits?.five_hour;
|
|
507
629
|
const sevenDay = hookData.rate_limits?.seven_day;
|
|
508
|
-
// [LAW:single-enforcer] One clock read feeds every projection this render.
|
|
509
|
-
const nowMs = (deps.clock ?? (() => new Date()))().getTime();
|
|
510
630
|
const blockEta = fiveHour
|
|
511
631
|
? projectEtaMinutes(
|
|
512
632
|
fiveHour.used_percentage,
|
|
@@ -532,6 +652,13 @@ export async function buildRenderPayload(
|
|
|
532
652
|
wants("burn") && burnCost != null && burnDuration != null
|
|
533
653
|
? projectCostPerHour(burnCost, burnDuration)
|
|
534
654
|
: undefined;
|
|
655
|
+
// [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
|
|
656
|
+
// effect: it read state and advanced the baseline); the rate is a pure fold of
|
|
657
|
+
// that data here at the edge. Absent observation (lane skipped/failed) or no
|
|
658
|
+
// projectable lane → no `speed` key → every lane reads its -1 default.
|
|
659
|
+
const speedObs = take(speed);
|
|
660
|
+
const speedPayload =
|
|
661
|
+
speedObs !== undefined ? projectSpeed(speedObs) : undefined;
|
|
535
662
|
|
|
536
663
|
// [LAW:one-source-of-truth] The theme variable surfaces the session's
|
|
537
664
|
// resolved theme so the toolbar/tray DSL templates can encode it into
|
|
@@ -597,6 +724,7 @@ export async function buildRenderPayload(
|
|
|
597
724
|
...(sessionPayload !== undefined && { session: sessionPayload }),
|
|
598
725
|
...(todayPayload !== undefined && { today: todayPayload }),
|
|
599
726
|
...(costPerHour !== undefined && { burn: { costPerHour } }),
|
|
727
|
+
...(speedPayload !== undefined && { speed: speedPayload }),
|
|
600
728
|
...(wants("block") &&
|
|
601
729
|
fiveHour !== undefined && {
|
|
602
730
|
block: {
|
|
@@ -697,6 +825,28 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
|
|
|
697
825
|
const upstream = field("upstream", info.upstream);
|
|
698
826
|
const repoName = field("repoName", info.repoName);
|
|
699
827
|
|
|
828
|
+
// [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
|
|
829
|
+
// `failed` lookup is NOT dropped to a missing key (which the template can't
|
|
830
|
+
// tell apart from "no PR"). It is BOTH logged AND surfaced as `prError` so
|
|
831
|
+
// the segment renders a distinct marker. `absent` is still a missing key (no
|
|
832
|
+
// PR / no forge → segment off). The reason is the gate value the template
|
|
833
|
+
// tests; the same reason is logged for the operator.
|
|
834
|
+
const pr = info.pullRequest;
|
|
835
|
+
const prFields: {
|
|
836
|
+
prNumber?: number;
|
|
837
|
+
prState?: string;
|
|
838
|
+
prUrl?: string;
|
|
839
|
+
prError?: string;
|
|
840
|
+
} = {};
|
|
841
|
+
if (pr?.kind === "ok") {
|
|
842
|
+
prFields.prNumber = pr.value.number;
|
|
843
|
+
prFields.prState = pr.value.state;
|
|
844
|
+
prFields.prUrl = pr.value.url;
|
|
845
|
+
} else if (pr?.kind === "failed") {
|
|
846
|
+
failures.push(`git.pr: ${pr.reason}`);
|
|
847
|
+
prFields.prError = pr.reason;
|
|
848
|
+
}
|
|
849
|
+
|
|
700
850
|
return {
|
|
701
851
|
git: {
|
|
702
852
|
branch: info.branch,
|
|
@@ -717,6 +867,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
|
|
|
717
867
|
...(stash !== undefined && { stash }),
|
|
718
868
|
...(upstream !== undefined && { upstream }),
|
|
719
869
|
...(repoName !== undefined && { repoName }),
|
|
870
|
+
...prFields,
|
|
720
871
|
},
|
|
721
872
|
failures,
|
|
722
873
|
};
|
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",
|
package/src/segments/git.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface AheadBehind {
|
|
|
16
16
|
behind: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
|
|
20
|
+
// `number` and `url` are the click target; `state` is the forge's status string
|
|
21
|
+
// (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
|
|
22
|
+
// though resolvePullRequest only ever returns a PR whose state is open (a
|
|
23
|
+
// merged/closed PR for the branch is the domain's `absent`, not a value).
|
|
24
|
+
export interface PullRequest {
|
|
25
|
+
number: number;
|
|
26
|
+
state: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
// [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
|
|
20
31
|
// value is unknown because the fetch failed" is representable distinct from
|
|
21
32
|
// a real 0/""/basename — the states the old catch-and-substitute blocks
|
|
@@ -37,6 +48,13 @@ export interface GitInfo {
|
|
|
37
48
|
upstream?: Outcome<string>;
|
|
38
49
|
repoName?: Outcome<string>;
|
|
39
50
|
isWorktree?: boolean;
|
|
51
|
+
// [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
|
|
52
|
+
// distinct here: `ok` is an open PR, `absent` is "this branch has none / no
|
|
53
|
+
// forge / no forge CLI", `failed` is "the forge was asked but couldn't
|
|
54
|
+
// answer" (auth, network, API error). The render boundary surfaces `failed`
|
|
55
|
+
// as a VISIBLE marker — collapsing it to `absent` would make a transient
|
|
56
|
+
// outage look like the PR vanished. Undefined = `showPullRequest` was off.
|
|
57
|
+
pullRequest?: Outcome<PullRequest>;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
// [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
|
|
@@ -53,6 +71,11 @@ export interface GitInfoOptions {
|
|
|
53
71
|
showStashCount?: boolean;
|
|
54
72
|
showUpstream?: boolean;
|
|
55
73
|
showRepoName?: boolean;
|
|
74
|
+
// Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
|
|
75
|
+
// one option whose fetch the daemon caches on a longer, independent TTL than
|
|
76
|
+
// the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
|
|
77
|
+
// inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
|
|
78
|
+
showPullRequest?: boolean;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// [LAW:dataflow-not-control-flow] One classifier for every git invocation.
|
|
@@ -89,6 +112,134 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
|
|
|
89
112
|
return v ? ok(v) : ABSENT;
|
|
90
113
|
}
|
|
91
114
|
|
|
115
|
+
// [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
|
|
116
|
+
// "ask a forge CLI for the branch's PR, fold the typed launch result into an
|
|
117
|
+
// Outcome<PullRequest>." The accept/reject shape table is identical across
|
|
118
|
+
// both — only the no-PR stderr signature and the JSON field names differ — so
|
|
119
|
+
// the classification lives here once and each forge supplies its own
|
|
120
|
+
// (noPrPattern, parse) as data.
|
|
121
|
+
//
|
|
122
|
+
// [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
|
|
123
|
+
// ok + parse ok (open PR) → ok (the value)
|
|
124
|
+
// ok + parse ok (not open) → absent (branch's PR is done)
|
|
125
|
+
// ok + parse fails → failed (forge answered garbage)
|
|
126
|
+
// non-zero + no-PR stderr → absent (genuine "none for branch")
|
|
127
|
+
// spawn-error ENOENT (no CLI) → absent (no forge integration)
|
|
128
|
+
// spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
|
|
129
|
+
// non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
|
|
130
|
+
// timeout / signal / rate-limited → failed (forge couldn't answer)
|
|
131
|
+
export type ForgeName = "github" | "gitlab";
|
|
132
|
+
|
|
133
|
+
// [LAW:types-are-the-program] Extract the host from a git remote, handling the
|
|
134
|
+
// two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
|
|
135
|
+
// host[:port]/path`. The URL form is checked first — its `host` in a scp regex
|
|
136
|
+
// would mis-capture the scheme (`https` before `://`). Returns null for an
|
|
137
|
+
// unrecognized shape (local path, unknown syntax).
|
|
138
|
+
function remoteHost(remoteUrl: string): string | null {
|
|
139
|
+
const url = remoteUrl.trim();
|
|
140
|
+
const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
|
|
141
|
+
if (proto) return proto[1]!.toLowerCase();
|
|
142
|
+
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
|
|
143
|
+
if (scp) return scp[1]!.toLowerCase();
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
|
|
148
|
+
// URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
|
|
149
|
+
// `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
|
|
150
|
+
// `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
|
|
151
|
+
// hostname is undetectable here and falls through to null (absent), same as
|
|
152
|
+
// GitHub Enterprise on a custom domain.
|
|
153
|
+
export function detectForge(remoteUrl: string): ForgeName | null {
|
|
154
|
+
const host = remoteHost(remoteUrl);
|
|
155
|
+
if (!host) return null;
|
|
156
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
157
|
+
if (/(^|\.)gitlab\./.test(host)) return "gitlab";
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function classifyForgePr(
|
|
162
|
+
label: string,
|
|
163
|
+
result: LaunchResult,
|
|
164
|
+
noPrPattern: RegExp,
|
|
165
|
+
parse: (stdout: string) => Outcome<PullRequest>,
|
|
166
|
+
): Outcome<PullRequest> {
|
|
167
|
+
if (result.ok) return parse(result.stdout);
|
|
168
|
+
// ENOENT (no forge CLI on PATH) is a static configuration absence, not a
|
|
169
|
+
// transient lookup failure — it never showed a PR, so showing nothing costs
|
|
170
|
+
// nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
|
|
171
|
+
// resource limits) means the CLI is present but could not launch — a real
|
|
172
|
+
// failure that must stay visible, so it falls through to the `failed` path.
|
|
173
|
+
if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
|
|
174
|
+
return ABSENT;
|
|
175
|
+
if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
|
|
176
|
+
return ABSENT;
|
|
177
|
+
const detail = [
|
|
178
|
+
result.reason,
|
|
179
|
+
result.exitCode != null ? `exit ${result.exitCode}` : null,
|
|
180
|
+
result.error ?? firstLine(result.stderr),
|
|
181
|
+
]
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.join(", ");
|
|
184
|
+
return failed(`${label}: ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
188
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
|
|
192
|
+
// value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
|
|
193
|
+
export function parseGithubPr(stdout: string): Outcome<PullRequest> {
|
|
194
|
+
let json: unknown;
|
|
195
|
+
try {
|
|
196
|
+
json = JSON.parse(stdout);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return failed(
|
|
199
|
+
`gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
|
|
203
|
+
const { number, state, url } = json;
|
|
204
|
+
if (
|
|
205
|
+
typeof number !== "number" ||
|
|
206
|
+
typeof state !== "string" ||
|
|
207
|
+
typeof url !== "string"
|
|
208
|
+
) {
|
|
209
|
+
return failed("gh pr view: missing number/state/url");
|
|
210
|
+
}
|
|
211
|
+
if (state.toUpperCase() !== "OPEN") return ABSENT;
|
|
212
|
+
return ok({ number, state, url });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `glab mr view --output json` → one JSON object (iid / state / web_url). State
|
|
216
|
+
// "opened" is the open MR; anything else is `absent`. NOTE: verified against
|
|
217
|
+
// glab's documented JSON shape, not runtime-exercised here (glab not installed
|
|
218
|
+
// on the dev machine) — the github path is the runtime-verified one.
|
|
219
|
+
export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
|
|
220
|
+
let json: unknown;
|
|
221
|
+
try {
|
|
222
|
+
json = JSON.parse(stdout);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return failed(
|
|
225
|
+
`glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
|
|
229
|
+
const iid = json.iid;
|
|
230
|
+
const state = json.state;
|
|
231
|
+
const url = json.web_url;
|
|
232
|
+
if (
|
|
233
|
+
typeof iid !== "number" ||
|
|
234
|
+
typeof state !== "string" ||
|
|
235
|
+
typeof url !== "string"
|
|
236
|
+
) {
|
|
237
|
+
return failed("glab mr view: missing iid/state/web_url");
|
|
238
|
+
}
|
|
239
|
+
if (state.toLowerCase() !== "opened") return ABSENT;
|
|
240
|
+
return ok({ number: iid, state, url });
|
|
241
|
+
}
|
|
242
|
+
|
|
92
243
|
export class GitService {
|
|
93
244
|
private isGitRepo(workingDir: string): boolean {
|
|
94
245
|
try {
|
|
@@ -407,6 +558,83 @@ export class GitService {
|
|
|
407
558
|
return ok(match?.[1] || path.basename(workingDir));
|
|
408
559
|
}
|
|
409
560
|
|
|
561
|
+
// [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
|
|
562
|
+
// remote to fold into its PR cache key (the PR value depends on the remote;
|
|
563
|
+
// a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
|
|
564
|
+
// forge detector reads the host from it. `config --get` exits 1 when unset →
|
|
565
|
+
// `absent` (no remote, hence no forge PR concept), distinct from a failure.
|
|
566
|
+
async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
|
|
567
|
+
return nonEmpty(
|
|
568
|
+
classify(
|
|
569
|
+
"git config remote.origin.url",
|
|
570
|
+
await this.execGitAsync(["config", "--get", "remote.origin.url"], {
|
|
571
|
+
cwd: workingDir,
|
|
572
|
+
timeout: 2000,
|
|
573
|
+
}),
|
|
574
|
+
"absent",
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
|
|
580
|
+
// execGitAsync but carries the "forge" launch category and a longer timeout
|
|
581
|
+
// (this is a network call, not a local git read). Returns the typed
|
|
582
|
+
// LaunchResult so classifyForgePr maps every termination cause to an Outcome.
|
|
583
|
+
private async execForgeAsync(
|
|
584
|
+
bin: string,
|
|
585
|
+
args: readonly string[],
|
|
586
|
+
options: { cwd: string; timeout: number },
|
|
587
|
+
): Promise<LaunchResult> {
|
|
588
|
+
return launch({
|
|
589
|
+
bin,
|
|
590
|
+
args: [...args],
|
|
591
|
+
cwd: options.cwd,
|
|
592
|
+
env: { ...process.env },
|
|
593
|
+
timeoutMs: options.timeout,
|
|
594
|
+
category: "forge",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
|
|
599
|
+
// CLI. Pure dispatch over a remote the CALLER has already read: pick the
|
|
600
|
+
// forge by host, run its CLI, fold the launch result into an Outcome. The
|
|
601
|
+
// remote is a parameter (not read here) so the cache layer can fold it into
|
|
602
|
+
// its key in the same read — no caching here; the daemon's GitDataProvider
|
|
603
|
+
// owns the PR cache + TTL (a network resource wants a longer, independent
|
|
604
|
+
// lifecycle than local git state). `absent` when the host is no recognized
|
|
605
|
+
// forge; the CLI dispatch then classifies the rest.
|
|
606
|
+
async resolvePullRequest(
|
|
607
|
+
workingDir: string,
|
|
608
|
+
remoteUrl: string,
|
|
609
|
+
): Promise<Outcome<PullRequest>> {
|
|
610
|
+
const forge = detectForge(remoteUrl);
|
|
611
|
+
if (forge === "github") {
|
|
612
|
+
return classifyForgePr(
|
|
613
|
+
"gh pr view",
|
|
614
|
+
await this.execForgeAsync(
|
|
615
|
+
"gh",
|
|
616
|
+
["pr", "view", "--json", "number,state,url"],
|
|
617
|
+
{ cwd: workingDir, timeout: 5000 },
|
|
618
|
+
),
|
|
619
|
+
/no (open )?pull requests? found/i,
|
|
620
|
+
parseGithubPr,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (forge === "gitlab") {
|
|
624
|
+
return classifyForgePr(
|
|
625
|
+
"glab mr view",
|
|
626
|
+
await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
|
|
627
|
+
cwd: workingDir,
|
|
628
|
+
timeout: 5000,
|
|
629
|
+
}),
|
|
630
|
+
/no (open )?merge requests? (found|available)/i,
|
|
631
|
+
parseGitlabMr,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// Recognized neither host → no forge integration for this remote.
|
|
635
|
+
return ABSENT;
|
|
636
|
+
}
|
|
637
|
+
|
|
410
638
|
private isWorktree(workingDir: string): boolean {
|
|
411
639
|
try {
|
|
412
640
|
const gitDir = path.join(workingDir, ".git");
|