agent-harness-kit 0.10.1 → 0.11.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.
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.10.1"
14
+ "ref": "v0.11.0"
15
15
  },
16
- "version": "0.10.1",
16
+ "version": "0.11.0",
17
17
  "description": "Solo-dev harness engineering kit — layered architecture, GC ritual, structural tests, review subagents.",
18
18
  "category": "development",
19
19
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Solo-dev harness engineering kit — layered architecture, garbage-collection ritual, structural tests, review subagents. Optimized for Claude Code 2.1+.",
5
5
  "author": {
6
6
  "name": "Tuan Le"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Solo-dev harness engineering kit for Claude Code. Layered architecture, structural tests, garbage-collection ritual, review subagents — without the enterprise overhead.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -288,10 +288,14 @@ function sha256(buf) {
288
288
  }
289
289
 
290
290
  // Inject a statusLine block into .claude/settings.json. Idempotent: if the
291
- // existing statusLine already references the kit's script, leave it; otherwise
292
- // set it to invoke scripts/statusline.mjs via node. Doesn't clobber a
291
+ // existing statusLine already references the kit's script with the desired
292
+ // padding + refreshInterval, leave it; otherwise update. Doesn't clobber a
293
293
  // user-customised type:"command" entry that points at a different command.
294
294
  //
295
+ // padding/refreshInterval are sourced from harness.config.json#statusline
296
+ // (with defaults) so a user can tune through one config file and the merge
297
+ // keeps settings.json in sync.
298
+ //
295
299
  // Returns {changed, rawContent} for the lockfile bookkeeping (mirrors the
296
300
  // mergeHooksIntoSettings contract).
297
301
  export async function mergeStatusLineIntoSettings(cwd) {
@@ -310,9 +314,31 @@ export async function mergeStatusLineIntoSettings(cwd) {
310
314
  );
311
315
  }
312
316
  }
317
+
318
+ // Read padding + refreshInterval from harness.config.json#statusline if
319
+ // present; otherwise V4 defaults (padding 1, refresh 2s for live updates
320
+ // during long-running turns).
321
+ let padding = 1;
322
+ let refreshInterval = 2;
323
+ const cfgPath = resolve(cwd, "harness.config.json");
324
+ if (existsSync(cfgPath)) {
325
+ try {
326
+ const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
327
+ const sl = cfg?.statusline;
328
+ if (sl && typeof sl === "object") {
329
+ if (typeof sl.padding === "number" && sl.padding >= 0) padding = sl.padding;
330
+ if (typeof sl.refreshInterval === "number" && sl.refreshInterval >= 1) {
331
+ refreshInterval = sl.refreshInterval;
332
+ }
333
+ }
334
+ } catch { /* malformed config → use defaults */ }
335
+ }
336
+
313
337
  const desired = {
314
338
  type: "command",
315
339
  command: "node scripts/statusline.mjs",
340
+ padding,
341
+ refreshInterval,
316
342
  };
317
343
  // Preserve a user-customised entry if it already points elsewhere. We only
318
344
  // inject when statusLine is absent OR explicitly references our script.
@@ -330,7 +356,9 @@ export async function mergeStatusLineIntoSettings(cwd) {
330
356
  cur &&
331
357
  typeof cur === "object" &&
332
358
  cur.type === desired.type &&
333
- cur.command === desired.command
359
+ cur.command === desired.command &&
360
+ cur.padding === desired.padding &&
361
+ cur.refreshInterval === desired.refreshInterval
334
362
  ) {
335
363
  return { changed: false, rawContent: Buffer.from(raw) };
336
364
  }
@@ -60,6 +60,10 @@ Do **NOT** use for:
60
60
  - Converts MD → HTML (self-rolled subset: headings, lists, code blocks,
61
61
  tables, blockquotes, links, inline formatting — no npm dependency).
62
62
  - Writes `<slug>.html` at the path you pass.
63
+ - **Auto-opens** the file in the default browser (`open`/`xdg-open`/`start`).
64
+ Suppress with `--no-open`, or by setting `AHK_DISABLE_HTML_OPEN=1` /
65
+ `CI=true` in the environment. Open failures (missing binary, headless
66
+ box) never fail the deliverable.
63
67
 
64
68
  5. **Print the deliverable contract** (the script already does this — copy it
65
69
  into your response):
