@promptctl/cc-candybar 1.34.0 → 1.35.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 +88 -88
- package/package.json +5 -5
- package/src/check.ts +5 -0
- package/src/config/default-dsl-config.ts +59 -0
- package/src/daemon/client.ts +6 -3
- package/src/daemon/protocol.ts +62 -5
- package/src/daemon/render-payload.ts +120 -0
- package/src/daemon/server.ts +13 -8
- package/src/demo/statusline.json5 +8 -6
- package/src/index.ts +34 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.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,9 +91,9 @@
|
|
|
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.35.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.35.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.35.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.35.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/check.ts
CHANGED
|
@@ -119,6 +119,11 @@ export function checkPayload(
|
|
|
119
119
|
weekly: { percentage: 21, resetsAt: nowSec + 5 * 86400 },
|
|
120
120
|
cache: { expiresAt: nowSec + 15 * 60 },
|
|
121
121
|
tmux: { session: "work" },
|
|
122
|
+
// `ssh: true` for the same reason `tmux.session` is populated: this
|
|
123
|
+
// fixture deliberately satisfies every gate so a when-gated segment
|
|
124
|
+
// RENDERS and its template gets checked. A local-looking fixture would
|
|
125
|
+
// gate the host segment off and let a typo inside it ship.
|
|
126
|
+
host: { name: "tester-box", user: "tester", ssh: true },
|
|
122
127
|
theme: { effective: effective.theme },
|
|
123
128
|
look: { effective: effective.look },
|
|
124
129
|
// [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
|
|
@@ -465,6 +465,31 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
465
465
|
// gates the input variant so unused segments cost nothing.
|
|
466
466
|
"tmux.session": { kind: "input", path: "tmux.session", default: "" },
|
|
467
467
|
|
|
468
|
+
// Host identity — which machine this session is on, and whether the user
|
|
469
|
+
// arrived over SSH. All three come through the augmented payload rather
|
|
470
|
+
// than `kind: "env"` / `kind: "shell"`, and that is not a style choice:
|
|
471
|
+
//
|
|
472
|
+
// • `host.ssh` CANNOT be an env var here. Variables are evaluated in the
|
|
473
|
+
// DAEMON, which is detached and serves every session for this user at
|
|
474
|
+
// once — its `SSH_*` env describes whichever shell happened to spawn
|
|
475
|
+
// it. The fact is captured by the live client and carried as a wire
|
|
476
|
+
// hint (the `termCols` pattern); the payload is the only honest source.
|
|
477
|
+
// • `host.name`/`host.user` are machine facts the daemon reads directly,
|
|
478
|
+
// so they cost two syscalls instead of a per-render subprocess.
|
|
479
|
+
//
|
|
480
|
+
// Defaults are the "unknown" values, and for `ssh` that is `false`: an
|
|
481
|
+
// absent field (a client too old to send the hint) renders as local, which
|
|
482
|
+
// is the pre-feature behavior, while the input-fallback chain records a
|
|
483
|
+
// `last_error` so `cc-candybar debug vars` can still tell the two apart.
|
|
484
|
+
"host.name": { kind: "input", path: "host.name", default: "" },
|
|
485
|
+
"host.user": { kind: "input", path: "host.user", default: "" },
|
|
486
|
+
"host.ssh": {
|
|
487
|
+
kind: "input",
|
|
488
|
+
path: "host.ssh",
|
|
489
|
+
type: "boolean",
|
|
490
|
+
default: false,
|
|
491
|
+
},
|
|
492
|
+
|
|
468
493
|
// Git — every field flows from the daemon's projected GitInfo payload.
|
|
469
494
|
// The DSL's native `kind: "git"` source covers a 6-field subset
|
|
470
495
|
// (branch/sha/dirty/ahead/behind/stash); using `input` here gives the
|
|
@@ -803,6 +828,34 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
803
828
|
fg: "foreground",
|
|
804
829
|
when: '{{ ne .tmux.session "" }}',
|
|
805
830
|
},
|
|
831
|
+
// "You are not on your own machine." Modelled on the git-taculous zsh
|
|
832
|
+
// theme, which prepends `(%n@%m)` to the prompt under SSH and shows
|
|
833
|
+
// nothing locally — you already know your own hostname.
|
|
834
|
+
//
|
|
835
|
+
// [LAW:dataflow-not-control-flow] Presence IS the signal. There is no SSH
|
|
836
|
+
// "mode" and no force-on flag (git-taculous's GITTACULOUS_ENABLE_SSH_THEME
|
|
837
|
+
// would be a flag with no deletion date, [LAW:no-mode-explosion]); the cell
|
|
838
|
+
// exists exactly when the value says so, like tmux/block/weekly. A user who
|
|
839
|
+
// wants it always-on overrides this one segment's `when` to `"true"`.
|
|
840
|
+
//
|
|
841
|
+
// `bg: "warning"` is load-bearing, not decoration: warning is one of the
|
|
842
|
+
// hue-ANCHORED palette roots, so it survives every theme, look, and
|
|
843
|
+
// per-segment hue transposition still reading as an alert. Any other slot
|
|
844
|
+
// would drift with the hue stepper and could land camouflaged against its
|
|
845
|
+
// neighbours — exactly what a "wrong machine" warning must never do.
|
|
846
|
+
// `contrastOn (bgOf)` then derives a readable foreground from whatever that
|
|
847
|
+
// resolves to, rather than betting a fixed `foreground` stays legible.
|
|
848
|
+
//
|
|
849
|
+
// Each half falls back to "?" so a failed hostname/username read renders
|
|
850
|
+
// `⇄ ?@?` — still unmistakably "remote", and legibly missing its identity
|
|
851
|
+
// rather than a blank that reads as a rendering bug ([LAW:no-silent-failure]).
|
|
852
|
+
host: {
|
|
853
|
+
template:
|
|
854
|
+
'⇄ {{ .host.user | default "?" }}@{{ .host.name | default "?" }}',
|
|
855
|
+
bg: "warning",
|
|
856
|
+
fg: "{{ contrastOn (bgOf) }}",
|
|
857
|
+
when: "{{ .host.ssh }}",
|
|
858
|
+
},
|
|
806
859
|
git: {
|
|
807
860
|
template: GIT_TEMPLATE,
|
|
808
861
|
bg: "surface-active",
|
|
@@ -1268,6 +1321,12 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
1268
1321
|
kind: "container",
|
|
1269
1322
|
direction: "horizontal",
|
|
1270
1323
|
children: [
|
|
1324
|
+
// Leads the identity row: the first thing to read is WHICH MACHINE,
|
|
1325
|
+
// because it reframes every path and branch to its right. Same
|
|
1326
|
+
// placement git-taculous gives `(%n@%m)` — ahead of the directory.
|
|
1327
|
+
// Gated off entirely on a local session, so the row still opens with
|
|
1328
|
+
// `directory` where it always has.
|
|
1329
|
+
{ kind: "segment", name: "host" },
|
|
1271
1330
|
{ kind: "segment", name: "directory" },
|
|
1272
1331
|
{ kind: "segment", name: "gitaculous" },
|
|
1273
1332
|
{ kind: "segment", name: "toolbar" },
|
package/src/daemon/client.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { ClaudeHookData } from "../utils/claude";
|
|
13
13
|
import { requestOutcome } from "./client-transport";
|
|
14
14
|
import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
|
|
15
|
-
import type { Response } from "./protocol";
|
|
15
|
+
import type { ClientHints, Response } from "./protocol";
|
|
16
16
|
|
|
17
17
|
const CONNECT_TIMEOUT_MS = 50;
|
|
18
18
|
const TOTAL_BUDGET_MS = 150;
|
|
@@ -47,14 +47,17 @@ function projectOutput(
|
|
|
47
47
|
// There is no inline render path; see src/index.ts. The caller is responsible
|
|
48
48
|
// for branching on outcome.kind and deciding whether to kick, display an
|
|
49
49
|
// error glyph, or print the rendered output.
|
|
50
|
+
// [LAW:one-source-of-truth] `hints` carries every fact the daemon cannot
|
|
51
|
+
// observe for itself; it is spread onto the request verbatim so this relay
|
|
52
|
+
// never becomes a second place that decides what the client saw.
|
|
50
53
|
export function tryRenderViaDaemon(
|
|
51
54
|
hookData: ClaudeHookData,
|
|
52
55
|
args: string[],
|
|
53
56
|
cwd: string,
|
|
54
|
-
|
|
57
|
+
hints: ClientHints,
|
|
55
58
|
): Promise<ClientOutcome> {
|
|
56
59
|
return requestOutcome(
|
|
57
|
-
{ kind: "render", hookData, args, cwd,
|
|
60
|
+
{ kind: "render", hookData, args, cwd, ...hints },
|
|
58
61
|
RENDER_BUDGETS,
|
|
59
62
|
projectOutput,
|
|
60
63
|
);
|
package/src/daemon/protocol.ts
CHANGED
|
@@ -34,12 +34,61 @@ export interface RenderRequest {
|
|
|
34
34
|
hookData: ClaudeHookData;
|
|
35
35
|
args: string[];
|
|
36
36
|
cwd: string;
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
37
|
+
// ─── Client hints ────────────────────────────────────────────────────────
|
|
38
|
+
// [LAW:single-enforcer] Facts only the LIVE CLIENT can observe, captured at
|
|
39
|
+
// the trust boundary and trusted by the daemon. The daemon is detached and
|
|
40
|
+
// one-per-user, so its own env answers for whichever shell spawned it —
|
|
41
|
+
// possibly a different session, possibly hours ago. Every field below is
|
|
42
|
+
// typed here but arrives as untrusted JSON: callers MUST route the request
|
|
43
|
+
// through parseClientHints at the receive boundary, never read these
|
|
44
|
+
// directly. See the ClientHints doc block for the absence semantics.
|
|
42
45
|
termCols?: number;
|
|
46
|
+
ssh?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// [LAW:locality-or-seam] The seam for "a fact the daemon cannot observe about
|
|
50
|
+
// the session it is rendering for". `termCols` established the pattern; `ssh`
|
|
51
|
+
// is the second member, and the documented-but-unbuilt client-aware
|
|
52
|
+
// `colorCompatibility: "auto"` is the next. Naming the set as ONE type is what
|
|
53
|
+
// keeps that third addition a field rather than another sanitizer, another
|
|
54
|
+
// wire read, and another parameter threaded through the render path.
|
|
55
|
+
//
|
|
56
|
+
// [LAW:parse-dont-validate] This is the stamped type. `RenderRequest`'s
|
|
57
|
+
// same-named fields are raw JSON of unknown provenance; a `ClientHints` has
|
|
58
|
+
// crossed the checkpoint, so nothing downstream re-checks them.
|
|
59
|
+
//
|
|
60
|
+
// [LAW:types-are-the-program] Both fields are optional, but they mean
|
|
61
|
+
// DIFFERENT things by absence, and each is the strongest true theorem for its
|
|
62
|
+
// own fact:
|
|
63
|
+
// • `termCols` absent — the client tried and could not determine a width
|
|
64
|
+
// (no COLUMNS, no TTY on stderr). A genuine "unknown", reachable from any
|
|
65
|
+
// client version.
|
|
66
|
+
// • `ssh` absent — the client did not REPORT. A current client always knows
|
|
67
|
+
// (its own env is total on this question) and so always sends `true` or
|
|
68
|
+
// `false`; absence therefore means one thing only: a client too old to
|
|
69
|
+
// carry the field — a real case, because `cc-candybar install` stages a
|
|
70
|
+
// native binary that does not turn over with the npm package. Collapsing
|
|
71
|
+
// that to `false` here would fuse "we know it's local" with "we don't
|
|
72
|
+
// know" ([LAW:no-silent-failure]); instead it travels onward as an absent
|
|
73
|
+
// payload field, where the DSL input-fallback chain emits the declared
|
|
74
|
+
// default AND records a `last_error` that `cc-candybar debug vars`
|
|
75
|
+
// surfaces.
|
|
76
|
+
export interface ClientHints {
|
|
77
|
+
readonly termCols?: number;
|
|
78
|
+
readonly ssh?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// [LAW:single-enforcer] The ONE checkpoint where wire-supplied client hints
|
|
82
|
+
// become trusted values. Per-field sanitizers stay separate (each fact has its
|
|
83
|
+
// own validity rule) but nothing outside this function calls them, so a new
|
|
84
|
+
// hint cannot reach the render path un-sanitized.
|
|
85
|
+
export function parseClientHints(req: RenderRequest): ClientHints {
|
|
86
|
+
const termCols = sanitizeTermCols(req.termCols);
|
|
87
|
+
const ssh = sanitizeSsh(req.ssh);
|
|
88
|
+
return {
|
|
89
|
+
...(termCols !== undefined && { termCols }),
|
|
90
|
+
...(ssh !== undefined && { ssh }),
|
|
91
|
+
};
|
|
43
92
|
}
|
|
44
93
|
|
|
45
94
|
// [LAW:no-defensive-null-guards] exception: trust boundary. The wire is
|
|
@@ -58,6 +107,14 @@ export function sanitizeTermCols(v: unknown): number | undefined {
|
|
|
58
107
|
return n > MAX_TERM_COLS ? MAX_TERM_COLS : n;
|
|
59
108
|
}
|
|
60
109
|
|
|
110
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary, same shape as
|
|
111
|
+
// sanitizeTermCols. A non-boolean (absent, or a malformed/hostile frame) is
|
|
112
|
+
// NOT coerced to `false` — the three wire states stay three
|
|
113
|
+
// ([LAW:no-silent-failure]): true, false, and "no answer from this client".
|
|
114
|
+
export function sanitizeSsh(v: unknown): boolean | undefined {
|
|
115
|
+
return typeof v === "boolean" ? v : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
61
118
|
export interface ShutdownRequest {
|
|
62
119
|
v: number;
|
|
63
120
|
kind: "shutdown";
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
// resolve falls back to the variable's declared default).
|
|
18
18
|
|
|
19
19
|
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
20
21
|
import type { ClaudeHookData } from "../utils/claude.js";
|
|
22
|
+
import type { ClientHints } from "./protocol.js";
|
|
21
23
|
import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
|
|
22
24
|
import { walkNodes } from "../config/dsl-types.js";
|
|
23
25
|
import { extractTemplateRefs } from "../config/dsl-loader.js";
|
|
@@ -96,6 +98,11 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
96
98
|
|
|
97
99
|
readonly git?: GitPayload;
|
|
98
100
|
readonly tmux?: { readonly session: string };
|
|
101
|
+
// [LAW:types-are-the-program] REQUIRED for the same reason theme/look are:
|
|
102
|
+
// the daemon assembles it every render from sources that cannot be "not
|
|
103
|
+
// requested" (two syscalls and one already-parsed wire hint). The fields
|
|
104
|
+
// INSIDE it carry the real optionality — see HostPayload.
|
|
105
|
+
readonly host: HostPayload;
|
|
99
106
|
// [LAW:one-source-of-truth] The daemon-resolved effective theme name —
|
|
100
107
|
// effectiveThemeName(sessionState.theme, globals.palette). The SAME value the
|
|
101
108
|
// rendered basePalette is built from, surfaced so a trigger label can display
|
|
@@ -200,6 +207,41 @@ export interface GitPayload {
|
|
|
200
207
|
readonly prError?: string;
|
|
201
208
|
}
|
|
202
209
|
|
|
210
|
+
// Which machine this session is on, and whether the user got here over the
|
|
211
|
+
// network. The two halves have DIFFERENT provenance and that is the whole
|
|
212
|
+
// design ([LAW:one-source-of-truth]):
|
|
213
|
+
//
|
|
214
|
+
// • `name`/`user` are MACHINE facts. Client and daemon are the same machine
|
|
215
|
+
// by construction — the socket path is UID-derived and the pid mutex is
|
|
216
|
+
// per-user — so the daemon reading them directly cannot drift from what
|
|
217
|
+
// the client would have reported. Sending them over the wire would buy
|
|
218
|
+
// nothing and add a second source.
|
|
219
|
+
// • `ssh` is a SESSION fact and is the exact opposite: one daemon serves a
|
|
220
|
+
// local session and an SSH session simultaneously, so the daemon's own env
|
|
221
|
+
// answers for whichever shell spawned it. It can ONLY arrive as a client
|
|
222
|
+
// hint. This is the same reasoning that makes `globals.colorCompatibility:
|
|
223
|
+
// "auto"` deliberately unrepresentable.
|
|
224
|
+
//
|
|
225
|
+
// [LAW:no-silent-failure] Every field is optional because each can genuinely
|
|
226
|
+
// be unknown, and absence is preserved rather than defaulted here: `user` when
|
|
227
|
+
// the uid has no passwd entry, `ssh` when the client predates the hint. Both
|
|
228
|
+
// travel as missing keys to the DSL input-fallback chain, which emits the
|
|
229
|
+
// declared default AND records a `last_error` that `cc-candybar debug vars`
|
|
230
|
+
// surfaces — so "we don't know" stays distinguishable from "we know it's
|
|
231
|
+
// local", which a `?? false` here would have destroyed.
|
|
232
|
+
export interface HostPayload {
|
|
233
|
+
// The SHORT hostname — `os.hostname()` up to the first dot, the same
|
|
234
|
+
// projection zsh's `%m` makes. A statusbar cell identifies a machine to a
|
|
235
|
+
// human; the FQDN is a network address, a different fact, and a separate
|
|
236
|
+
// field the day something needs it.
|
|
237
|
+
readonly name?: string;
|
|
238
|
+
// The EFFECTIVE username from the passwd database, not `$USER`. The env var
|
|
239
|
+
// is a map that drifts (su/sudo leave it stale); the passwd entry for the
|
|
240
|
+
// running uid is the territory. Matches zsh's `%n`.
|
|
241
|
+
readonly user?: string;
|
|
242
|
+
readonly ssh?: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
203
245
|
export interface SessionPayload {
|
|
204
246
|
readonly cost?: number;
|
|
205
247
|
readonly tokens?: number;
|
|
@@ -463,6 +505,72 @@ function projectSpeedHistory(obs: SpeedObservation): string | undefined {
|
|
|
463
505
|
return rates.join(",");
|
|
464
506
|
}
|
|
465
507
|
|
|
508
|
+
// ─── Host identity ───────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The short hostname: everything before the first dot, or the whole string when
|
|
512
|
+
* there is no dot. `os.hostname()` yields an FQDN on some hosts (macOS's
|
|
513
|
+
* `mymachine.local`, a DNS-configured server's `web1.prod.example.com`) and a
|
|
514
|
+
* bare name on others; this makes the rendered cell identify the machine the
|
|
515
|
+
* same way on both, which is zsh's `%m` and the projection git-taculous shows.
|
|
516
|
+
*
|
|
517
|
+
* [LAW:effects-at-boundaries] Pure and total — the syscall stays in readHost.
|
|
518
|
+
*/
|
|
519
|
+
export function shortHostname(hostname: string): string {
|
|
520
|
+
const dot = hostname.indexOf(".");
|
|
521
|
+
return dot < 0 ? hostname : hostname.slice(0, dot);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Assemble the host identity for one render.
|
|
526
|
+
*
|
|
527
|
+
* [LAW:effects-at-boundaries] Named `read*`, not `project*`, because it is not
|
|
528
|
+
* pure: two syscalls happen here. That is in bounds — this module IS the
|
|
529
|
+
* daemon's data-assembly edge, the same edge that reads `process.env.HOME`
|
|
530
|
+
* below — and the point of the seam is that nothing downstream reads them
|
|
531
|
+
* again.
|
|
532
|
+
*
|
|
533
|
+
* [LAW:no-silent-failure] A throwing syscall (a uid with no passwd entry is the
|
|
534
|
+
* realistic case, in a stripped container) yields an ABSENT field plus a
|
|
535
|
+
* description for the caller to log, exactly like a failed git lane — never a
|
|
536
|
+
* fabricated name, and never an exception: the whole bar must not blank over a
|
|
537
|
+
* cosmetic cell.
|
|
538
|
+
*/
|
|
539
|
+
function readHost(hints: ClientHints): {
|
|
540
|
+
readonly host: HostPayload;
|
|
541
|
+
readonly failures: readonly string[];
|
|
542
|
+
} {
|
|
543
|
+
const failures: string[] = [];
|
|
544
|
+
const attempt = (field: string, read: () => string): string | undefined => {
|
|
545
|
+
try {
|
|
546
|
+
const value = read();
|
|
547
|
+
// "" is not a usable identity; treat it as absence so the DSL default
|
|
548
|
+
// applies rather than rendering an empty `@host` fragment.
|
|
549
|
+
return value === "" ? undefined : value;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
failures.push(`host.${field}: ${String(e)}`);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const name = attempt("name", () => shortHostname(os.hostname()));
|
|
557
|
+
const user = attempt("user", () => os.userInfo().username);
|
|
558
|
+
return {
|
|
559
|
+
host: {
|
|
560
|
+
...(name !== undefined && { name }),
|
|
561
|
+
...(user !== undefined && { user }),
|
|
562
|
+
// [LAW:one-source-of-truth] Passed straight through from the parsed
|
|
563
|
+
// hint. The daemon deliberately does NOT consult its own SSH_* env as a
|
|
564
|
+
// fallback: that env belongs to whichever shell spawned it, so a
|
|
565
|
+
// "helpful" fallback would confidently mislabel every session that
|
|
566
|
+
// daemon serves. Absent hint → absent field → declared default + a
|
|
567
|
+
// recorded last_error, which is the honest report of "not answered".
|
|
568
|
+
...(hints.ssh !== undefined && { ssh: hints.ssh }),
|
|
569
|
+
},
|
|
570
|
+
failures,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
466
574
|
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
467
575
|
|
|
468
576
|
// ─── Config-driven provider gating ───────────────────────────────────────────
|
|
@@ -666,6 +774,13 @@ export async function buildRenderPayload(
|
|
|
666
774
|
// daemon already computes every one of these for renderDsl's options; this
|
|
667
775
|
// is that same struct, threaded to the sole payload assembler.
|
|
668
776
|
effective: EffectiveGlobals,
|
|
777
|
+
// [LAW:locality-or-seam] The parsed client hints, NOT the raw request — this
|
|
778
|
+
// function is downstream of the wire checkpoint and never re-sanitizes.
|
|
779
|
+
// Separate from `effective` on purpose: that struct is resolved globals (what
|
|
780
|
+
// the config and the session chose), this is observed session context (what
|
|
781
|
+
// the client saw). Fusing them would put a config-precedence chain and a
|
|
782
|
+
// trust boundary behind one name.
|
|
783
|
+
hints: ClientHints,
|
|
669
784
|
): Promise<RenderPayload> {
|
|
670
785
|
const wants = (prefix: string): boolean =>
|
|
671
786
|
anyPathStartsWith(neededInputPaths, prefix);
|
|
@@ -756,6 +871,10 @@ export async function buildRenderPayload(
|
|
|
756
871
|
|
|
757
872
|
const gitProjection = projectGitInfo(gitOutcome);
|
|
758
873
|
failures.push(...gitProjection.failures);
|
|
874
|
+
// Ungated, like `home` below: two syscalls and a hint already in hand, so a
|
|
875
|
+
// `wants` gate would add a branch and save nothing.
|
|
876
|
+
const hostProjection = readHost(hints);
|
|
877
|
+
failures.push(...hostProjection.failures);
|
|
759
878
|
const usageValue = take(usage);
|
|
760
879
|
const todayValue = take(today);
|
|
761
880
|
const contextValue = take(context);
|
|
@@ -867,6 +986,7 @@ export async function buildRenderPayload(
|
|
|
867
986
|
...(home !== undefined && { home }),
|
|
868
987
|
...(gitProjection.git !== undefined && { git: gitProjection.git }),
|
|
869
988
|
...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
|
|
989
|
+
host: hostProjection.host,
|
|
870
990
|
// [LAW:one-source-of-truth] Always present — the daemon resolves every
|
|
871
991
|
// one of these each render (for BuildLineOptions/basePalette), and these
|
|
872
992
|
// are those exact values. No `wants` gate: each costs nothing (already in
|
package/src/daemon/server.ts
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
PROTOCOL_VERSION,
|
|
39
39
|
encodeFrame,
|
|
40
40
|
makeFrameReader,
|
|
41
|
-
|
|
41
|
+
parseClientHints,
|
|
42
42
|
} from "./protocol";
|
|
43
43
|
import type { Request, Response } from "./protocol";
|
|
44
44
|
import { GitDataProvider } from "./cache/git";
|
|
@@ -836,17 +836,21 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
836
836
|
req.cwd,
|
|
837
837
|
sessionConfigFile,
|
|
838
838
|
);
|
|
839
|
-
// [LAW:
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
839
|
+
// [LAW:parse-dont-validate] The ONE checkpoint for everything the client
|
|
840
|
+
// observed and the daemon cannot. Raw `req.*` hint fields are not read
|
|
841
|
+
// past this line; `hints` is the stamped type the render path consumes.
|
|
842
|
+
//
|
|
843
|
+
// [LAW:single-enforcer] Every hint is captured client-side because the
|
|
844
|
+
// daemon is detached and shared: its env answers for whichever shell
|
|
845
|
+
// spawned it. We do NOT consult getTerminalWidth's env/stderr fallbacks
|
|
846
|
+
// for width, and we do NOT consult SSH_* for remoteness — both would
|
|
847
|
+
// describe a different session than the one being rendered.
|
|
845
848
|
// [LAW:one-source-of-truth] Both branches feed raw cols through
|
|
846
849
|
// applyClaudeCodeReserve, so `width` always means "usable cells
|
|
847
850
|
// post-reserve" with no semantic split between wire-supplied and
|
|
848
851
|
// fallback values.
|
|
849
|
-
const
|
|
852
|
+
const hints = parseClientHints(req);
|
|
853
|
+
const termCols = hints.termCols;
|
|
850
854
|
const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
|
|
851
855
|
const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
|
|
852
856
|
// [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
|
|
@@ -926,6 +930,7 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
926
930
|
req.cwd,
|
|
927
931
|
entry.state.neededInputPaths,
|
|
928
932
|
effective,
|
|
933
|
+
hints,
|
|
929
934
|
);
|
|
930
935
|
// [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
|
|
931
936
|
// is derived from the same effective theme resolved above — so a theme
|
|
@@ -47,13 +47,15 @@
|
|
|
47
47
|
},
|
|
48
48
|
|
|
49
49
|
// ── Segments: how each piece of data is drawn ────────────────────────────
|
|
50
|
-
// `bg`/`fg` are palette spec strings
|
|
51
|
-
//
|
|
50
|
+
// `bg`/`fg` are palette spec strings, and either may also be a template
|
|
51
|
+
// that computes a color — `fg: '{{ contrastOn (bgOf) }}'` below picks
|
|
52
|
+
// black or white, whichever is readable against this segment's own
|
|
53
|
+
// background. `when` hides a segment when it evaluates false.
|
|
52
54
|
segments: {
|
|
53
55
|
user: {
|
|
54
56
|
template: ' {{ .user }} ',
|
|
55
57
|
bg: 'primary',
|
|
56
|
-
fg: '
|
|
58
|
+
fg: '{{ contrastOn (bgOf) }}',
|
|
57
59
|
},
|
|
58
60
|
directory: {
|
|
59
61
|
template: ' {{ .here }} ',
|
|
@@ -63,14 +65,14 @@
|
|
|
63
65
|
branch: {
|
|
64
66
|
template: ' {{ .branch }} ',
|
|
65
67
|
bg: 'accent',
|
|
66
|
-
fg: '
|
|
68
|
+
fg: '{{ contrastOn (bgOf) }}',
|
|
67
69
|
when: '{{ ne .branch "" }}', // hidden entirely outside a git repo
|
|
68
70
|
palette: 'gruvbox', // this segment pulls its OWN palette (per-segment switch)
|
|
69
71
|
},
|
|
70
72
|
model: {
|
|
71
73
|
template: ' {{ .model }} ',
|
|
72
74
|
bg: 'secondary',
|
|
73
|
-
fg: '
|
|
75
|
+
fg: '{{ contrastOn (bgOf) }}',
|
|
74
76
|
when: '{{ ne .model "" }}',
|
|
75
77
|
},
|
|
76
78
|
session: {
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
clock: {
|
|
82
84
|
template: ' {{ .clock }} ',
|
|
83
85
|
bg: 'primary',
|
|
84
|
-
fg: '
|
|
86
|
+
fg: '{{ contrastOn (bgOf) }}',
|
|
85
87
|
},
|
|
86
88
|
},
|
|
87
89
|
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,33 @@ function detectTermCols(): number | undefined {
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// The env vars an SSH login shell inherits from sshd. Any one of them present
|
|
40
|
+
// and non-empty means this session arrived over the network.
|
|
41
|
+
//
|
|
42
|
+
// [LAW:one-source-of-truth] This vocabulary is mirrored by the Rust client
|
|
43
|
+
// (rust-client/src/main.rs) and diffed by scripts/check-protocol.mjs, which
|
|
44
|
+
// anchors on the declaration below — keep it a named const holding string
|
|
45
|
+
// literals, or repoint the CHECKS row in the same commit. Both runtimes must
|
|
46
|
+
// agree on what "SSH" means or the fast path and the fallback path would
|
|
47
|
+
// disagree about the same session.
|
|
48
|
+
//
|
|
49
|
+
// All three are checked, not just SSH_CONNECTION: SSH_CLIENT is what older
|
|
50
|
+
// sshd builds (and the user's git-taculous zsh theme) key on, and SSH_TTY is
|
|
51
|
+
// the one that survives some `sudo` env_keep policies. Extra names can only
|
|
52
|
+
// widen recall of a fact that is otherwise reported as a plain `false`.
|
|
53
|
+
const SSH_ENV_VARS = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] as const;
|
|
54
|
+
|
|
55
|
+
// [LAW:dataflow-not-control-flow] A fold over the vocabulary, not a chain of
|
|
56
|
+
// ifs — adding a name is a data edit.
|
|
57
|
+
//
|
|
58
|
+
// Unlike detectTermCols this is TOTAL: the client reads its own environment, so
|
|
59
|
+
// "no SSH var set" is the affirmative answer "local", never a failure to
|
|
60
|
+
// determine. It therefore always reports, and the daemon reads an ABSENT `ssh`
|
|
61
|
+
// hint as "this client is too old to answer" rather than as "local".
|
|
62
|
+
function detectSsh(): boolean {
|
|
63
|
+
return SSH_ENV_VARS.some((name) => (process.env[name] ?? "") !== "");
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
function showHelpText(): void {
|
|
40
67
|
console.log(HELP_TEXT);
|
|
41
68
|
}
|
|
@@ -134,15 +161,18 @@ echo '{"session_id":"test-session","workspace":{"project_dir":"/path/to/project"
|
|
|
134
161
|
// caches). On daemon miss we spawn detached and emit empty output; the
|
|
135
162
|
// next status-line refresh hits the warm daemon and renders for real.
|
|
136
163
|
//
|
|
137
|
-
// [LAW:single-enforcer]
|
|
164
|
+
// [LAW:single-enforcer] Client hints are captured here, in the user's
|
|
138
165
|
// shell environment, then trusted by the daemon. The daemon's own env
|
|
139
|
-
// reflects whichever shell launched it minutes/hours ago, so it can
|
|
140
|
-
// measure the active terminal
|
|
166
|
+
// reflects whichever shell launched it minutes/hours ago, so it can
|
|
167
|
+
// measure neither the active terminal nor whether THIS session came in
|
|
168
|
+
// over SSH — only the live client can. One daemon serves a local session
|
|
169
|
+
// and an SSH session at the same time, so the answer genuinely differs per
|
|
170
|
+
// request.
|
|
141
171
|
const outcome = await tryRenderViaDaemon(
|
|
142
172
|
hookData,
|
|
143
173
|
process.argv,
|
|
144
174
|
process.cwd(),
|
|
145
|
-
detectTermCols(),
|
|
175
|
+
{ termCols: detectTermCols(), ssh: detectSsh() },
|
|
146
176
|
);
|
|
147
177
|
// [LAW:types-are-the-program] Three variants, one per outcome kind. The
|
|
148
178
|
// "kick on every failure" pattern was the load-bearing half of the
|