claude-doom-statusbar 0.6.0 → 0.7.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/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
- lst.push({ hooks: [{ type: "command", command: HOOK_CMD }] });
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.6.0",
3
+ "version": "0.7.0",
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/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 event-bus for the DOOM HUD. Port of hooks/mugshot_hook.py.
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
- // State carries: face reaction {expr, ts}; activity spans[] [start,end] (geiger),
7
- // squad{} (running subagents), pending[] (Agent launch labels), tasks{} (id ->
8
- // {title,status,ts}), tasks_ts (last tasks mutation), errors, mode (permission mode). Always exits 0.
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
- // State file: $MUGSHOT_STATE, else <temp>/mugshot_<session_id>.json.
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 { readFileSync, writeFileSync, renameSync } from "node:fs";
13
- import os from "node:os";
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
- const GEIGER_WINDOW = 30.0; // seconds of tool-run history kept for the sparkline
18
- const MAX_RUN = 300.0; // drop an unclosed span after this (assume the Post was lost)
19
- const TASK_LINGER = 10.0; // seconds the TASKS box lingers after all tasks settle
20
-
21
- const READ_TOOLS = new Set(["Read", "Grep", "Glob",
22
- "ctx_read", "ctx_multi_read", "ctx_search", "ctx_semantic_search", "ctx_tree", "ctx_overview"]);
23
- const WRITE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash", "ctx_shell", "ctx_edit"]);
24
-
25
- function statePath(ev) {
26
- if (process.env.MUGSHOT_STATE) return process.env.MUGSHOT_STATE;
27
- const sid = String(ev.session_id || "default").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48);
28
- return path.join(os.tmpdir(), `mugshot_${sid}.json`);
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 base(tool) {
32
- return tool.startsWith("mcp__") ? tool.split("__").pop() : tool;
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
- // Task event subject: the exact key isn't documented, so try the likely ones.
36
- function taskTitle(ev) {
37
- return ev.task_title || ev.task_subject || ev.subject || ev.title ||
38
- (ev.tool_input && ev.tool_input.subject) || "task";
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
- 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;
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
- 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 = {};
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 p = statePath(ev);
119
-
120
- let st = {};
121
- try { st = JSON.parse(readFileSync(p, "utf8")); } catch { st = {}; }
122
-
123
- foldActivity(st, name, ev, now);
124
- const expr = expression(name, ev.tool_name || "");
125
- if (expr) { st.expr = expr; st.ts = now; }
126
- if (ev.permission_mode) st.mode = ev.permission_mode;
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
- const tmp = `${p}.${process.pid}.tmp`;
129
- try { writeFileSync(tmp, JSON.stringify(st)); renameSync(tmp, p); } catch { /* never block */ }
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/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
- function git(cwd, ...args) {
36
- try {
37
- const r = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 1000 });
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);
@@ -156,14 +151,13 @@ export function buildValues(data) {
156
151
  if (cwd) {
157
152
  const name = clip(path.basename(cwd.replace(/[/\\]+$/, "")) || cwd, 24);
158
153
  try { v["loc.cwd"] = _link(name, pathToFileURL(cwd).href); } catch { v["loc.cwd"] = name; }
159
- const br = git(cwd, "branch", "--show-current");
154
+ // git fields come from the folded snapshot the async hook wrote, not a live spawn.
155
+ const { br = null, lr = null, st = null } = git || {};
160
156
  if (br) { const brLbl = clip(br, 24); v["git.branch"] = repoUrl ? _link(brLbl, `${repoUrl}/tree/${br}`) : brLbl; }
161
- const lr = git(cwd, "rev-list", "--count", "--left-right", "@{u}...HEAD");
162
157
  if (lr && lr.includes("\t")) {
163
158
  const [behind, ahead] = lr.split("\t");
164
159
  v["git.behind"] = `↓${behind}`; v["git.ahead"] = `↑${ahead}`;
165
160
  }
166
- const st = git(cwd, "status", "--porcelain");
167
161
  if (st !== null) v["git.status"] = String(st.split("\n").filter((l) => l.trim()).length);
168
162
  // Merge changed-file count + pull/push onto one line (icons baked in, like model.mode):
169
163
  // "✎ <files> ⇅ ↓<behind> ↑<ahead>" — files first, then pull/push.
@@ -189,14 +183,54 @@ function _pick(bucket) {
189
183
  return x % 3;
190
184
  }
191
185
 
192
- function statePath(data) {
193
- if (process.env.MUGSHOT_STATE) return process.env.MUGSHOT_STATE;
194
- const sid = String(data.session_id || "default").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48);
195
- return path.join(TMP, `mugshot_${sid}.json`);
196
- }
186
+ // Fold the per-session journal into the checkpoint and return the folded state.
187
+ //
188
+ // Read cost is O(events since last render): we read only journal bytes past the stored
189
+ // offset, so a multi-hour session never re-reads old events. Invariant: checkpoint.state
190
+ // always equals fold(journal[0..offset]); state + offset are persisted together atomically,
191
+ // so the reducer's push/increment ops never double-count across renders.
192
+ export function loadState(data) {
193
+ const { checkpoint, journal } = statePaths(data.session_id);
194
+
195
+ let size = -1;
196
+ try { size = statSync(journal).size; } catch { /* no journal yet */ }
197
+
198
+ let raw = null;
199
+ try { raw = readFileSync(checkpoint, "utf8"); } catch { /* no checkpoint */ }
200
+ let st;
201
+ if (raw === null) {
202
+ st = { offset: 0 }; // no checkpoint -> recompute from journal start
203
+ } else {
204
+ try { st = JSON.parse(raw); } catch { st = null; }
205
+ if (!st || typeof st !== "object") st = { offset: 0 }; // corrupt -> full recompute (journal has full history)
206
+ else if (typeof st.offset !== "number") st.offset = Math.max(0, size); // externally-supplied state is current
207
+ }
208
+
209
+ if (size < 0) return st; // no journal -> state stands as-is
210
+ if (st.offset > size) st = { offset: 0 }; // journal truncated/reset -> recompute from 0
211
+
212
+ if (size > st.offset) {
213
+ let chunk = "";
214
+ try {
215
+ const fd = openSync(journal, "r");
216
+ const buf = Buffer.alloc(size - st.offset);
217
+ readSync(fd, buf, 0, buf.length, st.offset);
218
+ closeSync(fd);
219
+ chunk = buf.toString("utf8");
220
+ } catch { chunk = ""; }
221
+ const lastNl = chunk.lastIndexOf("\n"); // consume complete lines only; keep any partial tail
222
+ if (lastNl >= 0) {
223
+ foldBatch(st, chunk.slice(0, lastNl).split("\n"));
224
+ st.offset += Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
225
+ }
226
+ }
197
227
 
198
- function readState(data) {
199
- try { return JSON.parse(readFileSync(statePath(data), "utf8")); } catch { return {}; }
228
+ try { // persist state + offset together, atomically
229
+ const tmp = `${checkpoint}.${process.pid}.tmp`;
230
+ writeFileSync(tmp, JSON.stringify(st));
231
+ renameSync(tmp, checkpoint);
232
+ } catch { /* ignore: next render retries */ }
233
+ return st;
200
234
  }
201
235
 
202
236
  function ramPercent() {
@@ -394,9 +428,9 @@ function main() {
394
428
  const cfg = parseToml(readFileSync(preset, "utf8"));
395
429
 
396
430
  const now = Date.now() / 1000;
397
- const st = readState(data);
431
+ const st = loadState(data);
398
432
  const cwd = data.cwd || (data.workspace || {}).current_dir;
399
- const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
433
+ const values = { ...buildValues(data, st.git), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
400
434
  const [advModel, advTs] = advisorInfo(data.transcript_path || "");
401
435
  if (advModel) values["advisor.model"] = advModel;
402
436
  const god_until = godFlash(data, advTs, now);