@@ -69,7 +73,7 @@ Do **NOT** use for:
69
73
  **File:** <path> (<size>)
70
74
  **Template:** decision-doc | audit-report | status-report
71
75
  **Lang:** vi | en
72
- **Open:** `open <slug>.html` (macOS) / `xdg-open <slug>.html` (linux)
76
+ **Open:** auto-opened (or fallback hint if --no-open / CI=true)
73
77
  ```
74
78
 
75
79
  ## Output contract
@@ -59,6 +59,10 @@ Trigger keyword từ user (tiếng Việt / English):
59
59
  - Convert MD → HTML (self-rolled subset: heading, list, code block, table,
60
60
  blockquote, link, inline format — không cần npm dependency).
61
61
  - Ghi `<slug>.html` tại path bạn truyền.
62
+ - **Tự mở** file trong browser mặc định (`open`/`xdg-open`/`start`).
63
+ Tắt bằng `--no-open`, hoặc set `AHK_DISABLE_HTML_OPEN=1` /
64
+ `CI=true` trong env. Lỗi mở (thiếu binary, headless) không làm
65
+ fail deliverable.
62
66
 
63
67
  5. **In deliverable contract** (script tự in — bạn copy vào response):
64
68
 
@@ -67,7 +71,7 @@ Trigger keyword từ user (tiếng Việt / English):
67
71
  **File:** <path> (<size>)
68
72
  **Template:** decision-doc | audit-report | status-report
69
73
  **Lang:** vi | en
70
- **Open:** `open <slug>.html` (macOS) / `xdg-open <slug>.html` (linux)
74
+ **Open:** auto-opened (hoặc fallback hint khi --no-open / CI=true)
71
75
  ```
72
76
 
73
77
  ## Output contract
@@ -0,0 +1,57 @@
1
+ // statusline-cache.mjs — tiny file-based memo for statusLine segments.
2
+ //
3
+ // Why this exists: Claude Code re-spawns the statusLine command on every
4
+ // refresh, so in-process memoization is useless — each invocation is a
5
+ // fresh node process. File-based cache keyed on `session_id` (stable per
6
+ // Claude Code session) is the documented pattern.
7
+ //
8
+ // The cache lives under $TMPDIR. Each key gets a separate file with mtime
9
+ // as the freshness signal. Reads bypass the file when stale; writes are
10
+ // best-effort (failure to write = next call recomputes, no error surfaced).
11
+ //
12
+ // Usage:
13
+ // import { cached } from "./statusline-cache.mjs";
14
+ // const branch = cached(
15
+ // { sessionId, key: "git-branch", ttlMs: 5000 },
16
+ // () => spawnSync("git", ["branch", "--show-current"], ...).stdout.trim(),
17
+ // );
18
+
19
+ import { readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const CACHE_DIR = join(tmpdir(), "ahk-statusline");
24
+
25
+ function ensureDir() {
26
+ try { mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* exists */ }
27
+ }
28
+
29
+ function cachePath(sessionId, key) {
30
+ // session_id can contain anything → sanitize. No path separator survives.
31
+ const safeSession = String(sessionId || "no-session").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
32
+ const safeKey = String(key).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32);
33
+ return join(CACHE_DIR, `${safeSession}-${safeKey}.cache`);
34
+ }
35
+
36
+ // Synchronous because statusline.mjs runs as a one-shot command and the
37
+ // upstream caller blocks on its output anyway. async would add no value.
38
+ export function cached({ sessionId, key, ttlMs }, fetchFn) {
39
+ ensureDir();
40
+ const file = cachePath(sessionId, key);
41
+ try {
42
+ const st = statSync(file);
43
+ if (Date.now() - st.mtimeMs < ttlMs) {
44
+ return readFileSync(file, "utf8");
45
+ }
46
+ } catch { /* miss */ }
47
+ let value;
48
+ try {
49
+ value = fetchFn();
50
+ } catch {
51
+ value = "";
52
+ }
53
+ if (value == null) value = "";
54
+ const s = String(value);
55
+ try { writeFileSync(file, s); } catch { /* best-effort */ }
56
+ return s;
57
+ }
@@ -1,14 +1,47 @@
1
1
  #!/usr/bin/env node
