agent-harness-kit 0.10.2 → 0.11.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -5
- package/package.json +3 -2
- package/src/core/render-templates.mjs +31 -3
- package/src/templates/.claude/keybindings.json.example +20 -0
- 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/.claude/skills/setup-nightly-eval/SKILL.md +118 -0
- package/src/templates/docs/env-vars.md +54 -0
- package/src/templates/docs/memory-cheatsheet.md +82 -0
- package/src/templates/scripts/_lib/jp.sh +53 -0
- package/src/templates/scripts/_lib/statusline-cache.mjs +57 -0
- package/src/templates/scripts/_lib/telemetry.sh +45 -0
- package/src/templates/scripts/notify-on-block.sh.hbs +6 -23
- package/src/templates/scripts/pre-compact.sh.hbs +2 -20
- package/src/templates/scripts/pre-push.sh +2 -20
- package/src/templates/scripts/precompletion-checklist.sh.hbs +5 -31
- package/src/templates/scripts/pretooluse-bash-guard.sh.hbs +2 -20
- package/src/templates/scripts/pretooluse-edit-guard.sh.hbs +2 -14
- package/src/templates/scripts/session-end.sh.hbs +2 -14
- package/src/templates/scripts/session-start.sh.hbs +2 -20
- package/src/templates/scripts/statusline.mjs +327 -36
- package/src/templates/scripts/structural-test-on-edit.sh.hbs +2 -14
- package/src/templates/scripts/subagent-stop.sh.hbs +7 -18
- package/src/templates/scripts/telemetry-on-skill.sh +14 -20
- package/src/templates/scripts/userprompt-guard.sh.hbs +2 -20
|
@@ -6,26 +6,9 @@ set -eo pipefail
|
|
|
6
6
|
|
|
7
7
|
INPUT=$(cat)
|
|
8
8
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
have_jp() {
|
|
14
|
-
have_jq && return 0
|
|
15
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
16
|
-
return 1
|
|
17
|
-
}
|
|
18
|
-
jp() {
|
|
19
|
-
if have_jq; then
|
|
20
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
21
|
-
else
|
|
22
|
-
if [ -n "$2" ]; then
|
|
23
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
24
|
-
else
|
|
25
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
26
|
-
fi
|
|
27
|
-
fi
|
|
28
|
-
}
|
|
9
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
10
|
+
. "$_LIB_DIR/jp.sh"
|
|
11
|
+
. "$_LIB_DIR/telemetry.sh"
|
|
29
12
|
|
|
30
13
|
if [ "${AHK_DISABLE_NOTIFY:-}" = "1" ]; then
|
|
31
14
|
exit 0
|
|
@@ -46,12 +29,12 @@ if [ -n "$TYPE" ]; then
|
|
|
46
29
|
fi
|
|
47
30
|
[ -z "$BODY" ] && BODY="Claude Code wants your attention."
|
|
48
31
|
|
|
49
|
-
mkdir -p .harness
|
|
50
32
|
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
51
33
|
ESCAPED_TITLE=${TITLE//\"/\\\"}
|
|
52
34
|
ESCAPED_BODY=${BODY//\"/\\\"}
|
|
53
|
-
printf '{"ts":"%s","hook":"Notification","type":"%s","title":"%s","body":"%s"}
|
|
54
|
-
"$TS" "$TYPE" "$ESCAPED_TITLE" "$ESCAPED_BODY"
|
|
35
|
+
LINE=$(printf '{"ts":"%s","hook":"Notification","type":"%s","title":"%s","body":"%s"}' \
|
|
36
|
+
"$TS" "$TYPE" "$ESCAPED_TITLE" "$ESCAPED_BODY")
|
|
37
|
+
telemetry_append "$LINE"
|
|
55
38
|
|
|
56
39
|
OS_KIND=$(uname -s 2>/dev/null || echo "Unknown")
|
|
57
40
|
case "$OS_KIND" in
|
|
@@ -28,26 +28,8 @@ set -eo pipefail
|
|
|
28
28
|
|
|
29
29
|
INPUT=$(cat)
|
|
30
30
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
command -v jq >/dev/null 2>&1
|
|
34
|
-
}
|
|
35
|
-
have_jp() {
|
|
36
|
-
have_jq && return 0
|
|
37
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
38
|
-
return 1
|
|
39
|
-
}
|
|
40
|
-
jp() {
|
|
41
|
-
if have_jq; then
|
|
42
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
43
|
-
else
|
|
44
|
-
if [ -n "$2" ]; then
|
|
45
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
46
|
-
else
|
|
47
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
48
|
-
fi
|
|
49
|
-
fi
|
|
50
|
-
}
|
|
31
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
32
|
+
. "$_LIB_DIR/jp.sh"
|
|
51
33
|
|
|
52
34
|
TRIGGER=""
|
|
53
35
|
TOKENS=""
|
|
@@ -8,26 +8,8 @@ set -eo pipefail
|
|
|
8
8
|
# Without this fallback, `jq` missing on a fresh CI image silently disabled
|
|
9
9
|
# the baseline-monotonic guard — a known audit hole.
|
|
10
10
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
command -v jq >/dev/null 2>&1
|
|
14
|
-
}
|
|
15
|
-
have_jp() {
|
|
16
|
-
have_jq && return 0
|
|
17
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
18
|
-
return 1
|
|
19
|
-
}
|
|
20
|
-
jp() {
|
|
21
|
-
if have_jq; then
|
|
22
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
23
|
-
else
|
|
24
|
-
if [ -n "$2" ]; then
|
|
25
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
26
|
-
else
|
|
27
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
28
|
-
fi
|
|
29
|
-
fi
|
|
30
|
-
}
|
|
11
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
12
|
+
. "$_LIB_DIR/jp.sh"
|
|
31
13
|
|
|
32
14
|
# Baseline monotonic guard. .harness/structural-baseline.json is decreasing-
|
|
33
15
|
# only — fixes REMOVE entries; no path should ADD them. Catches the "mask
|
|
@@ -14,37 +14,11 @@ INPUT=$(cat)
|
|
|
14
14
|
|
|
15
15
|
# Resolve the directory this hook lives in (used to find _lib/json-pick.mjs).
|
|
16
16
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
17
|
-
|
|
18
|
-
# have_jq
|
|
19
|
-
# used by tests to exercise the jq-less code path
|
|
20
|
-
# installed locally.
|
|
21
|
-
|
|
22
|
-
[ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
|
|
23
|
-
command -v jq >/dev/null 2>&1
|
|
24
|
-
}
|
|
25
|
-
# jp — JSON picker. Uses `jq` when available, else falls back to a bundled
|
|
26
|
-
# Node script with a jq-subset implementation. Keeps hooks portable on
|
|
27
|
-
# minimal CI / Windows where jq is not installed by default. Without this
|
|
28
|
-
# fallback, the entire pre-completion check used to be a silent no-op.
|
|
29
|
-
jp() {
|
|
30
|
-
if have_jq; then
|
|
31
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
32
|
-
else
|
|
33
|
-
if [ -n "$2" ]; then
|
|
34
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
35
|
-
else
|
|
36
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
37
|
-
fi
|
|
38
|
-
fi
|
|
39
|
-
}
|
|
40
|
-
# Probe: do we have either jq or the Node fallback? Node is always
|
|
41
|
-
# present (kit's `engines` field requires >=20), so this is just an explicit
|
|
42
|
-
# probe and a fail-loud branch if even node is missing.
|
|
43
|
-
have_jp() {
|
|
44
|
-
have_jq && return 0
|
|
45
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
46
|
-
return 1
|
|
47
|
-
}
|
|
17
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
18
|
+
# have_jq / have_jp / jp shared across all hook scripts. AHK_DISABLE_JQ=1
|
|
19
|
+
# forces the Node fallback, used by tests to exercise the jq-less code path
|
|
20
|
+
# on machines that have jq installed locally.
|
|
21
|
+
. "$_LIB_DIR/jp.sh"
|
|
48
22
|
|
|
49
23
|
# CRITICAL: avoid infinite loops. If the hook already ran, do not block again.
|
|
50
24
|
if have_jp; then
|
|
@@ -32,26 +32,8 @@ set -eo pipefail
|
|
|
32
32
|
|
|
33
33
|
INPUT=$(cat)
|
|
34
34
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
command -v jq >/dev/null 2>&1
|
|
38
|
-
}
|
|
39
|
-
have_jp() {
|
|
40
|
-
have_jq && return 0
|
|
41
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
42
|
-
return 1
|
|
43
|
-
}
|
|
44
|
-
jp() {
|
|
45
|
-
if have_jq; then
|
|
46
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
47
|
-
else
|
|
48
|
-
if [ -n "$2" ]; then
|
|
49
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
50
|
-
else
|
|
51
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
52
|
-
fi
|
|
53
|
-
fi
|
|
54
|
-
}
|
|
35
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
36
|
+
. "$_LIB_DIR/jp.sh"
|
|
55
37
|
|
|
56
38
|
if ! have_jp; then
|
|
57
39
|
# Without a JSON parser we can't read the command. Skip rather than
|
|
@@ -23,20 +23,8 @@ set -eo pipefail
|
|
|
23
23
|
|
|
24
24
|
INPUT=$(cat)
|
|
25
25
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
command -v jq >/dev/null 2>&1
|
|
29
|
-
}
|
|
30
|
-
have_jp() {
|
|
31
|
-
have_jq && return 0
|
|
32
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
33
|
-
return 1
|
|
34
|
-
}
|
|
35
|
-
jp() {
|
|
36
|
-
if have_jq; then jq -r "$1"
|
|
37
|
-
else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
38
|
-
fi
|
|
39
|
-
}
|
|
26
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
27
|
+
. "$_LIB_DIR/jp.sh"
|
|
40
28
|
if ! have_jp; then exit 0; fi
|
|
41
29
|
|
|
42
30
|
# Resolve target file. Write/Edit ship .tool_input.file_path; MultiEdit ships
|
|
@@ -19,20 +19,8 @@ set -eo pipefail
|
|
|
19
19
|
|
|
20
20
|
INPUT=$(cat)
|
|
21
21
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
command -v jq >/dev/null 2>&1
|
|
25
|
-
}
|
|
26
|
-
have_jp() {
|
|
27
|
-
have_jq && return 0
|
|
28
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
29
|
-
return 1
|
|
30
|
-
}
|
|
31
|
-
jp() {
|
|
32
|
-
if have_jq; then jq -r "$1"
|
|
33
|
-
else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
34
|
-
fi
|
|
35
|
-
}
|
|
22
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
23
|
+
. "$_LIB_DIR/jp.sh"
|
|
36
24
|
|
|
37
25
|
REASON=""
|
|
38
26
|
SESSION_ID=""
|
|
@@ -22,26 +22,8 @@ set -eo pipefail
|
|
|
22
22
|
|
|
23
23
|
INPUT=$(cat)
|
|
24
24
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
command -v jq >/dev/null 2>&1
|
|
28
|
-
}
|
|
29
|
-
have_jp() {
|
|
30
|
-
have_jq && return 0
|
|
31
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
32
|
-
return 1
|
|
33
|
-
}
|
|
34
|
-
jp() {
|
|
35
|
-
if have_jq; then
|
|
36
|
-
if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
|
|
37
|
-
else
|
|
38
|
-
if [ -n "$2" ]; then
|
|
39
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
|
|
40
|
-
else
|
|
41
|
-
node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
42
|
-
fi
|
|
43
|
-
fi
|
|
44
|
-
}
|
|
25
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
26
|
+
. "$_LIB_DIR/jp.sh"
|
|
45
27
|
|
|
46
28
|
SOURCE=""
|
|
47
29
|
if have_jp; then
|
|
@@ -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 */ }
|
|
@@ -13,20 +13,8 @@ INPUT=$(cat)
|
|
|
13
13
|
# when jq is missing — silently skipping the structural check on jq-less
|
|
14
14
|
# environments (minimal CI, Windows without WSL+brew) was a known audit hole.
|
|
15
15
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
command -v jq >/dev/null 2>&1
|
|
19
|
-
}
|
|
20
|
-
have_jp() {
|
|
21
|
-
have_jq && return 0
|
|
22
|
-
command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
|
|
23
|
-
return 1
|
|
24
|
-
}
|
|
25
|
-
jp() {
|
|
26
|
-
if have_jq; then jq -r "$1"
|
|
27
|
-
else node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
|
|
28
|
-
fi
|
|
29
|
-
}
|
|
16
|
+
_LIB_DIR="$SCRIPT_DIR/_lib"
|
|
17
|
+
. "$_LIB_DIR/jp.sh"
|
|
30
18
|
if ! have_jp; then
|
|
31
19
|
echo "[ahk] structural-test-on-edit: no JSON parser available (need jq OR node + scripts/_lib/json-pick.mjs)." >&2
|
|
32
20
|
exit 0
|