claude-doom-statusbar 0.6.0 → 0.7.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/bin/cli.js +7 -3
- package/package.json +2 -2
- package/presets/full.toml +3 -0
- package/src/fold.js +137 -0
- package/src/hook.js +95 -108
- package/src/render.js +45 -9
- package/src/statusline.js +65 -27
package/bin/cli.js
CHANGED
|
@@ -29,8 +29,11 @@ const STATUSLINE_CMD = `node "${STATUSLINE}"`;
|
|
|
29
29
|
const HOOK_CMD = `node "${HOOK}"`;
|
|
30
30
|
|
|
31
31
|
// Lifecycle events the mugshot hook understands (face reactions, geiger, subagents,
|
|
32
|
-
// tasks, permission mode). PreToolUse has no matcher -> fires for every tool.
|
|
32
|
+
// tasks, permission mode, git snapshots). PreToolUse has no matcher -> fires for every tool.
|
|
33
|
+
// SessionStart resets the journal and primes git so the HUD is populated from the first
|
|
34
|
+
// render. All entries are installed async (see install()) so they never block a tool.
|
|
33
35
|
const HOOK_EVENTS = [
|
|
36
|
+
"SessionStart",
|
|
34
37
|
"PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionDenied",
|
|
35
38
|
"Stop", "SubagentStart", "SubagentStop", "TaskCreated", "TaskCompleted",
|
|
36
39
|
];
|
|
@@ -91,8 +94,9 @@ function install(cfg, preset) {
|
|
|
91
94
|
for (const ev of HOOK_EVENTS) {
|
|
92
95
|
const lst = (hooks[ev] ??= []);
|
|
93
96
|
if (!lst.some(ours)) {
|
|
94
|
-
// idempotent: don't double-add
|
|
95
|
-
|
|
97
|
+
// idempotent: don't double-add. async:true keeps the hook off the blocking path —
|
|
98
|
+
// it only appends one journal line and returns; statusline folds it at render time.
|
|
99
|
+
lst.push({ hooks: [{ type: "command", command: HOOK_CMD, async: true }] });
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
return notes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-doom-statusbar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "DOOM-inspired status bar for the Claude Code CLI — a mugshot that tracks session health, plus usage, model, project, system, and a live subagent list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"statusline": "node src/statusline.js",
|
|
17
17
|
"hook": "node src/hook.js",
|
|
18
|
-
"test": "node test/installer.test.mjs && node test/smoke.test.mjs && node test/hook-tasks.test.mjs && node test/statusline-tasks.test.mjs && node test/render-scroll.test.mjs && node test/statusline-savings.test.mjs && node test/e2e-tasks.test.mjs && node test/marquee.test.mjs && node test/layout.test.mjs && node test/resolve-preset.test.mjs",
|
|
18
|
+
"test": "node test/installer.test.mjs && node test/smoke.test.mjs && node test/hook-tasks.test.mjs && node test/statusline-tasks.test.mjs && node test/render-scroll.test.mjs && node test/statusline-savings.test.mjs && node test/e2e-tasks.test.mjs && node test/marquee.test.mjs && node test/layout.test.mjs && node test/resolve-preset.test.mjs && node test/preset-bands.test.mjs && node test/journal.test.mjs && node test/git-event.test.mjs",
|
|
19
19
|
"preversion": "npm test",
|
|
20
20
|
"postversion": "git push --follow-tags",
|
|
21
21
|
"prepublishOnly": "npm test"
|
package/presets/full.toml
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
# full — DOOM panel, everything on
|
|
2
|
+
# text_overflow = "scroll" # how labels longer than their box behave: "scroll" (ping-pong
|
|
3
|
+
# marquee, default) or "clip" (static, truncated with …).
|
|
4
|
+
# Env override: DOOMBAR_TEXT_OVERFLOW.
|
|
2
5
|
[bar]
|
|
3
6
|
border_style = "vertical"
|
|
4
7
|
border_color = "term-bg" # seamless cuts through the panel
|
package/src/fold.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Shared, pure reducer for the DOOM HUD state. No I/O, no spawns.
|
|
2
|
+
//
|
|
3
|
+
// Two writers used to fight over one state file (hook did read-modify-write per event).
|
|
4
|
+
// Now hooks only APPEND raw events to a per-session journal, and statusline FOLDS that
|
|
5
|
+
// journal into a checkpoint at render time. This module is the fold — identical state
|
|
6
|
+
// shape as before (spans, squad, pending, tasks, tasks_ts, expr, ts, errors, mode, git),
|
|
7
|
+
// so every downstream consumer (activityValues, the tests) is unchanged.
|
|
8
|
+
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const GEIGER_WINDOW = 30.0; // seconds of tool-run history kept for the sparkline
|
|
13
|
+
export const MAX_RUN = 300.0; // drop an unclosed span after this (assume the Post was lost)
|
|
14
|
+
export const TASK_LINGER = 10.0; // seconds the TASKS box lingers after all tasks settle
|
|
15
|
+
|
|
16
|
+
export const READ_TOOLS = new Set(["Read", "Grep", "Glob",
|
|
17
|
+
"ctx_read", "ctx_multi_read", "ctx_search", "ctx_semantic_search", "ctx_tree", "ctx_overview"]);
|
|
18
|
+
export const WRITE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash", "ctx_shell", "ctx_edit"]);
|
|
19
|
+
|
|
20
|
+
// Filesystem-safe session key, shared by hook + statusline so they agree on file names.
|
|
21
|
+
export const sidKey = (id) => String(id || "default").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48);
|
|
22
|
+
|
|
23
|
+
// Checkpoint (statusline-owned folded state) + journal (hook-appended events) live next to
|
|
24
|
+
// each other so both processes derive the same pair from the session id. MUGSHOT_STATE
|
|
25
|
+
// overrides the checkpoint path (tests, custom setups); the journal hangs off it.
|
|
26
|
+
export function statePaths(sessionId) {
|
|
27
|
+
const base = process.env.MUGSHOT_STATE || path.join(os.tmpdir(), `mugshot_${sidKey(sessionId)}.json`);
|
|
28
|
+
return { checkpoint: base, journal: base + ".jsonl" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function base(tool) {
|
|
32
|
+
return tool.startsWith("mcp__") ? tool.split("__").pop() : tool;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Task event subject: the exact key isn't documented, so try the likely ones.
|
|
36
|
+
export function taskTitle(ev) {
|
|
37
|
+
return ev.task_title || ev.task_subject || ev.subject || ev.title ||
|
|
38
|
+
(ev.tool_input && ev.tool_input.subject) || "task";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function expression(name, tool) {
|
|
42
|
+
const b = base(tool);
|
|
43
|
+
if (["PostToolUseFailure", "StopFailure", "PermissionDenied"].includes(name)) return "ouch";
|
|
44
|
+
if (["Stop", "TaskCompleted"].includes(name)) return "evl";
|
|
45
|
+
if (name === "PostToolUse") {
|
|
46
|
+
if (READ_TOOLS.has(b)) return Math.floor((Date.now() / 1000) * 2) % 2 === 0 ? "tl" : "tr";
|
|
47
|
+
if (WRITE_TOOLS.has(b)) return "kill";
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function foldActivity(st, name, ev, now) {
|
|
53
|
+
st.spans ??= [];
|
|
54
|
+
st.squad ??= {};
|
|
55
|
+
st.pending ??= [];
|
|
56
|
+
st.tasks ??= {}; // keyed map: id -> { title, status, ts }
|
|
57
|
+
st.errors ??= 0;
|
|
58
|
+
|
|
59
|
+
const tool = base(ev.tool_name || "");
|
|
60
|
+
if (name === "PreToolUse") {
|
|
61
|
+
st.spans.push([now, null]); // open a run interval
|
|
62
|
+
if (tool === "Agent") {
|
|
63
|
+
const ti = ev.tool_input || {};
|
|
64
|
+
st.pending.push({ type: ti.subagent_type || "", desc: ti.description || "", ts: now });
|
|
65
|
+
st.pending = st.pending.filter((p) => now - p.ts < 60).slice(-16);
|
|
66
|
+
}
|
|
67
|
+
} else if (["PostToolUse", "PostToolUseFailure", "PermissionDenied"].includes(name)) {
|
|
68
|
+
for (let i = st.spans.length - 1; i >= 0; i--) { // close the most recent open one
|
|
69
|
+
if (st.spans[i][1] === null) { st.spans[i][1] = now; break; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (["PostToolUseFailure", "StopFailure", "PermissionDenied"].includes(name)) {
|
|
74
|
+
st.errors += 1;
|
|
75
|
+
} else if (name === "SubagentStart") {
|
|
76
|
+
const aid = String(ev.agent_id || now);
|
|
77
|
+
const atype = ev.agent_type || "agent";
|
|
78
|
+
let desc = "";
|
|
79
|
+
for (let i = 0; i < st.pending.length; i++) { // FIFO match the launch by agent type
|
|
80
|
+
if (st.pending[i].type === atype) { desc = st.pending[i].desc; st.pending.splice(i, 1); break; }
|
|
81
|
+
}
|
|
82
|
+
st.squad[aid] = { type: atype, start: now, desc };
|
|
83
|
+
} else if (name === "SubagentStop") {
|
|
84
|
+
delete st.squad[String(ev.agent_id || "")];
|
|
85
|
+
} else if (name === "TaskCreated") {
|
|
86
|
+
const id = String(ev.task_id ?? now);
|
|
87
|
+
st.tasks[id] = { title: taskTitle(ev), status: "pending", ts: now };
|
|
88
|
+
st.tasks_ts = now;
|
|
89
|
+
} else if (name === "TaskCompleted") {
|
|
90
|
+
const id = String(ev.task_id ?? "");
|
|
91
|
+
if (st.tasks[id]) st.tasks[id].status = "completed";
|
|
92
|
+
else st.tasks[id] = { title: taskTitle(ev), status: "completed", ts: now };
|
|
93
|
+
st.tasks_ts = now;
|
|
94
|
+
} else if (name === "PostToolUse" && (ev.tool_name === "TaskUpdate") && ev.tool_input) {
|
|
95
|
+
const id = String(ev.tool_input.taskId ?? "");
|
|
96
|
+
const s = ev.tool_input.status;
|
|
97
|
+
if (id && st.tasks[id] && ["pending", "in_progress", "completed", "deleted"].includes(s)) {
|
|
98
|
+
st.tasks[id].status = s;
|
|
99
|
+
st.tasks_ts = now;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const win = now - GEIGER_WINDOW; // prune: closed spans out of window, orphaned open spans
|
|
104
|
+
st.spans = st.spans.filter((s) => (s[1] === null ? s[0] >= now - MAX_RUN : s[1] >= win));
|
|
105
|
+
st.squad = Object.fromEntries(Object.entries(st.squad).filter(([, v]) => now - v.start < MAX_RUN));
|
|
106
|
+
|
|
107
|
+
const taskVals = Object.values(st.tasks || {});
|
|
108
|
+
const anyOpen = taskVals.some((t) => t.status === "pending" || t.status === "in_progress");
|
|
109
|
+
if (taskVals.length && !anyOpen && now - (st.tasks_ts || 0) > TASK_LINGER) st.tasks = {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// One full event fold: activity + face expression + permission mode + git snapshot.
|
|
113
|
+
// This is exactly what hook.js's main() used to do per event — now applied at render time
|
|
114
|
+
// over the journal. A `git` event carries a precomputed snapshot in ev.git.
|
|
115
|
+
export function foldEvent(st, name, ev, now) {
|
|
116
|
+
foldActivity(st, name, ev, now);
|
|
117
|
+
const expr = expression(name, ev.tool_name || "");
|
|
118
|
+
if (expr) { st.expr = expr; st.ts = now; }
|
|
119
|
+
if (ev.permission_mode) st.mode = ev.permission_mode;
|
|
120
|
+
if (name === "git" && ev.git) st.git = ev.git; // { br, lr, st, cwd } or { cwd } when no repo
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fold a batch of journal lines (each { name, ev, ts }) into st. Sort by ts first: async
|
|
124
|
+
// hooks give no append-order guarantee, so SubagentStop can land before its Start. Sorting
|
|
125
|
+
// the batch keeps open/close and create/consume in causal order (within the batch).
|
|
126
|
+
export function foldBatch(st, lines) {
|
|
127
|
+
const evs = [];
|
|
128
|
+
for (const ln of lines) {
|
|
129
|
+
if (!ln) continue;
|
|
130
|
+
let o; try { o = JSON.parse(ln); } catch { continue; } // torn/partial line -> skip
|
|
131
|
+
if (!o || typeof o.ts !== "number" || typeof o.name !== "string") continue;
|
|
132
|
+
evs.push(o);
|
|
133
|
+
}
|
|
134
|
+
evs.sort((a, b) => a.ts - b.ts);
|
|
135
|
+
for (const o of evs) foldEvent(st, o.name, o.ev || {}, o.ts);
|
|
136
|
+
return st;
|
|
137
|
+
}
|
package/src/hook.js
CHANGED
|
@@ -1,112 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Claude Code hook: an
|
|
3
|
-
// Each invocation reads the shared state file, folds in the lifecycle event, and
|
|
4
|
-
// writes it back atomically. The status line reads that state.
|
|
2
|
+
// Claude Code hook: an APPEND-ONLY event recorder for the DOOM HUD.
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Old design did read-modify-write on a shared state file per event; under async hooks that
|
|
5
|
+
// races (lost subagents/tasks). Now each invocation just APPENDS one slim line to a
|
|
6
|
+
// per-session journal — append is atomic, so concurrent async hooks never clobber each
|
|
7
|
+
// other. statusline.js folds the journal into a checkpoint at render time (see fold.js).
|
|
9
8
|
//
|
|
10
|
-
//
|
|
9
|
+
// Because the heavy work (folding, git) is off the blocking path, install these hooks with
|
|
10
|
+
// "async": true (see bin/cli.js). This hook never reads the journal and always exits 0.
|
|
11
|
+
//
|
|
12
|
+
// Extra job: git lives here now, not on the render hot path. On write-affecting events (and
|
|
13
|
+
// once per turn on Stop, and at SessionStart) we snapshot git into a `git` journal line,
|
|
14
|
+
// throttled by DOOMBAR_GIT_TTL. statusline no longer spawns git at all -> the Windows MSYS
|
|
15
|
+
// "bash flood" is gone by construction (git runs async, event-driven, rarely).
|
|
16
|
+
//
|
|
17
|
+
// Journal: <checkpoint>.jsonl where checkpoint is $MUGSHOT_STATE or <temp>/mugshot_<sid>.json.
|
|
11
18
|
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import path from "node:path";
|
|
19
|
+
import { appendFileSync, writeFileSync, readFileSync } from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
15
21
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { base, statePaths, sidKey, WRITE_TOOLS } from "./fold.js";
|
|
25
|
+
|
|
26
|
+
// Re-export the reducer so existing tests importing from hook.js keep working.
|
|
27
|
+
export { foldActivity, expression } from "./fold.js";
|
|
28
|
+
|
|
29
|
+
const GIT_TTL = Number.isFinite(Number(process.env.DOOMBAR_GIT_TTL))
|
|
30
|
+
? Number(process.env.DOOMBAR_GIT_TTL)
|
|
31
|
+
: 4000; // ms; DOOMBAR_GIT_TTL=0 disables throttling (0 is a valid TTL)
|
|
32
|
+
|
|
33
|
+
// Project an event down to only the fields fold.js consumes. Keeps journal lines tiny and
|
|
34
|
+
// bounded — a raw Write/Edit event carries the whole file body in tool_input, which would
|
|
35
|
+
// bloat the journal and stress append atomicity. We never journal that.
|
|
36
|
+
function slim(ev) {
|
|
37
|
+
const ti = ev.tool_input || {};
|
|
38
|
+
const tn = ev.tool_name;
|
|
39
|
+
let tool_input;
|
|
40
|
+
if (tn === "TaskUpdate") tool_input = { taskId: ti.taskId, status: ti.status };
|
|
41
|
+
else if (tn === "Agent") tool_input = { subagent_type: ti.subagent_type, description: ti.description };
|
|
42
|
+
else if (ti.subject) tool_input = { subject: ti.subject };
|
|
43
|
+
return {
|
|
44
|
+
tool_name: tn,
|
|
45
|
+
tool_input,
|
|
46
|
+
agent_id: ev.agent_id,
|
|
47
|
+
agent_type: ev.agent_type,
|
|
48
|
+
task_id: ev.task_id,
|
|
49
|
+
task_title: ev.task_title, task_subject: ev.task_subject, subject: ev.subject, title: ev.title,
|
|
50
|
+
permission_mode: ev.permission_mode,
|
|
51
|
+
};
|
|
29
52
|
}
|
|
30
53
|
|
|
31
|
-
function
|
|
32
|
-
|
|
54
|
+
function gitCmd(cwd, ...args) {
|
|
55
|
+
try {
|
|
56
|
+
const r = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 1000 });
|
|
57
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
58
|
+
} catch { return null; }
|
|
33
59
|
}
|
|
34
60
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(
|
|
61
|
+
function gitSnapshot(cwd) {
|
|
62
|
+
return {
|
|
63
|
+
cwd,
|
|
64
|
+
br: gitCmd(cwd, "branch", "--show-current"),
|
|
65
|
+
lr: gitCmd(cwd, "rev-list", "--count", "--left-right", "@{u}...HEAD"),
|
|
66
|
+
st: gitCmd(cwd, "status", "--porcelain"),
|
|
67
|
+
};
|
|
39
68
|
}
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (name === "PostToolUse") {
|
|
46
|
-
if (READ_TOOLS.has(b)) return Math.floor((Date.now() / 1000) * 2) % 2 === 0 ? "tl" : "tr";
|
|
47
|
-
if (WRITE_TOOLS.has(b)) return "kill";
|
|
48
|
-
}
|
|
49
|
-
return null;
|
|
70
|
+
// Best-effort per-session throttle (not a lock): a tiny marker holds the last git ts + cwd.
|
|
71
|
+
// Worst case under a race is a redundant concurrent git spawn — harmless and rare.
|
|
72
|
+
function gitMarkerPath(sid) {
|
|
73
|
+
return path.join(os.tmpdir(), `mugshot_git_${sidKey(sid)}.json`);
|
|
50
74
|
}
|
|
51
75
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
st.spans.push([now, null]); // open a run interval
|
|
62
|
-
if (tool === "Agent") {
|
|
63
|
-
const ti = ev.tool_input || {};
|
|
64
|
-
st.pending.push({ type: ti.subagent_type || "", desc: ti.description || "", ts: now });
|
|
65
|
-
st.pending = st.pending.filter((p) => now - p.ts < 60).slice(-16);
|
|
66
|
-
}
|
|
67
|
-
} else if (["PostToolUse", "PostToolUseFailure", "PermissionDenied"].includes(name)) {
|
|
68
|
-
for (let i = st.spans.length - 1; i >= 0; i--) { // close the most recent open one
|
|
69
|
-
if (st.spans[i][1] === null) { st.spans[i][1] = now; break; }
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (["PostToolUseFailure", "StopFailure", "PermissionDenied"].includes(name)) {
|
|
74
|
-
st.errors += 1;
|
|
75
|
-
} else if (name === "SubagentStart") {
|
|
76
|
-
const aid = String(ev.agent_id || now);
|
|
77
|
-
const atype = ev.agent_type || "agent";
|
|
78
|
-
let desc = "";
|
|
79
|
-
for (let i = 0; i < st.pending.length; i++) { // FIFO match the launch by agent type
|
|
80
|
-
if (st.pending[i].type === atype) { desc = st.pending[i].desc; st.pending.splice(i, 1); break; }
|
|
81
|
-
}
|
|
82
|
-
st.squad[aid] = { type: atype, start: now, desc };
|
|
83
|
-
} else if (name === "SubagentStop") {
|
|
84
|
-
delete st.squad[String(ev.agent_id || "")];
|
|
85
|
-
} else if (name === "TaskCreated") {
|
|
86
|
-
const id = String(ev.task_id ?? now);
|
|
87
|
-
st.tasks[id] = { title: taskTitle(ev), status: "pending", ts: now };
|
|
88
|
-
st.tasks_ts = now;
|
|
89
|
-
} else if (name === "TaskCompleted") {
|
|
90
|
-
const id = String(ev.task_id ?? "");
|
|
91
|
-
if (st.tasks[id]) st.tasks[id].status = "completed";
|
|
92
|
-
else st.tasks[id] = { title: taskTitle(ev), status: "completed", ts: now };
|
|
93
|
-
st.tasks_ts = now;
|
|
94
|
-
} else if (name === "PostToolUse" && (ev.tool_name === "TaskUpdate") && ev.tool_input) {
|
|
95
|
-
const id = String(ev.tool_input.taskId ?? "");
|
|
96
|
-
const s = ev.tool_input.status;
|
|
97
|
-
if (id && st.tasks[id] && ["pending", "in_progress", "completed", "deleted"].includes(s)) {
|
|
98
|
-
st.tasks[id].status = s;
|
|
99
|
-
st.tasks_ts = now;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const win = now - GEIGER_WINDOW; // prune: closed spans out of window, orphaned open spans
|
|
104
|
-
st.spans = st.spans.filter((s) => (s[1] === null ? s[0] >= now - MAX_RUN : s[1] >= win));
|
|
105
|
-
st.squad = Object.fromEntries(Object.entries(st.squad).filter(([, v]) => now - v.start < MAX_RUN));
|
|
106
|
-
|
|
107
|
-
const taskVals = Object.values(st.tasks || {});
|
|
108
|
-
const anyOpen = taskVals.some((t) => t.status === "pending" || t.status === "in_progress");
|
|
109
|
-
if (taskVals.length && !anyOpen && now - (st.tasks_ts || 0) > TASK_LINGER) st.tasks = {};
|
|
76
|
+
function shouldSnapshotGit(name, ev, nowMs, sid) {
|
|
77
|
+
if (name !== "SessionStart" && name !== "Stop" &&
|
|
78
|
+
!(name === "PostToolUse" && WRITE_TOOLS.has(base(ev.tool_name || "")))) return false;
|
|
79
|
+
if (name === "SessionStart") return true; // always prime at start
|
|
80
|
+
let m = {};
|
|
81
|
+
try { m = JSON.parse(readFileSync(gitMarkerPath(sid), "utf8")); } catch { /* none */ }
|
|
82
|
+
const cwd = ev.cwd || (ev.workspace || {}).current_dir;
|
|
83
|
+
if (m.cwd !== cwd) return true; // cwd changed -> refresh regardless of TTL
|
|
84
|
+
return nowMs - (m.ts || 0) >= GIT_TTL;
|
|
110
85
|
}
|
|
111
86
|
|
|
112
87
|
function main() {
|
|
@@ -114,19 +89,31 @@ function main() {
|
|
|
114
89
|
let ev = {};
|
|
115
90
|
try { ev = JSON.parse(readFileSync(0, "utf8")); } catch { ev = {}; }
|
|
116
91
|
const name = ev.hook_event_name || "";
|
|
117
|
-
const now = Date.now() / 1000;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
92
|
+
const now = Date.now() / 1000; // seconds, matches fold's time base
|
|
93
|
+
const nowMs = Date.now();
|
|
94
|
+
const sid = ev.session_id || "default";
|
|
95
|
+
const { journal } = statePaths(sid);
|
|
96
|
+
|
|
97
|
+
// SessionStart resets the journal so each session starts clean (hygiene; sid is already
|
|
98
|
+
// per-session). At this instant no other hook is appending, so truncation is race-free.
|
|
99
|
+
if (name === "SessionStart") {
|
|
100
|
+
try { writeFileSync(journal, ""); } catch { /* ignore */ }
|
|
101
|
+
}
|
|
127
102
|
|
|
128
|
-
|
|
129
|
-
try {
|
|
103
|
+
// Append the slim event line (atomic). foldEvent ignores names it doesn't know.
|
|
104
|
+
try {
|
|
105
|
+
appendFileSync(journal, JSON.stringify({ name, ev: slim(ev), ts: now }) + "\n", { flag: "a" });
|
|
106
|
+
} catch { /* never block a tool */ }
|
|
107
|
+
|
|
108
|
+
// Git snapshot on write-affecting events / per-turn Stop / session start, throttled.
|
|
109
|
+
const cwd = ev.cwd || (ev.workspace || {}).current_dir;
|
|
110
|
+
if (cwd && shouldSnapshotGit(name, ev, nowMs, sid)) {
|
|
111
|
+
const snap = gitSnapshot(cwd);
|
|
112
|
+
try {
|
|
113
|
+
appendFileSync(journal, JSON.stringify({ name: "git", ev: { git: snap }, ts: now }) + "\n", { flag: "a" });
|
|
114
|
+
} catch { /* ignore */ }
|
|
115
|
+
try { writeFileSync(gitMarkerPath(sid), JSON.stringify({ ts: nowMs, cwd })); } catch { /* ignore */ }
|
|
116
|
+
}
|
|
130
117
|
} catch { /* swallow everything: a hook must never block a tool */ }
|
|
131
118
|
process.exit(0);
|
|
132
119
|
}
|
package/src/render.js
CHANGED
|
@@ -66,6 +66,28 @@ export function vlen(s) {
|
|
|
66
66
|
return n;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// OSC8 hyperlink helpers — long hyperlinked labels (cwd, branch) can't be column-sliced
|
|
70
|
+
// as-is (slicing corrupts the escape), so we operate on the visible text and re-wrap it
|
|
71
|
+
// with the same URL. Matches the format emitted by statusline's _link().
|
|
72
|
+
const OSC8_RE = /^\x1b\]8;;([^\x1b\x07]*)(?:\x1b\\|\x07)([\s\S]*?)\x1b\]8;;(?:\x1b\\|\x07)$/;
|
|
73
|
+
function splitLink(s) {
|
|
74
|
+
const m = String(s).match(OSC8_RE);
|
|
75
|
+
return m ? { url: m[1], inner: m[2] } : null;
|
|
76
|
+
}
|
|
77
|
+
function wrapLink(text, url) {
|
|
78
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
79
|
+
}
|
|
80
|
+
// First `width` visible columns of `text`, no padding, never splitting a 2-col glyph.
|
|
81
|
+
function headCols(text, width) {
|
|
82
|
+
let col = 0, out = "";
|
|
83
|
+
for (const ch of [...String(text)]) {
|
|
84
|
+
const cw = vlen(ch);
|
|
85
|
+
if (col + cw > width) break;
|
|
86
|
+
out += ch; col += cw;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
function threshold(pct) {
|
|
70
92
|
return pct < 60 ? OK : pct < 85 ? WARN : CRIT;
|
|
71
93
|
}
|
|
@@ -213,7 +235,11 @@ const ESC_RE = /\x1b/;
|
|
|
213
235
|
function capLen(s, textCap) {
|
|
214
236
|
const str = String(s);
|
|
215
237
|
const w = vlen(str);
|
|
216
|
-
|
|
238
|
+
// plain text and OSC8 hyperlinks are column-sliceable (marquee/clip operate on the
|
|
239
|
+
// visible text), so cap them to textCap. Other escapes (raw SGR we can't safely slice)
|
|
240
|
+
// keep their full width as a hard floor.
|
|
241
|
+
const sliceable = !ESC_RE.test(str) || OSC8_RE.test(str);
|
|
242
|
+
return sliceable ? Math.min(w, textCap) : w;
|
|
217
243
|
}
|
|
218
244
|
|
|
219
245
|
export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
|
|
@@ -304,11 +330,18 @@ function sliceCols(text, off, width) {
|
|
|
304
330
|
|
|
305
331
|
// Fit `text` into exactly `width` display columns. Fits -> left-aligned + padded.
|
|
306
332
|
// Overflows -> ping-pong marquee window for the current `tick`.
|
|
307
|
-
export function marquee(text, width, tick = 0) {
|
|
333
|
+
export function marquee(text, width, tick = 0, mode = "scroll") {
|
|
308
334
|
text = String(text);
|
|
309
335
|
if (width <= 0) return "";
|
|
336
|
+
const link = splitLink(text); // hyperlink: fit the visible text, re-wrap
|
|
337
|
+
if (link) return wrapLink(marquee(link.inner, width, tick, mode), link.url);
|
|
310
338
|
const tw = vlen(text);
|
|
311
339
|
if (tw <= width) return text + " ".repeat(width - tw);
|
|
340
|
+
if (mode === "clip") { // static truncation with an ellipsis
|
|
341
|
+
if (width === 1) return "…";
|
|
342
|
+
const h = headCols(text, width - 1);
|
|
343
|
+
return h + "…" + " ".repeat(Math.max(0, width - vlen(h) - 1));
|
|
344
|
+
}
|
|
312
345
|
return sliceCols(text, marqueeOffset(tw - width, tick), width);
|
|
313
346
|
}
|
|
314
347
|
|
|
@@ -428,8 +461,10 @@ export function resolvePreset(chosenCfg, target, loadByName, spriteFor) {
|
|
|
428
461
|
return last; // nothing fit -> smallest reached
|
|
429
462
|
}
|
|
430
463
|
|
|
431
|
-
export function buildBar(cfg, target, spriteFor, tick = 0) {
|
|
464
|
+
export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
|
|
432
465
|
if (!spriteFor) spriteFor = (hp) => `STFST${hp}1`;
|
|
466
|
+
// How overflowing text fits its box: "scroll" (ping-pong marquee) or "clip" (static …).
|
|
467
|
+
const ovf = overflow || cfg.text_overflow || "scroll";
|
|
433
468
|
|
|
434
469
|
const { style, headers, segs, totalRows, hp, face, faceW } = layoutContext(cfg, spriteFor);
|
|
435
470
|
const bar = cfg.bar || {};
|
|
@@ -464,10 +499,10 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
|
|
|
464
499
|
if (Array.isArray(item) && item.length === 2) {
|
|
465
500
|
const right = f(TEXT) + String(item[1]);
|
|
466
501
|
const budget = Math.max(0, w - vlen(lbl) - vlen(String(item[1])) - 1); // 1 = min gap
|
|
467
|
-
const left = lbl + f(TEXT) + marquee(String(item[0]), budget, tick);
|
|
502
|
+
const left = lbl + f(TEXT) + marquee(String(item[0]), budget, tick, ovf);
|
|
468
503
|
body = left + " ".repeat(Math.max(0, w - vlen(left) - vlen(String(item[1])))) + right;
|
|
469
504
|
} else {
|
|
470
|
-
body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick);
|
|
505
|
+
body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick, ovf);
|
|
471
506
|
}
|
|
472
507
|
col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
|
|
473
508
|
}
|
|
@@ -492,7 +527,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
|
|
|
492
527
|
const right = f(TEXT) + String(item[1]) + (marker ? f(TEXT) + tail : "");
|
|
493
528
|
const rightW = vlen(String(item[1])) + tailW;
|
|
494
529
|
const labelMax = Math.max(0, w - vlen(lbl) - rightW - 1); // 1 = min gap
|
|
495
|
-
const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick);
|
|
530
|
+
const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick, ovf);
|
|
496
531
|
const room = Math.max(0, w - vlen(left) - rightW);
|
|
497
532
|
body = left + " ".repeat(room) + right;
|
|
498
533
|
} else { // {mark, markRgb, text} (tasks)
|
|
@@ -501,7 +536,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
|
|
|
501
536
|
const mPad = m + (vlen(m) < 2 ? " " : ""); // normalize mark to 2 cols so text aligns
|
|
502
537
|
const head = markCol + mPad + " " + f(TEXT);
|
|
503
538
|
const max = Math.max(0, w - vlen(mPad) - 1 - tailW); // reserve gap + marker on the right
|
|
504
|
-
body = head + marquee(String(item.text), max, tick);
|
|
539
|
+
body = head + marquee(String(item.text), max, tick, ovf);
|
|
505
540
|
body += " ".repeat(Math.max(0, w - tailW - vlen(body)));
|
|
506
541
|
if (tail) body += f(TEXT) + tail;
|
|
507
542
|
}
|
|
@@ -520,11 +555,12 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
|
|
|
520
555
|
const raw = String(VALUES[m.id]);
|
|
521
556
|
const lbl = m.icon ? m.icon + " " : "";
|
|
522
557
|
const budget = w - vlen(lbl) - vlen(rhs);
|
|
523
|
-
|
|
558
|
+
const sliceable = !ESC_RE.test(raw) || OSC8_RE.test(raw); // plain or hyperlink
|
|
559
|
+
if (sliceable && budget > 0 && vlen(raw) > budget) {
|
|
524
560
|
let col;
|
|
525
561
|
if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
|
|
526
562
|
else col = m.color ? rgbOf(m.color) : TEXT;
|
|
527
|
-
body = lbl + f(col) + marquee(raw, budget, tick);
|
|
563
|
+
body = lbl + f(col) + marquee(raw, budget, tick, ovf);
|
|
528
564
|
}
|
|
529
565
|
}
|
|
530
566
|
body += " ".repeat(Math.max(0, w - vlen(body) - vlen(rhs))) + rhs;
|
package/src/statusline.js
CHANGED
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
// Config: $DOOMBAR_PRESET (default presets/standard.toml) State: $MUGSHOT_STATE
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync, statSync,
|
|
13
|
+
readFileSync, writeFileSync, renameSync, openSync, fstatSync, readSync, closeSync, statfsSync, statSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
-
import { spawnSync } from "node:child_process";
|
|
19
18
|
import { parse as parseToml } from "smol-toml";
|
|
20
19
|
import { pyround, sgrFg } from "./ansi.js";
|
|
21
20
|
import { buildBar, setValues, resolvePreset, OK, TEXT, CRIT } from "./render.js";
|
|
21
|
+
import { foldBatch, statePaths } from "./fold.js";
|
|
22
22
|
|
|
23
23
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const REPO = path.dirname(HERE);
|
|
@@ -32,14 +32,9 @@ const GEIGER_BINS = 14;
|
|
|
32
32
|
|
|
33
33
|
const has = (o, k) => Object.prototype.hasOwnProperty.call(o || {}, k);
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return r.status === 0 ? r.stdout.trim() : null;
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
35
|
+
// git is no longer spawned here. The async hook snapshots it into the journal (see hook.js
|
|
36
|
+
// + fold.js); buildValues reads the folded snapshot from state.git. This keeps the render
|
|
37
|
+
// hot path spawn-free — the Windows MSYS "bash flood" cannot happen by construction.
|
|
43
38
|
|
|
44
39
|
// Clip a display label to at most `n` code points, ending with … when truncated,
|
|
45
40
|
// so an oversized repo or branch name can't blow up the PROJECT box width.
|
|
@@ -111,7 +106,7 @@ function godFlash(data, advTs, now) {
|
|
|
111
106
|
|
|
112
107
|
const f = sgrFg;
|
|
113
108
|
|
|
114
|
-
export function buildValues(data) {
|
|
109
|
+
export function buildValues(data, git) {
|
|
115
110
|
const v = {};
|
|
116
111
|
const cw = data.context_window || {};
|
|
117
112
|
if ("used_percentage" in cw) v["context.hp"] = pyround(cw.used_percentage);
|
|
@@ -150,20 +145,21 @@ export function buildValues(data) {
|
|
|
150
145
|
if (repo.host && repo.owner && repo.name) repoUrl = `https://${repo.host}/${repo.owner}/${repo.name}`;
|
|
151
146
|
|
|
152
147
|
const sname = data.session_name || data.session_id; // session_name only set via /rename or --name
|
|
153
|
-
|
|
148
|
+
// Clip generously, not to box width: the renderer fits each field to its box (marquee or
|
|
149
|
+
// clip per text_overflow). A tight clip here would truncate before the renderer ever sees it.
|
|
150
|
+
if (sname) v["session.name"] = clip(sname, 60);
|
|
154
151
|
|
|
155
152
|
const cwd = data.cwd || (data.workspace || {}).current_dir;
|
|
156
153
|
if (cwd) {
|
|
157
|
-
const name = clip(path.basename(cwd.replace(/[/\\]+$/, "")) || cwd,
|
|
154
|
+
const name = clip(path.basename(cwd.replace(/[/\\]+$/, "")) || cwd, 60);
|
|
158
155
|
try { v["loc.cwd"] = _link(name, pathToFileURL(cwd).href); } catch { v["loc.cwd"] = name; }
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
156
|
+
// git fields come from the folded snapshot the async hook wrote, not a live spawn.
|
|
157
|
+
const { br = null, lr = null, st = null } = git || {};
|
|
158
|
+
if (br) { const brLbl = clip(br, 60); v["git.branch"] = repoUrl ? _link(brLbl, `${repoUrl}/tree/${br}`) : brLbl; }
|
|
162
159
|
if (lr && lr.includes("\t")) {
|
|
163
160
|
const [behind, ahead] = lr.split("\t");
|
|
164
161
|
v["git.behind"] = `↓${behind}`; v["git.ahead"] = `↑${ahead}`;
|
|
165
162
|
}
|
|
166
|
-
const st = git(cwd, "status", "--porcelain");
|
|
167
163
|
if (st !== null) v["git.status"] = String(st.split("\n").filter((l) => l.trim()).length);
|
|
168
164
|
// Merge changed-file count + pull/push onto one line (icons baked in, like model.mode):
|
|
169
165
|
// "✎ <files> ⇅ ↓<behind> ↑<ahead>" — files first, then pull/push.
|
|
@@ -189,14 +185,54 @@ function _pick(bucket) {
|
|
|
189
185
|
return x % 3;
|
|
190
186
|
}
|
|
191
187
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
188
|
+
// Fold the per-session journal into the checkpoint and return the folded state.
|
|
189
|
+
//
|
|
190
|
+
// Read cost is O(events since last render): we read only journal bytes past the stored
|
|
191
|
+
// offset, so a multi-hour session never re-reads old events. Invariant: checkpoint.state
|
|
192
|
+
// always equals fold(journal[0..offset]); state + offset are persisted together atomically,
|
|
193
|
+
// so the reducer's push/increment ops never double-count across renders.
|
|
194
|
+
export function loadState(data) {
|
|
195
|
+
const { checkpoint, journal } = statePaths(data.session_id);
|
|
196
|
+
|
|
197
|
+
let size = -1;
|
|
198
|
+
try { size = statSync(journal).size; } catch { /* no journal yet */ }
|
|
199
|
+
|
|
200
|
+
let raw = null;
|
|
201
|
+
try { raw = readFileSync(checkpoint, "utf8"); } catch { /* no checkpoint */ }
|
|
202
|
+
let st;
|
|
203
|
+
if (raw === null) {
|
|
204
|
+
st = { offset: 0 }; // no checkpoint -> recompute from journal start
|
|
205
|
+
} else {
|
|
206
|
+
try { st = JSON.parse(raw); } catch { st = null; }
|
|
207
|
+
if (!st || typeof st !== "object") st = { offset: 0 }; // corrupt -> full recompute (journal has full history)
|
|
208
|
+
else if (typeof st.offset !== "number") st.offset = Math.max(0, size); // externally-supplied state is current
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (size < 0) return st; // no journal -> state stands as-is
|
|
212
|
+
if (st.offset > size) st = { offset: 0 }; // journal truncated/reset -> recompute from 0
|
|
213
|
+
|
|
214
|
+
if (size > st.offset) {
|
|
215
|
+
let chunk = "";
|
|
216
|
+
try {
|
|
217
|
+
const fd = openSync(journal, "r");
|
|
218
|
+
const buf = Buffer.alloc(size - st.offset);
|
|
219
|
+
readSync(fd, buf, 0, buf.length, st.offset);
|
|
220
|
+
closeSync(fd);
|
|
221
|
+
chunk = buf.toString("utf8");
|
|
222
|
+
} catch { chunk = ""; }
|
|
223
|
+
const lastNl = chunk.lastIndexOf("\n"); // consume complete lines only; keep any partial tail
|
|
224
|
+
if (lastNl >= 0) {
|
|
225
|
+
foldBatch(st, chunk.slice(0, lastNl).split("\n"));
|
|
226
|
+
st.offset += Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
197
229
|
|
|
198
|
-
|
|
199
|
-
|
|
230
|
+
try { // persist state + offset together, atomically
|
|
231
|
+
const tmp = `${checkpoint}.${process.pid}.tmp`;
|
|
232
|
+
writeFileSync(tmp, JSON.stringify(st));
|
|
233
|
+
renameSync(tmp, checkpoint);
|
|
234
|
+
} catch { /* ignore: next render retries */ }
|
|
235
|
+
return st;
|
|
200
236
|
}
|
|
201
237
|
|
|
202
238
|
function ramPercent() {
|
|
@@ -394,9 +430,9 @@ function main() {
|
|
|
394
430
|
const cfg = parseToml(readFileSync(preset, "utf8"));
|
|
395
431
|
|
|
396
432
|
const now = Date.now() / 1000;
|
|
397
|
-
const st =
|
|
433
|
+
const st = loadState(data);
|
|
398
434
|
const cwd = data.cwd || (data.workspace || {}).current_dir;
|
|
399
|
-
const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
|
|
435
|
+
const values = { ...buildValues(data, st.git), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
|
|
400
436
|
const [advModel, advTs] = advisorInfo(data.transcript_path || "");
|
|
401
437
|
if (advModel) values["advisor.model"] = advModel;
|
|
402
438
|
const god_until = godFlash(data, advTs, now);
|
|
@@ -437,7 +473,9 @@ function main() {
|
|
|
437
473
|
catch { return null; }
|
|
438
474
|
};
|
|
439
475
|
const selected = resolvePreset(cfg, target, loadByName, spriteFor);
|
|
440
|
-
|
|
476
|
+
// Text overflow behavior: env wins, then the preset's text_overflow, default "scroll".
|
|
477
|
+
const overflow = process.env.DOOMBAR_TEXT_OVERFLOW || selected.text_overflow || cfg.text_overflow || "scroll";
|
|
478
|
+
const res = buildBar(selected, target, spriteFor, tick, overflow);
|
|
441
479
|
process.stdout.write(res.lines.join("\n") + "\n");
|
|
442
480
|
}
|
|
443
481
|
|