@kaydash9999/banker-plugins 0.19.5 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OMC HUD custom formatter (wrapper) — v4
4
+ *
5
+ * Runs the canonical OMC HUD (omc-hud.mjs), then reformats its rendered line
6
+ * into a compact pipe-delimited layout requested by the user:
7
+ *
8
+ * <model>/<effort>|5h:<pct>/<reset>|wk:<pct>/<reset>|<mm>:<pct>/<reset>|think|PL:<mb>M|se:<hours>h|ctx:<pct>%|🤖<agents>|🔧<tools>|⚡<skills>|<cwd>|<account>|<branch>|claude<cur>→<new>
9
+ * e.g. Op5.5/high|5h:8%/1h23m|wk:96%/1h23m|Fa:0%/1h23m|think|PL:0.4M|se:0.4h|ctx:12%|🤖1|🔧14|⚡3|...
10
+ *
11
+ * Changes vs v3:
12
+ * - OMC 5.x wraps at the terminal width; the width is hidden from OMC and the
13
+ * wrapper wraps the compact line itself at segment boundaries (no "…").
14
+ * - effort:<level> badge dropped; session -> se:<hours, 1 decimal>h.
15
+ * - ctx:<pct>% comes right after se, without OMC's " COMPRESS?" / " CRITICAL"
16
+ * words (the color shows the level).
17
+ * - OMC's call counts ("🔧14 🤖1 ⚡3") are shown agents, tools, skills, one
18
+ * segment each: 🤖1|🔧14|⚡3. A count OMC leaves out (zero) stays out.
19
+ * - per-model usage badge (fable:0%(1h31m)) -> Fa:0%/1h31m, after the rate windows.
20
+ * - PL:<MB, 1 decimal>M always shown before se. It reads payload-mon's module
21
+ * copy next to this file (hud/payload-mon/payload-size.mjs); without it PL
22
+ * is left out.
23
+ * - cwd group split on "|"; branch shown without "branch:" after the account.
24
+ * - OMC#X.Y.Z label no longer shown, nor OMC's update hint lines
25
+ * ("[!] omc X - paste: ! ...", "[!] claude X - paste: ! claude update").
26
+ * - Claude Code update notice "[Claude#2.1.300] -> 2.1.302 claude update" ->
27
+ * "claude2.1.300→2.1.302", shown last. A claude-update-last block has
28
+ * nothing left to move.
29
+ * - >=85% is pink (256-color 213) instead of red.
30
+ * - Blocks added by `payload-mon on` / `claude-update-last on` are not part of
31
+ * this file; those tools insert them.
32
+ *
33
+ * Why a wrapper: OMC's render.js hardcodes " | " separators, the "[...]" label
34
+ * brackets, the "Model: " prefix, the "percent(time)" rate layout and the
35
+ * "session:Nm" label. None are configurable, so we transform the final text.
36
+ * All data (usage %, reset times, tool counts, cwd) still comes from OMC.
37
+ *
38
+ * Defensive: on any parse failure it prints OMC's raw output unchanged, so the
39
+ * statusline never breaks if OMC's format changes after an update.
40
+ *
41
+ * ─────────────────────────────────────────────────────────────────────────────
42
+ * Changes vs v1 (formatting logic below is byte-identical to v1 on purpose):
43
+ *
44
+ * 1. The canonical HUD runs IN THIS PROCESS instead of via spawnSync, so a
45
+ * statusline refresh costs one node process instead of two. Windows charges
46
+ * kernel paged pool per process creation (fltmgr FMfn name-cache node), and
47
+ * on this machine that accumulation is not reclaimable short of a reboot.
48
+ *
49
+ * 2. Every unbounded wait now has a time budget. v1 could block forever in two
50
+ * places with no timeout: readFileSync(0) waiting for stdin EOF, and
51
+ * spawnSync waiting on the child. Both are SYNCHRONOUS, so a setTimeout
52
+ * watchdog cannot fire while they block — the event loop is not running.
53
+ * A stalled instance never dies, and the next refresh spawns another, so
54
+ * they accumulate linearly. Both are now async and bounded.
55
+ *
56
+ * Timer discipline comes from a 3-OS reproduction documented in
57
+ * .omc/wiki/bounding-a-hanging-dynamic-import-in-node.md:
58
+ * - the budget timer stays REF'd: an unref'd timer never fires when Node bails
59
+ * out of an unsettled top-level await with exit 13,
60
+ * - the self-exit timer is UNREF'd: it must not hold a healthy process open,
61
+ * and when something really is hung the loop is alive so it still fires,
62
+ * - the budget is 10s, not 3s: a tight budget silently discards work that was
63
+ * merely slow, without saving any time (the hung import holds the loop anyway).
64
+ *
65
+ * 🔴 omc-hud.mjs ends with a bare `main();` — no await. Module evaluation
66
+ * therefore completes BEFORE any output is produced. Awaiting the import
67
+ * alone captures nothing (that is why v2 printed "[OMC HUD] no output").
68
+ * We wait for the first complete line instead.
69
+ */
70
+ import { spawnSync } from "node:child_process";
71
+ import { readFileSync } from "node:fs";
72
+ import { homedir } from "node:os";
73
+ import { dirname, join } from "node:path";
74
+ import { Readable } from "node:stream";
75
+ import { fileURLToPath, pathToFileURL } from "node:url";
76
+
77
+ const __dirname = dirname(fileURLToPath(import.meta.url));
78
+ const HUD = join(__dirname, "omc-hud.mjs");
79
+
80
+ const STDIN_TIMEOUT_MS = 2000; // Claude Code pipes the payload immediately
81
+ const RENDER_TIMEOUT_MS = 10000; // see timer discipline note above
82
+ const SPAWN_TIMEOUT_MS = 8000; // fallback path only
83
+ const EXIT_GRACE_MS = 2000;
84
+ const HARD_EXIT_MS = 20000;
85
+
86
+ // Last-resort guard: if any path leaves us alive past this, end voluntarily.
87
+ // unref'd so a healthy render is never held open by it.
88
+ {
89
+ const g = setTimeout(() => {
90
+ try {
91
+ process.stdout.write("\n");
92
+ } catch {
93
+ /* stdout already gone */
94
+ }
95
+ process.exit(0);
96
+ }, HARD_EXIT_MS);
97
+ if (g && g.unref) g.unref();
98
+ }
99
+
100
+ // Print and terminate. The canonical HUD ran in-process, so its own async work
101
+ // may still be pending on the event loop; once our line is out there is nothing
102
+ // left to wait for. The write callback is the flush-safe exit point, and the
103
+ // unref'd timer covers the case where the callback never arrives.
104
+ // Only the first call prints: the fallback path may emit and then fall through
105
+ // to the formatter, whose passthrough catch would print a second line.
106
+ let emitted = false;
107
+ function emit(s) {
108
+ if (emitted) return;
109
+ emitted = true;
110
+ try {
111
+ process.stdout.write(s, () => process.exit(0));
112
+ } catch {
113
+ process.exit(0);
114
+ }
115
+ const k = setTimeout(() => process.exit(0), EXIT_GRACE_MS);
116
+ if (k && k.unref) k.unref();
117
+ }
118
+
119
+ // Currently logged-in Claude account: the part of the email before "@".
120
+ // Read from <config home>/.claude.json (oauthAccount.emailAddress) via a light
121
+ // regex so we don't JSON.parse the whole config every render. Claude Code keeps
122
+ // that file in $CLAUDE_CONFIG_DIR when set, otherwise in the home folder.
123
+ function claudeJsonDir() {
124
+ const v = (process.env.CLAUDE_CONFIG_DIR || "").trim();
125
+ if (!v) return homedir();
126
+ if (v === "~") return homedir();
127
+ if (v.startsWith("~/") || v.startsWith("~\\")) return join(homedir(), v.slice(2));
128
+ return v;
129
+ }
130
+ function loginAccount() {
131
+ try {
132
+ const raw = readFileSync(join(claudeJsonDir(), ".claude.json"), "utf8");
133
+ const m = raw.match(/"emailAddress"\s*:\s*"([^"@]+)@/);
134
+ return m ? m[1] : "";
135
+ } catch {
136
+ return "";
137
+ }
138
+ }
139
+
140
+ // Read the statusline JSON Claude Code pipes on stdin, bounded.
141
+ // A TTY means a human ran this by hand — there is no EOF coming, so skip it
142
+ // entirely rather than hang waiting for one.
143
+ function readStdinBounded(ms) {
144
+ if (process.stdin.isTTY) return Promise.resolve("");
145
+ return new Promise((resolve) => {
146
+ let data = "";
147
+ let done = false;
148
+ const onData = (c) => {
149
+ data += c;
150
+ };
151
+ const finish = () => {
152
+ if (done) return;
153
+ done = true;
154
+ clearTimeout(t);
155
+ process.stdin.removeListener("data", onData);
156
+ process.stdin.removeListener("end", finish);
157
+ process.stdin.removeListener("error", finish);
158
+ try {
159
+ process.stdin.pause();
160
+ } catch {
161
+ /* already closed */
162
+ }
163
+ resolve(data);
164
+ };
165
+ const t = setTimeout(finish, ms); // ref'd on purpose
166
+ try {
167
+ process.stdin.setEncoding("utf8");
168
+ process.stdin.on("data", onData);
169
+ process.stdin.once("end", finish);
170
+ process.stdin.once("error", finish);
171
+ } catch {
172
+ finish();
173
+ }
174
+ });
175
+ }
176
+
177
+ const input = await readStdinBounded(STDIN_TIMEOUT_MS);
178
+
179
+ // Current reasoning effort level (low/medium/high/xhigh) from the statusline
180
+ // payload (effort.level) — shown next to the model. Empty if absent.
181
+ let effort = "";
182
+ try {
183
+ effort = String(JSON.parse(input)?.effort?.level || "");
184
+ } catch {
185
+ /* no/invalid stdin JSON */
186
+ }
187
+
188
+ // Run the canonical HUD in-process, capturing what it writes to stdout.
189
+ // Resolves on the first complete line (see the `main();` note in the header),
190
+ // or when the budget expires — whichever comes first.
191
+ //
192
+ // 🔴 The HUD DOES read stdin — dist/hud/stdin.js readStdin() async-iterates
193
+ // process.stdin (it is not readFileSync(0)). We already drained the real fd,
194
+ // so without replaying the payload the HUD sees empty input and prints its
195
+ // "HUD installed / statusLine configured" info screen instead of a
196
+ // statusline. Swapping process.stdin for a stream carrying the same bytes
197
+ // restores identical behaviour to the spawnSync path, which fed the child
198
+ // through its stdin.
199
+ // What the HUD writes to stdout is collected instead of printed; `onLine` runs
200
+ // once a full line is in.
201
+ function captureStdout(onLine) {
202
+ const origWrite = process.stdout.write.bind(process.stdout);
203
+ let captured = "";
204
+ process.stdout.write = (chunk, enc, cb) => {
205
+ captured += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
206
+ const done = typeof enc === "function" ? enc : cb;
207
+ if (typeof done === "function") done();
208
+ if (captured.includes("\n")) onLine();
209
+ return true;
210
+ };
211
+ return {
212
+ text: () => captured,
213
+ restore: () => {
214
+ process.stdout.write = origWrite;
215
+ },
216
+ };
217
+ }
218
+
219
+ async function renderInProcess(ms) {
220
+ const origStdin = Object.getOwnPropertyDescriptor(process, "stdin");
221
+ let settleFirstLine;
222
+ const firstLine = new Promise((r) => {
223
+ settleFirstLine = r;
224
+ });
225
+
226
+ const replay = Readable.from(input ? [input] : []);
227
+ replay.isTTY = false; // must stay falsy or readStdin() bails out early
228
+ try {
229
+ Object.defineProperty(process, "stdin", { value: replay, configurable: true });
230
+ } catch {
231
+ /* leave the real stdin in place; the fallback path still works */
232
+ }
233
+
234
+ const out = captureStdout(settleFirstLine);
235
+ let budgetTimer;
236
+ const budget = new Promise((r) => {
237
+ budgetTimer = setTimeout(r, ms); // ref'd on purpose
238
+ });
239
+
240
+ try {
241
+ await Promise.race([
242
+ (async () => {
243
+ await import(pathToFileURL(HUD).href);
244
+ await firstLine;
245
+ })(),
246
+ budget,
247
+ ]);
248
+ } finally {
249
+ clearTimeout(budgetTimer);
250
+ out.restore();
251
+ // Restore the real stdin so the spawnSync fallback below behaves normally.
252
+ if (origStdin) {
253
+ try {
254
+ Object.defineProperty(process, "stdin", origStdin);
255
+ } catch {
256
+ /* not restorable; we exit right after anyway */
257
+ }
258
+ }
259
+ }
260
+ return out.text();
261
+ }
262
+
263
+ // OMC 5.x wraps its line at the terminal width (index.js: stderr/stdout
264
+ // columns, then $COLUMNS). Wrapped tails land below the OMC# line, where this
265
+ // wrapper passes them through raw ("session:9m | ctx:12% | 🔧12"). The wrapper
266
+ // does its own compaction, so hide the width and let OMC emit one line.
267
+ // The width is read first: the wrapper wraps the compact line itself at the
268
+ // end, so Claude Code does not cut the tail off with "…".
269
+ const TERM_COLS =
270
+ process.stderr.columns || process.stdout.columns || parseInt(process.env.COLUMNS ?? "0", 10) || 0;
271
+ delete process.env.COLUMNS; // also keeps the spawnSync fallback unwrapped
272
+ for (const s of [process.stdout, process.stderr]) {
273
+ try {
274
+ Object.defineProperty(s, "columns", { value: undefined, configurable: true });
275
+ } catch {
276
+ /* not overridable; OMC may wrap and the tail stays below */
277
+ }
278
+ }
279
+
280
+ let raw = "";
281
+ try {
282
+ raw = (await renderInProcess(RENDER_TIMEOUT_MS)).replace(/\r/g, "");
283
+ } catch {
284
+ raw = ""; // fall through to the child-process path
285
+ }
286
+
287
+ if (!raw.trim()) {
288
+ // In-process rendering produced nothing usable. Fall back to the original
289
+ // out-of-process path: it costs an extra node, but a statusline that renders
290
+ // beats one that does not — e.g. if a future OMC version stops being
291
+ // import-safe. Bounded, unlike v1.
292
+ const res = spawnSync(process.execPath, [HUD], {
293
+ input,
294
+ encoding: "utf8",
295
+ timeout: SPAWN_TIMEOUT_MS,
296
+ killSignal: "SIGKILL",
297
+ maxBuffer: 4 * 1024 * 1024,
298
+ });
299
+ raw = (res.stdout || "").replace(/\r/g, "");
300
+ if (!raw.trim()) {
301
+ emit(((res.stderr || "").trim() || "[OMC HUD] no output") + "\n");
302
+ }
303
+ }
304
+
305
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
306
+
307
+ // Terminal columns of a string: ANSI is free, CJK and emoji take two columns,
308
+ // variation selectors and ZWJ take none.
309
+ const WRAP_MARGIN = 4;
310
+ // BMP emoji drawn two columns wide (East Asian Width W), e.g. ⚡ in OMC's call counts.
311
+ const WIDE_BMP_EMOJI = [
312
+ [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
313
+ [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
314
+ [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
315
+ [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
316
+ [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd],
317
+ [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
318
+ [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
319
+ [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50],
320
+ [0x2b55, 0x2b55],
321
+ ];
322
+ // Every two-column range: Hangul Jamo, the emoji above, CJK, Hangul syllables, CJK compatibility and
323
+ // forms, fullwidth forms, the emoji planes and the supplementary ideographs.
324
+ const WIDE_RANGES = [
325
+ [0x1100, 0x115f], ...WIDE_BMP_EMOJI, [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f],
326
+ [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff], [0x20000, 0x10ffff],
327
+ ];
328
+ const isWide = (c) => WIDE_RANGES.some(([lo, hi]) => c >= lo && c <= hi);
329
+ const visibleWidth = (s) => {
330
+ let w = 0;
331
+ for (const ch of stripAnsi(s)) {
332
+ const c = ch.codePointAt(0);
333
+ if (c === 0x200d || (c >= 0xfe00 && c <= 0xfe0f)) continue;
334
+ w += isWide(c) ? 2 : 1;
335
+ }
336
+ return w;
337
+ };
338
+
339
+ // Color helpers — we strip OMC's ANSI to parse, then re-apply our own so the
340
+ // statusline is highlighted. Threshold scale (usage/context %): green<70,
341
+ // yellow<85, pink>=85 (pink replaces red on request). Pipes and path are
342
+ // dimmed; model name cyan, effort yellow.
343
+ const A = (c, s) => `\x1b[${c}m${s}\x1b[0m`;
344
+ const SEP = "\x1b[2m|\x1b[0m";
345
+ const PINK = "38;5;213";
346
+ const pc = (n) => (n >= 85 ? PINK : n >= 70 ? "33" : "32");
347
+ const colorRate = (seg) => {
348
+ const m = seg.match(/^(\w+):(\d+)%\/(.+)$/);
349
+ return m ? `${m[1]}:${A(pc(+m[2]), m[2] + "%")}\x1b[2m/\x1b[0m${A("36", m[3])}` : seg;
350
+ };
351
+ const colorCtx = (seg) => {
352
+ const m = seg.match(/^ctx:(\d+)%$/);
353
+ return m ? `ctx:${A(pc(+m[1]), m[1] + "%")}` : seg;
354
+ };
355
+ const colorModel = (seg) => {
356
+ const m = seg.match(/^(.+?)\/(\w+)$/);
357
+ return m ? `${A("36", m[1])}\x1b[2m/\x1b[0m${A("33", m[2])}` : A("36", seg);
358
+ };
359
+ const colorSe = (seg) => {
360
+ const m = seg.match(/^se:(.+)$/);
361
+ return m ? `se:${A("33", m[1])}` : seg;
362
+ };
363
+ // Per-model usage badge ("fable:0%") rides along in the rate group and has no
364
+ // dedicated field. Shorten the model name to two letters so it matches the
365
+ // model token's own convention (Opus 4.8 -> Op4.8): fable:0% -> Fa:0%.
366
+ // OMC appends a stale marker and/or a reset time to scoped weekly buckets
367
+ // (renderRateLimits in dist/hud/elements/limits.js), so the pattern must not
368
+ // anchor at "%": keep that tail intact -> fable:0%(3d3h) -> Fa:0%(3d3h).
369
+ const shortModelPct = (seg) => {
370
+ const m = seg.match(/^([A-Za-z][A-Za-z0-9.-]*):(\d+%\*?(?:\([^)]*\))?)$/);
371
+ return m ? `${m[1][0].toUpperCase()}${m[1].slice(1, 2).toLowerCase()}:${m[2]}` : seg;
372
+ };
373
+
374
+ try {
375
+ const lines = raw.split("\n").map(stripAnsi).filter((l) => l.length);
376
+ const mainIdx = lines.findIndex((l) => l.includes("OMC#"));
377
+ if (mainIdx === -1) throw new Error("no OMC line");
378
+
379
+ const main = lines[mainIdx];
380
+ const pathLine = lines.slice(0, mainIdx).join(" ").trim(); // cwd (+git) group above
381
+ // Detail lines (agent tree, warnings) are kept as-is, except OMC's update
382
+ // hints ("[!] omc 5.7.5 - paste: ! claude plugin ...", "[!] claude 2.1.302 -
383
+ // paste: ! claude update"): the OMC version is not wanted here, and the Claude
384
+ // Code update already shows as claude<cur>→<new> on the main line.
385
+ const UPDATE_HINT = /^\[!\] (?:omc|claude) \S+ - paste: /;
386
+ const below = lines.slice(mainIdx + 1).filter((l) => !UPDATE_HINT.test(l.trim()));
387
+
388
+ // Collapse " | " -> "|", then split into segments.
389
+ const rawSegs = main
390
+ .replace(/\s*\|\s*/g, "|")
391
+ .split("|")
392
+ .map((s) => s.trim())
393
+ .filter(Boolean);
394
+
395
+ // The 5h/wk/sn rate windows live in one space-separated segment; expand them.
396
+ // The per-model usage badge ("fable:0%") arrives inside that same segment, so
397
+ // remember which pieces came from there. Only those get abbreviated later —
398
+ // unrelated badges that also land in `extra` must be left alone.
399
+ const segs = [];
400
+ const rateGroup = new Set();
401
+ // OMC's Claude Code update notice ("[Claude#2.1.300] -> 2.1.302 claude update",
402
+ // render.js claudeLabel) is shortened to "claude2.1.300→2.1.302" and shown last.
403
+ // It is taken out of segs, so a claude-update-last block (which looks for the
404
+ // long form in segs) finds nothing to move.
405
+ const UPDATE_NOTICE = /^\[Claude(?:#([^\]]*))?\]\s*->\s*(\S+)\s+claude\s+update\b/;
406
+ let update = "";
407
+ for (const s of rawSegs) {
408
+ const um = s.match(UPDATE_NOTICE);
409
+ if (um) {
410
+ update = `claude${um[1] || ""}→${um[2]}`;
411
+ } else if (/\b(5h|wk|sn):/.test(s) && /\s/.test(s)) {
412
+ for (const p of s.split(/\s+/)) {
413
+ if (!p) continue;
414
+ segs.push(p);
415
+ if (!/^(5h|wk|sn):/.test(p)) rateGroup.add(p);
416
+ }
417
+ } else {
418
+ segs.push(s);
419
+ }
420
+ }
421
+
422
+ // Without effort.level on stdin, take the level from OMC's effort badge.
423
+ if (!effort) {
424
+ const badge = segs.find((s) => /^effort:\S/.test(s));
425
+ if (badge) effort = badge.slice("effort:".length).trim();
426
+ }
427
+
428
+ const f = { label: "", model: "", r5: "", rwk: "", rsn: "", think: "", ctx: "", se: "", counts: "" };
429
+ let ctxShown = ""; // the ctx segment shown after se; f.ctx stays empty (see the ctx line below)
430
+ const extra = [];
431
+ const modelRates = []; // per-model usage badges, shown right after the rate windows
432
+ const slash = (s) => s.replace(/%\(([^)]+)\)/, "%/$1"); // 6%(3h56m) -> 6%/3h56m
433
+
434
+ for (const s of segs) {
435
+ if (/^\[?OMC#/.test(s)) {
436
+ const m = s.match(/OMC#([0-9][0-9.]*)/);
437
+ f.label = m ? `OMC#${m[1]}` : s; // drop brackets + trailing "L"
438
+ } else if (/^Model:/.test(s) || /^(Opus|Sonnet|Haiku)\b/.test(s)) {
439
+ const m = s.match(/(?:Model:\s*)?([A-Za-z]+)\s*([0-9][0-9.]*)/);
440
+ // Opus 4.8 -> Op4.8/high; an unknown id (Model: claude-x-5) keeps its name.
441
+ const name = m ? `${m[1].slice(0, 2)}${m[2]}` : s.replace(/^Model:\s*/, "");
442
+ f.model = `${name}${effort ? "/" + effort : ""}`;
443
+ } else if (/^5h:/.test(s)) {
444
+ f.r5 = slash(s);
445
+ } else if (/^wk:/.test(s)) {
446
+ f.rwk = slash(s);
447
+ } else if (/^sn:/.test(s)) {
448
+ f.rsn = slash(s);
449
+ } else if (/^think/i.test(s)) {
450
+ f.think = "think";
451
+ } else if (/^effort:/.test(s)) {
452
+ // OMC 5.x effort badge; the level already rides on the model (Op5.5/high).
453
+ } else if (/^ctx:/.test(s)) {
454
+ // "ctx:82% COMPRESS?" -> "ctx:82%": the color already says how full it is.
455
+ const m = s.match(/^ctx:(\d+)%/);
456
+ ctxShown = m ? `ctx:${m[1]}%` : s;
457
+ } else if (/^session:/.test(s)) {
458
+ const m = s.match(/session:(\d+)m/);
459
+ f.se = m ? `se:${(parseInt(m[1], 10) / 60).toFixed(1)}h` : s.replace(/^session:/, "se:");
460
+ } else if (/🔧|🤖|⚡/.test(s) || /^[TAS]:\d/.test(s)) {
461
+ f.counts = s;
462
+ } else {
463
+ if (rateGroup.has(s)) modelRates.push(slash(shortModelPct(s))); // fable:0%(1h31m) -> Fa:0%/1h31m
464
+ else extra.push(s); // dynamic badges: todos, ralph, autopilot, bg, skill, etc.
465
+ }
466
+ }
467
+
468
+ const colored = [];
469
+ if (f.model) colored.push(colorModel(f.model));
470
+ if (f.r5) colored.push(colorRate(f.r5));
471
+ if (f.rwk) colored.push(colorRate(f.rwk));
472
+ if (f.rsn) colored.push(colorRate(f.rsn));
473
+ for (const r of modelRates) colored.push(colorRate(r));
474
+ if (f.think) colored.push(A("36", f.think));
475
+ // Keep the next line: f.ctx is always empty now (ctx is shown after se), but
476
+ // `payload-mon on` inserts its block right after this exact line, ahead of PL.
477
+ if (f.ctx) colored.push(colorCtx(f.ctx));
478
+ // PL: payload size always shown before se, as "PL:0.3M" (MB, one decimal),
479
+ // colored by its share of the 32MB limit. Replaces the payload-mon segment,
480
+ // which only appears from 8MB on. Reuses payload-mon's module copy and cache;
481
+ // without it (payload-mon off) or without a transcript, PL is left out.
482
+ try {
483
+ const transcript = JSON.parse(input)?.transcript_path;
484
+ if (transcript) {
485
+ const { estimatePayloadBytes, REQUEST_LIMIT_BYTES } = await import(pathToFileURL(join(__dirname, "payload-mon", "payload-size.mjs")).href);
486
+ const bytes = estimatePayloadBytes(transcript);
487
+ if (Number.isFinite(bytes)) {
488
+ for (let i = colored.length - 1; i >= 0; i--) if (/^payload:/.test(stripAnsi(colored[i]))) colored.splice(i, 1);
489
+ const pct = Math.floor((bytes / REQUEST_LIMIT_BYTES) * 100);
490
+ colored.push(`PL:${A(pc(pct), (bytes / (1024 * 1024)).toFixed(1) + "M")}`);
491
+ }
492
+ }
493
+ } catch {
494
+ /* no stdin JSON, unreadable transcript or module missing */
495
+ }
496
+ if (f.se) colored.push(colorSe(f.se));
497
+ if (ctxShown) colored.push(colorCtx(ctxShown));
498
+ // OMC prints "🔧14 🤖1 ⚡3" ("T:14 A:1 S:3" in ASCII) and leaves out a zero count.
499
+ // Show agents, tools, skills, one segment each; anything else keeps its place after them.
500
+ if (f.counts) {
501
+ const rank = (p) => (/^(?:🤖|A:)/u.test(p) ? 0 : /^(?:🔧|T:)/u.test(p) ? 1 : /^(?:⚡|S:)/u.test(p) ? 2 : 3);
502
+ colored.push(...f.counts.trim().split(/\s+/).sort((a, b) => rank(a) - rank(b)));
503
+ }
504
+ for (const e of extra) colored.push(A("2", e));
505
+ // OMC's cwd group is "cwd | branch:x | ...": keep the cwd parts here with "|",
506
+ // and move the branch (without its "branch:" label) right before OMC#.
507
+ let branch = "";
508
+ const pathParts = pathLine
509
+ .split(/\s*\|\s*/)
510
+ .map((p) => p.trim())
511
+ .filter((p) => {
512
+ const m = p.match(/^branch:\s*(.+)$/);
513
+ if (m) branch = m[1];
514
+ return p && !m;
515
+ });
516
+ for (const p of pathParts) colored.push(A("36", p));
517
+ const acct = loginAccount();
518
+ if (acct) colored.push(A("92", acct));
519
+ if (branch) colored.push(A("36", branch));
520
+ // The OMC#X.Y.Z label is parsed (it marks OMC's main line) but not shown.
521
+ if (update) colored.push(A("2", update));
522
+
523
+ let result = colored.join(SEP);
524
+ // Wrap at segment boundaries instead of letting Claude Code truncate the tail.
525
+ // WRAP_MARGIN leaves room for the indent Claude Code puts before the line.
526
+ if (TERM_COLS > WRAP_MARGIN + 20) {
527
+ const max = TERM_COLS - WRAP_MARGIN;
528
+ const rows = [];
529
+ let row = "";
530
+ for (const seg of colored) {
531
+ if (row && visibleWidth(row) + 1 + visibleWidth(seg) > max) {
532
+ rows.push(row);
533
+ row = seg;
534
+ } else {
535
+ row = row ? row + SEP + seg : seg;
536
+ }
537
+ }
538
+ if (row) rows.push(row);
539
+ result = rows.join("\n");
540
+ }
541
+ if (below.length) result += "\n" + below.join("\n");
542
+ emit(result + "\n");
543
+ } catch {
544
+ emit(raw.endsWith("\n") ? raw : raw + "\n"); // safe passthrough
545
+ }
@@ -28,7 +28,7 @@
28
28
  import { spawnSync } from "node:child_process";
29
29
  import { accessSync, constants, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
30
  import { homedir, tmpdir } from "node:os";
31
- import { join } from "node:path";
31
+ import { join, resolve } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
34
  const OPEN = "// >>> claude-update-last >>>";
@@ -42,7 +42,16 @@ const JOIN_LINE = /^ {2}let result = colored\.join\(SEP\);\r?$/gm;
42
42
  // What the block reads at that line, all in the wrapper's own scope there.
43
43
  const NAMES = [/\bconst segs\b/, /\bconst colored\b/, /\bconst stripAnsi\b/, /\bconst A = /];
44
44
 
45
- const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
45
+ // The same rules as ../assets/apply-hud.mjs (OMC's getClaudeConfigDir): trim, expand a leading "~",
46
+ // make it absolute, drop a trailing separator, so this finds the wrapper the installer wrote.
47
+ function configDir() {
48
+ const v = (process.env.CLAUDE_CONFIG_DIR || "").trim();
49
+ if (!v) return join(homedir(), ".claude");
50
+ if (v === "~") return homedir();
51
+ if (v.startsWith("~/") || v.startsWith("~\\")) return join(homedir(), v.slice(2));
52
+ return resolve(v);
53
+ }
54
+ const CONFIG_DIR = configDir().replace(/[\\/]+$/, "");
46
55
 
47
56
  const DEFAULTS = {
48
57
  hudFile: process.env.CLAUDE_UPDATE_LAST_HUD_FILE || join(CONFIG_DIR, "hud", "omc-hud-custom.mjs"),