2
- // statusLine — single compact line into Claude Code's TUI status bar.
3
- // Reads stdin (Claude Code payload), augments with kit state, emits to
4
- // stdout. Failure mode: print nothing rather than crash.
2
+ // statusLine — "Two-line Dashboard" (V4).
3
+ //
4
+ // LINE 1 vitals (always emitted when any segment resolves):
5
+ // ▶ Opus│terse│⏱1h12m main(±3) feat:health-endpoint ▓▓▓▓░ 42% $0.83 +156/-23
6
+ //
7
+ // LINE 2 — alerts (only when ≥1 trigger fires; otherwise omitted):
8
+ // ⚠ >200K — auto-compact next msg ⚠ ctx 84% ⏳ 5h limit 78%, resets in 1h12m 🚫 last-block: <title>
9
+ //
10
+ // Payload (Claude Code v2.1.132+ schema):
11
+ // model.display_name, output_style.name, session_id, version,
12
+ // cost.{total_cost_usd, total_duration_ms, total_lines_added, total_lines_removed},
13
+ // context_window.{used_percentage, context_window_size, total_input_tokens},
14
+ // exceeds_200k_tokens, rate_limits.five_hour.{used_percentage, resets_at}
15
+ //
16
+ // Behaviour gates:
17
+ // - NO_COLOR env or non-TTY → ANSI escapes stripped, plain text only.
18
+ // - harness.config.json#statusline.compact = true → line 2 dropped.
19
+ // - harness.config.json#statusline.{lang,showLines,showRateLimit,showLastBlock}
20
+ // toggle individual segments. Defaults: full features, lang from
21
+ // claudeMd.humanLanguage.
22
+ //
23
+ // Caching:
24
+ // - Git branch / dirty count cached 5s per session_id.
25
+ // - feature_list.json cached 30s.
26
+ // - telemetry tail cached 10s.
27
+ // - harness.config.json cached 60s.
28
+ //
29
+ // Failure mode: print nothing rather than crash. The TUI never breaks
30
+ // because of a statusline bug.
5
31
 
6
32
  import { readFileSync, existsSync } from "node:fs";
7
33
  import { resolve } from "node:path";
8
34
  import { spawnSync } from "node:child_process";
35
+ import { cached } from "./_lib/statusline-cache.mjs";
9
36
 
10
37
  const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
