agent-harness-kit 0.10.2 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/render-templates.mjs +31 -3
- package/src/templates/.claude/skills/deliver-html/SKILL.md.hbs +5 -1
- package/src/templates/.claude/skills/deliver-html/SKILL.md.vi.hbs +5 -1
- package/src/templates/.claude/skills/deliver-html/scripts/wrap-html.mjs +0 -0
- package/src/templates/scripts/_lib/statusline-cache.mjs +57 -0
- package/src/templates/scripts/statusline.mjs +327 -36
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"source": {
|
|
12
12
|
"source": "github",
|
|
13
13
|
"repo": "tuanle96/agent-harness-kit",
|
|
14
|
-
"ref": "v0.
|
|
14
|
+
"ref": "v0.11.0"
|
|
15
15
|
},
|
|
16
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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
|
|
292
|
-
//
|
|
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:**
|
|
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:**
|
|
74
|
+
**Open:** auto-opened (hoặc fallback hint khi --no-open / CI=true)
|
|
71
75
|
```
|
|
72
76
|
|
|
73
77
|
## Output contract
|
|
Binary file
|
|
@@ -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 —
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
}
|
|
283
|
+
}
|
|
48
284
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
if (line) process.stdout.write(line);
|
|
354
|
+
try { main(); } catch { /* swallow — never crash the TUI */ }
|