38
+ const NO_COLOR =
39
+ process.env.NO_COLOR != null && process.env.NO_COLOR !== "" ||
40
+ process.env.AHK_STATUSLINE_NO_COLOR === "1";
11
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Tiny helpers.
44
+ // ---------------------------------------------------------------------------
12
45
  function safeRead(rel) {
13
46
  try { return readFileSync(resolve(CWD, rel), "utf8"); }
14
47
  catch { return null; }
@@ -21,43 +54,301 @@ function safeJSON(rel) {
21
54
  function readStdinSync() {
22
55
  try { return readFileSync(0, "utf8"); } catch { return ""; }
23
56
  }
57
+ function num(x, def = 0) {
58
+ return typeof x === "number" && Number.isFinite(x) ? x : def;
59
+ }
24
60
 
25
- function pieces() {
26
- const out = [];
27
- const lock = safeJSON(".harness/installed.json");
28
- if (lock?.version) out.push(`{kit-v${lock.version}}`);
29
-
30
- const features = safeJSON("feature_list.json");
31
- if (features?.features && Array.isArray(features.features)) {
32
- const open = features.features.find((f) => f.passes === false);
33
- out.push(open ? `feat:${open.id}` : "feat:clean");
34
- }
35
-
36
- try {
37
- const br = spawnSync("git", ["branch", "--show-current"], {
38
- cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
39
- });
40
- const status = spawnSync("git", ["status", "--short"], {
41
- cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
42
- });
43
- if (br.status === 0 && br.stdout.trim()) {
44
- const dirty = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
45
- out.push(dirty > 0 ? `${br.stdout.trim()}(±${dirty})` : br.stdout.trim());
61
+ // ANSI wrappers. When NO_COLOR is set, return the bare string — every caller
62
+ // produces uncoloured output without branching.
63
+ const RESET = NO_COLOR ? "" : "\x1b[0m";
64
+ function c(code, s) {
65
+ if (NO_COLOR || !s) return s ?? "";
66
+ return `\x1b[${code}m${s}${RESET}`;
67
+ }
68
+ const cyan = (s) => c("36", s);
69
+ const green = (s) => c("32", s);
70
+ const yellow = (s) => c("33", s);
71
+ const red = (s) => c("31", s);
72
+ const magenta = (s) => c("35", s);
73
+ const dim = (s) => c("2", s);
74
+ const dimGreen = (s) => c("2;32", s);
75
+ const dimRed = (s) => c("2;31", s);
76
+
77
+ // Color the context bar gradient by percentage band.
78
+ function ctxColor(pct) {
79
+ if (pct >= 80) return red;
80
+ if (pct >= 50) return yellow;
81
+ return cyan;
82
+ }
83
+
84
+ // Color the cost by tier — under $1 dim, $1–5 default, $5+ yellow.
85
+ function costStr(usd) {
86
+ const v = num(usd, 0);
87
+ const str = "$" + (v < 1 ? v.toFixed(2) : v < 10 ? v.toFixed(2) : v.toFixed(1));
88
+ if (v < 1) return dim(str);
89
+ if (v < 5) return str;
90
+ return yellow(str);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Config & locale.
95
+ // ---------------------------------------------------------------------------
96
+ const DEFAULT_CONFIG = {
97
+ compact: false,
98
+ lang: null,
99
+ showLines: true,
100
+ showRateLimit: true,
101
+ showLastBlock: true,
102
+ };
103
+
104
+ function readConfig(sessionId) {
105
+ const raw = cached(
106
+ { sessionId, key: "config", ttlMs: 60_000 },
107
+ () => safeRead("harness.config.json") ?? "",
108
+ );
109
+ if (!raw) return { config: DEFAULT_CONFIG, humanLanguage: "en" };
110
+ let parsed;
111
+ try { parsed = JSON.parse(raw); } catch { return { config: DEFAULT_CONFIG, humanLanguage: "en" }; }
112
+ const config = { ...DEFAULT_CONFIG, ...(parsed?.statusline ?? {}) };
113
+ const humanLanguage = parsed?.claudeMd?.humanLanguage || "en";
114
+ return { config, humanLanguage };
115
+ }
116
+
117
+ const STRINGS = {
118
+ en: {
119
+ compact_soon: " — /compact soon",
120
+ over_200k: ">200K — auto-compact next msg",
121
+ rate_resets: ", resets in ",
122
+ last_block: "last-block: ",
123
+ },
124
+ vi: {
125
+ compact_soon: " — /compact sắp tới",
126
+ over_200k: ">200K — sắp auto-compact",
127
+ rate_resets: ", reset trong ",
128
+ last_block: "vừa block: ",
129
+ },
130
+ };
131
+
132
+ function pickLang(config, humanLanguage) {
133
+ const lang = config.lang || humanLanguage;
134
+ return STRINGS[lang] ? lang : "en";
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Segment data fetchers (cached).
139
+ // ---------------------------------------------------------------------------
140
+ function fetchGit(sessionId) {
141
+ const raw = cached(
142
+ { sessionId, key: "git", ttlMs: 5_000 },
143
+ () => {
144
+ const br = spawnSync("git", ["branch", "--show-current"], {
145
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 500,
146
+ });
147
+ if (br.status !== 0 || !br.stdout) return "";
148
+ const branch = br.stdout.trim();
149
+ if (!branch) return "";
150
+ const st = spawnSync("git", ["status", "--short"], {
151
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 500,
152
+ });
153
+ const dirty = st.stdout ? st.stdout.split("\n").filter(Boolean).length : 0;
154
+ // Conflict marker check — short and cheap on already-fetched output.
155
+ const conflict = /^(UU|AA|DD)/m.test(st.stdout || "");
156
+ return JSON.stringify({ branch, dirty, conflict });
157
+ },
158
+ );
159
+ if (!raw) return null;
160
+ try { return JSON.parse(raw); } catch { return null; }
161
+ }
162
+
163
+ function fetchFeature(sessionId) {
164
+ const raw = cached(
165
+ { sessionId, key: "feat", ttlMs: 30_000 },
166
+ () => safeRead("feature_list.json") ?? "",
167
+ );
168
+ if (!raw) return null;
169
+ let features;
170
+ try { features = JSON.parse(raw); } catch { return null; }
171
+ if (!features?.features || !Array.isArray(features.features)) return null;
172
+ const open = features.features.find((f) => f.passes === false);
173
+ return { open: open?.id ?? null, clean: !open };
174
+ }
175
+
176
+ // Returns the most recent Notification record from .harness/telemetry.jsonl
177
+ // (the file notify-on-block.sh writes a record to on every Notification
178
+ // hook firing). Returns {ts, title} if found within the last 5 min, else
179
+ // null. Caching avoids re-reading the JSONL on every refresh.
180
+ function fetchLastBlock(sessionId) {
181
+ const raw = cached(
182
+ { sessionId, key: "tele", ttlMs: 10_000 },
183
+ () => {
184
+ const body = safeRead(".harness/telemetry.jsonl");
185
+ if (!body) return "";
186
+ const lines = body.split("\n").filter(Boolean);
187
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 50; i--) {
188
+ try {
189
+ const rec = JSON.parse(lines[i]);
190
+ if (rec.hook === "Notification") {
191
+ return JSON.stringify({ ts: rec.ts, title: rec.title || rec.body || "" });
192
+ }
193
+ } catch { /* skip malformed */ }
194
+ }
195
+ return "";
196
+ },
197
+ );
198
+ if (!raw) return null;
199
+ let rec;
200
+ try { rec = JSON.parse(raw); } catch { return null; }
201
+ if (!rec?.ts) return null;
202
+ const ageMs = Date.now() - new Date(rec.ts).getTime();
203
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 5 * 60_000) return null;
204
+ return rec;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Format helpers.
209
+ // ---------------------------------------------------------------------------
210
+ function fmtDuration(ms) {
211
+ const s = Math.floor(num(ms, 0) / 1000);
212
+ if (s < 60) return `${s}s`;
213
+ const m = Math.floor(s / 60);
214
+ if (m < 60) return `${m}m`;
215
+ const h = Math.floor(m / 60);
216
+ const rm = m % 60;
217
+ return `${h}h${String(rm).padStart(2, "0")}m`;
218
+ }
219
+
220
+ function fmtCountdown(epochSeconds) {
221
+ const target = Number(epochSeconds) * 1000;
222
+ if (!Number.isFinite(target)) return "?";
223
+ const ms = target - Date.now();
224
+ if (ms <= 0) return "0m";
225
+ const m = Math.floor(ms / 60_000);
226
+ if (m < 60) return `${m}m`;
227
+ const h = Math.floor(m / 60);
228
+ const rm = m % 60;
229
+ return `${h}h${String(rm).padStart(2, "0")}m`;
230
+ }
231
+
232
+ function bar(pct, width = 10) {
233
+ const p = Math.max(0, Math.min(100, num(pct, 0)));
234
+ const filled = Math.round((p / 100) * width);
235
+ return "▓".repeat(filled) + "░".repeat(width - filled);
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Line 1 — vitals.
240
+ // ---------------------------------------------------------------------------
241
+ function renderLine1(payload, git, feat, config) {
242
+ const left = []; // identity group: model, style, duration
243
+ const mid = []; // workspace group: branch, feat
244
+ const right = []; // burn group: ctx, cost, lines
245
+
246
+ const modelName = payload?.model?.display_name;
247
+ if (modelName) left.push(cyan(`▶ ${modelName}`));
248
+
249
+ const styleName = payload?.output_style?.name;
250
+ if (styleName && styleName !== "default") left.push(dim(styleName));
251
+
252
+ const durMs = payload?.cost?.total_duration_ms;
253
+ if (durMs && durMs >= 1000) left.push(dim(`⏱${fmtDuration(durMs)}`));
254
+
255
+ if (git?.branch) {
256
+ const tag = git.conflict ? red(`${git.branch}!CONFLICT`)
257
+ : git.dirty > 0 ? yellow(`${git.branch}(±${git.dirty})`)
258
+ : green(git.branch);
259
+ mid.push(tag);
260
+ }
261
+
262
+ if (feat) {
263
+ mid.push(feat.open ? magenta(`feat:${feat.open}`) : dimGreen("feat:clean"));
264
+ }
265
+
266
+ const pct = payload?.context_window?.used_percentage;
267
+ if (typeof pct === "number") {
268
+ const col = ctxColor(pct);
269
+ right.push(`${col(bar(pct))} ${col(`${Math.round(pct)}%`)}`);
270
+ }
271
+
272
+ const cost = payload?.cost?.total_cost_usd;
273
+ if (typeof cost === "number" && cost > 0) {
274
+ right.push(costStr(cost));
275
+ }
276
+
277
+ if (config.showLines) {
278
+ const add = num(payload?.cost?.total_lines_added, 0);
279
+ const rem = num(payload?.cost?.total_lines_removed, 0);
280
+ if (add > 0 || rem > 0) {
281
+ right.push(`${green("+" + add)}/${dimRed("-" + rem)}`);
46
282
  }
47
- } catch { /* git not on PATH — skip */ }
283
+ }
48
284
 
49
- const raw = readStdinSync();
50
- let payload = null;
51
- if (raw) { try { payload = JSON.parse(raw); } catch { /* ignore */ } }
52
- if (payload?.context && typeof payload.context.percentage === "number") {
53
- out.push(`ctx:${Math.round(payload.context.percentage)}%`);
285
+ const parts = [];
286
+ if (left.length) parts.push(left.join(dim("│")));
287
+ if (mid.length) parts.push(mid.join(" "));
288
+ if (right.length) parts.push(right.join(" "));
289
+ return parts.join(" ");
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Line 2 — alerts.
294
+ // ---------------------------------------------------------------------------
295
+ function renderLine2(payload, sessionId, config, lang) {
296
+ if (config.compact) return "";
297
+ const t = STRINGS[lang];
298
+ const alerts = [];
299
+
300
+ // Order by severity: hardest stop first.
301
+ if (payload?.exceeds_200k_tokens === true) {
302
+ alerts.push(red(`⚠ ${t.over_200k}`));
54
303
  }
55
- if (payload?.cost && typeof payload.cost.total === "number") {
56
- const v = payload.cost.total;
57
- out.push(`$${v < 1 ? v.toFixed(2) : v.toFixed(1)}`);
304
+
305
+ const pct = payload?.context_window?.used_percentage;
306
+ if (typeof pct === "number" && pct >= 80 && payload?.exceeds_200k_tokens !== true) {
307
+ alerts.push(red(`⚠ ctx ${Math.round(pct)}%${t.compact_soon}`));
58
308
  }
59
- return out;
309
+
310
+ if (config.showRateLimit) {
311
+ const five = payload?.rate_limits?.five_hour;
312
+ if (five && typeof five.used_percentage === "number" && five.used_percentage >= 75) {
313
+ const resetTxt = five.resets_at ? `${t.rate_resets}${fmtCountdown(five.resets_at)}` : "";
314
+ alerts.push(yellow(`⏳ 5h limit ${Math.round(five.used_percentage)}%${resetTxt}`));
315
+ }
316
+ }
317
+
318
+ if (config.showLastBlock) {
319
+ const lb = fetchLastBlock(sessionId);
320
+ if (lb) {
321
+ const title = String(lb.title || "").slice(0, 40);
322
+ alerts.push(red(`🚫 ${t.last_block}${title}`));
323
+ }
324
+ }
325
+
326
+ return alerts.join(" ");
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Main.
331
+ // ---------------------------------------------------------------------------
332
+ function main() {
333
+ const raw = readStdinSync();
334
+ let payload = {};
335
+ if (raw) {
336
+ try { payload = JSON.parse(raw) || {}; } catch { payload = {}; }
337
+ }
338
+ const sessionId = payload?.session_id || "no-session";
339
+ const { config, humanLanguage } = readConfig(sessionId);
340
+ const lang = pickLang(config, humanLanguage);
341
+
342
+ const git = fetchGit(sessionId);
343
+ const feat = fetchFeature(sessionId);
344
+
345
+ const line1 = renderLine1(payload, git, feat, config);
346
+ const line2 = renderLine2(payload, sessionId, config, lang);
347
+
348
+ const out = [];
349
+ if (line1) out.push(line1);
350
+ if (line2) out.push(line2);
351
+ if (out.length) process.stdout.write(out.join("\n"));
60
352
  }
61
353
 
62
- const line = pieces().join(" ");
63
- if (line) process.stdout.write(line);
354
+ try { main(); } catch { /* swallow — never crash the TUI */ }