Fix new-window context leak showing previous session ctx%

Stop tmux status from falling back to global status files, seed new
tmux instances at 0%, rank live sessions by opened_at instead of
signals.json keepalive mtime, and bind Grok pids to tmux more reliably.
This commit is contained in:
2026-07-17 16:53:41 +08:00
parent 9225922a10
commit ed1a1ade45
10 changed files with 299 additions and 28 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.3.13
### Fixes
- **New window context leak**: brand-new Terminal no longer shows the previous
sessions high ctx%. Root causes fixed:
1. tmux status **no longer falls back** to global `~/.grok/hud/tmux-*.txt`
2. new tmux instances are **seeded at ctx 0%**
3. session ranking ignores `signals.json` mtime keepalive and prefers newest
`opened_at` among live sessions
4. stronger pid/tty → tmux binding via `resolveTmuxSessionForGrok`
## 0.3.12 ## 0.3.12
### Repo / docs ### Repo / docs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "grok-build-hud", "name": "grok-build-hud",
"version": "0.3.12", "version": "0.3.13",
"description": "第三方 Grok Build 状态条(非 xAI 官方):同窗口上下文/配额/token/工具;plugin + CLI + tmux HUD", "description": "第三方 Grok Build 状态条(非 xAI 官方):同窗口上下文/配额/token/工具;plugin + CLI + tmux HUD",
"type": "module", "type": "module",
"main": "dist/src/index.js", "main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "grok-build-hud", "name": "grok-build-hud",
"version": "0.3.12", "version": "0.3.13",
"description": "第三方 Grok Build 状态条(非官方):同窗口上下文/配额/token/工具/待办;tmux HUD + 会话斜杠命令", "description": "第三方 Grok Build 状态条(非官方):同窗口上下文/配额/token/工具/待办;tmux HUD + 会话斜杠命令",
"author": { "author": {
"name": "Redredchen01" "name": "Redredchen01"
+6 -6
View File
@@ -18,7 +18,7 @@ import {
} from "./session.js"; } from "./session.js";
import { getCreditUsage } from "./billing.js"; import { getCreditUsage } from "./billing.js";
import { writeStatusFiles, formatCompactLine } from "./status.js"; import { writeStatusFiles, formatCompactLine } from "./status.js";
import { findTmuxSessionForPid } from "./multi-session.js"; import { resolveTmuxSessionForGrok } from "./multi-session.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js"; import type { SessionSnapshot, UsageSnapshot } from "./types.js";
export function dashboardPidPath(grokHome = defaultGrokHome()): string { export function dashboardPidPath(grokHome = defaultGrokHome()): string {
@@ -178,17 +178,17 @@ export async function refreshDashboard(options: {
/* ignore */ /* ignore */
} }
// Write per-tmux-session status so each Terminal bar shows ITS own Grok // Write per-tmux-session status so each Terminal bar shows ITS own Grok.
// Layout adapts to each window's client width. // New windows are seeded at ctx 0% and never read global status (tmux-hud).
for (const snap of targets) { for (const snap of targets) {
const tmuxSession = findTmuxSessionForPid(snap.pid); const tmuxSession = resolveTmuxSessionForGrok(snap.pid, grokHome);
const tty = ttyForPid(snap.pid); const tty = ttyForPid(snap.pid);
const isPrimary = const isPrimary =
primary != null && snap.sessionId === primary.sessionId; primary != null && snap.sessionId === primary.sessionId;
writeStatusFiles(snap, usage, grokHome, { writeStatusFiles(snap, usage, grokHome, {
tmuxSession, tmuxSession,
ttyPath: tty, ttyPath: tty,
// Only primary overwrites global status.* (hooks / grok-hud status) // Only primary overwrites global status.* (hooks / grok-hud status CLI)
writeGlobal: isPrimary || targets.length === 1, writeGlobal: isPrimary || targets.length === 1,
}); });
@@ -202,7 +202,7 @@ export async function refreshDashboard(options: {
// If somehow no primary write happened, write one // If somehow no primary write happened, write one
if (primary && !targets.some((t) => t.sessionId === primary!.sessionId)) { if (primary && !targets.some((t) => t.sessionId === primary!.sessionId)) {
writeStatusFiles(primary, usage, grokHome, { writeStatusFiles(primary, usage, grokHome, {
tmuxSession: findTmuxSessionForPid(primary.pid), tmuxSession: resolveTmuxSessionForGrok(primary.pid, grokHome),
writeGlobal: true, writeGlobal: true,
}); });
} }
+12 -1
View File
@@ -173,7 +173,18 @@ export async function runHookTick(
} }
} }
const written = writeStatusFiles(snap, usage, grokHome); // Bind to THIS Terminal's tmux instance when env is set (set by grok wrap).
// Avoid overwriting global status with a stale high-ctx session from another tab
// when this hook is for a brand-new session.
const tmuxSession =
env.GROK_HUD_TMUX_SESSION?.trim() ||
env.GROK_TMUX_SESSION?.trim() ||
null;
const written = writeStatusFiles(snap, usage, grokHome, {
tmuxSession,
// Always refresh global for CLI `grok-hud status`, but per-tmux is authoritative in UI
writeGlobal: true,
});
const annotate = const annotate =
options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome); options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome);
if (annotate) { if (annotate) {
+64
View File
@@ -163,6 +163,70 @@ export function findTmuxSessionForPid(grokPid?: number): string | null {
return null; return null;
} }
/** tty device base for a pid (e.g. ttys012), or null. */
export function ttyBaseForPid(pid?: number): string | null {
if (!pid || pid <= 0) return null;
try {
const out = execFileSync("ps", ["-p", String(pid), "-o", "tty="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
}).trim();
if (!out || out === "??" || out === "-") return null;
return out.replace(/^\/dev\//, "");
} catch {
return null;
}
}
/**
* Resolve tmux session for a live Grok process.
* 1) process tree → pane
* 2) meta.json launcherPid / tty match (set at `grok` wrap time)
* Never invent a name — caller must treat null as "do not write global for this".
*/
export function resolveTmuxSessionForGrok(
grokPid?: number,
grokHome = defaultGrokHome(),
): string | null {
const byTree = findTmuxSessionForPid(grokPid);
if (byTree) return byTree;
if (!grokPid || grokPid <= 0) return null;
const tty = ttyBaseForPid(grokPid);
const root = path.join(grokHome, "hud", "tmux");
if (!fs.existsSync(root)) return null;
try {
for (const name of fs.readdirSync(root)) {
const metaPath = path.join(root, name, "meta.json");
if (!fs.existsSync(metaPath)) continue;
let meta: TmuxInstanceMeta;
try {
meta = JSON.parse(fs.readFileSync(metaPath, "utf8")) as TmuxInstanceMeta;
} catch {
continue;
}
if (meta.launcherPid && isInProcessTree(grokPid, meta.launcherPid)) {
return meta.tmuxSession || name;
}
if (meta.launcherPid === grokPid) {
return meta.tmuxSession || name;
}
if (tty && meta.tty) {
const mt = meta.tty.replace(/^\/dev\//, "");
if (mt && (tty === mt || tty.endsWith(mt) || mt.endsWith(tty))) {
return meta.tmuxSession || name;
}
}
}
} catch {
/* ignore */
}
return null;
}
/** List instance dirs under hud/tmux/ that look like our sessions. */ /** List instance dirs under hud/tmux/ that look like our sessions. */
export function listHudTmuxSessions(grokHome = defaultGrokHome()): string[] { export function listHudTmuxSessions(grokHome = defaultGrokHome()): string[] {
const root = path.join(grokHome, "hud", "tmux"); const root = path.join(grokHome, "hud", "tmux");
+60 -4
View File
@@ -96,6 +96,10 @@ export function parseTimeMs(value?: string | null): number {
* After /new, both old and new Grok processes can stay live; array order in * After /new, both old and new Grok processes can stay live; array order in
* active_sessions.json is insertion order (oldest first), so we must rank by * active_sessions.json is insertion order (oldest first), so we must rank by
* activity — not by list position. * activity — not by list position.
*
* IMPORTANT: do NOT rank by signals.json mtime alone. An older live session
* keeps rewriting signals while a brand-new Terminal is still at ctx 0%, and
* that made the HUD "stick" on the old high context.
*/ */
export function sessionRecencyMs( export function sessionRecencyMs(
session: SessionSnapshot, session: SessionSnapshot,
@@ -105,13 +109,19 @@ export function sessionRecencyMs(
const lastActive = parseTimeMs(session.summary?.last_active_at); const lastActive = parseTimeMs(session.summary?.last_active_at);
const updated = parseTimeMs(session.summary?.updated_at); const updated = parseTimeMs(session.summary?.updated_at);
const created = parseTimeMs(session.summary?.created_at); const created = parseTimeMs(session.summary?.created_at);
// User-facing activity only (not signals keepalive rewrites)
const semantic = Math.max(opened, lastActive, updated, created);
const fileM = Math.max( const fileM = Math.max(
mtimeMs(path.join(session.sessionDir, "signals.json")),
mtimeMs(path.join(session.sessionDir, "summary.json")), mtimeMs(path.join(session.sessionDir, "summary.json")),
mtimeMs(path.join(session.sessionDir, "updates.jsonl")), mtimeMs(path.join(session.sessionDir, "updates.jsonl")),
mtimeMs(path.join(session.sessionDir, "chat_history.jsonl")), mtimeMs(path.join(session.sessionDir, "chat_history.jsonl")),
); );
return Math.max(opened, lastActive, updated, created, fileM); // Prefer semantic timestamps; use file mtime only as a weak boost when
// semantic exists (never let signals-only rewrites dominate).
if (semantic > 0) {
return Math.max(semantic, fileM);
}
return fileM;
} }
/** live first, then most recently active. */ /** live first, then most recently active. */
@@ -136,6 +146,10 @@ export function sortSessionsByRecency(
/** /**
* Prefer the newest live session among active_sessions. * Prefer the newest live session among active_sessions.
* Fixes HUD stuck on an older tab's ctx% after the user runs /new. * Fixes HUD stuck on an older tab's ctx% after the user runs /new.
*
* When several sessions are live, break ties with opened_at (newest Terminal
* /newest /new wins) so a brand-new window at ctx 0% is not displaced by an
* older busy session that still rewrites signals.
*/ */
export function pickFromActiveSessions( export function pickFromActiveSessions(
grokHome: string, grokHome: string,
@@ -162,12 +176,54 @@ export function pickFromActiveSessions(
const ranked = sortSessionsByRecency(snaps, active); const ranked = sortSessionsByRecency(snaps, active);
if (options.preferLive !== false) { if (options.preferLive !== false) {
const live = ranked.find((s) => s.live); const live = ranked.filter((s) => s.live);
if (live) return live; if (live.length === 1) return live[0]!;
if (live.length > 1) {
// Newest open wins among live (new window / /new)
return [...live].sort((a, b) => {
const ae = active.find((x) => x.session_id === a.sessionId);
const be = active.find((x) => x.session_id === b.sessionId);
const ao = parseTimeMs(ae?.opened_at);
const bo = parseTimeMs(be?.opened_at);
if (ao !== bo) return bo - ao;
return sessionRecencyMs(b, be) - sessionRecencyMs(a, ae);
})[0]!;
}
} }
return ranked[0] ?? null; return ranked[0] ?? null;
} }
/** Minimal snapshot for a brand-new Terminal (ctx 0%, no session yet). */
export function emptySessionSnapshot(
overrides: Partial<SessionSnapshot> = {},
): SessionSnapshot {
return {
sessionId: "—",
sessionDir: "",
cwd: "",
model: "—",
live: false,
contextPercent: 0,
contextTokensUsed: 0,
contextWindowTokens: 0,
turnCount: 0,
userMessageCount: 0,
toolCallCount: 0,
toolFailureCount: 0,
errorCount: 0,
durationSeconds: 0,
agentLinesAdded: 0,
agentLinesRemoved: 0,
compactionCount: 0,
avgTtftMs: 0,
tools: [],
agents: [],
todos: [],
signals: {},
...overrides,
};
}
export function loadSnapshotFromDir( export function loadSnapshotFromDir(
sessionDir: string, sessionDir: string,
options: { options: {
+33 -15
View File
@@ -13,11 +13,31 @@ import { resolveTheme, tmuxStatusChrome } from "./theme.js";
import { loadHudConfig } from "./hud-config.js"; import { loadHudConfig } from "./hud-config.js";
import { installGrokCommandWrap } from "./grok-wrap.js"; import { installGrokCommandWrap } from "./grok-wrap.js";
import { import {
detectControllingTtyBase,
hudTmuxDir, hudTmuxDir,
uniqueTmuxSessionName, uniqueTmuxSessionName,
writeTmuxInstanceMeta, writeTmuxInstanceMeta,
} from "./multi-session.js"; } from "./multi-session.js";
/** Brand-new Terminal status: ctx 0%, no leakage from previous windows. */
export function seedIdleTmuxStatus(instDir: string): void {
fs.mkdirSync(instDir, { recursive: true });
const idleSingle = "窗 ░░░░░░░░░░ 0% · —\n";
const idleLines = [
"— · —",
"窗 ░░░░░░░░░░ 0% · —",
"",
].join("\n") + "\n";
fs.writeFileSync(path.join(instDir, "tmux-status.txt"), idleSingle, "utf8");
fs.writeFileSync(path.join(instDir, "tmux-lines.txt"), idleLines, "utf8");
fs.writeFileSync(path.join(instDir, "status-line.txt"), "[hud] ctx 0%\n", "utf8");
}
function detectControllingTtyPath(): string | null {
const base = detectControllingTtyBase();
return base ? `/dev/${base}` : null;
}
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.TMUX && env.TMUX.length > 0); return Boolean(env.TMUX && env.TMUX.length > 0);
} }
@@ -61,8 +81,9 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
// #{session_name} expanded by tmux before running #() — one bar per session // #{session_name} expanded by tmux before running #() — one bar per session
const scopedLines = `${instRoot}/#{session_name}/tmux-lines.txt`; const scopedLines = `${instRoot}/#{session_name}/tmux-lines.txt`;
const scopedSingle = `${instRoot}/#{session_name}/tmux-status.txt`; const scopedSingle = `${instRoot}/#{session_name}/tmux-status.txt`;
const fallbackLines = path.join(grokHome, "hud", "tmux-lines.txt"); // NEVER fall back to global ~/.grok/hud/tmux-*.txt — that leaked the previous
const fallbackSingle = path.join(grokHome, "hud", "tmux-status.txt"); // Terminal's high ctx% into brand-new windows (should show 0% / idle).
const idleSingle = "窗 ░░░░░░░░░░ 0% · —";
// Up to 3 status rows in the SAME terminal window // Up to 3 status rows in the SAME terminal window
const statusLines = Math.max( const statusLines = Math.max(
1, 1,
@@ -91,15 +112,20 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
"set", "set",
"-g", "-g",
"status-format[0]", "status-format[0]",
`#[align=left]#(cat ${scopedSingle} 2>/dev/null || cat ${fallbackSingle} 2>/dev/null)`, `#[align=left]#(cat ${scopedSingle} 2>/dev/null || printf '%s' '${idleSingle}')`,
]); ]);
} else { } else {
for (let i = 0; i < statusLines; i++) { for (let i = 0; i < statusLines; i++) {
// Line 0 gets idle placeholder; other rows blank when scoped missing
const idle =
i === 0
? `printf '%s' '${idleSingle}'`
: `printf ''`;
cmds.push([ cmds.push([
"set", "set",
"-g", "-g",
`status-format[${i}]`, `status-format[${i}]`,
`#[align=left]#(sed -n '${i + 1}p' ${scopedLines} 2>/dev/null || sed -n '${i + 1}p' ${fallbackLines} 2>/dev/null)`, `#[align=left]#(sed -n '${i + 1}p' ${scopedLines} 2>/dev/null || ${idle})`,
]); ]);
} }
} }
@@ -180,22 +206,14 @@ export function execGrokInSameWindowTmux(options: {
const instDir = hudTmuxDir(session, grokHome); const instDir = hudTmuxDir(session, grokHome);
fs.mkdirSync(instDir, { recursive: true }); fs.mkdirSync(instDir, { recursive: true });
const statusFile = path.join(instDir, "tmux-status.txt"); // Seed THIS tmux only with ctx 0% — never copy global / previous window state
if (!fs.existsSync(statusFile)) { seedIdleTmuxStatus(instDir);
fs.writeFileSync(statusFile, "ctx … · quota …\n", "utf8");
}
// Also ensure global fallback placeholders
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
const globalStatus = path.join(grokHome, "hud", "tmux-status.txt");
if (!fs.existsSync(globalStatus)) {
fs.writeFileSync(globalStatus, "ctx … · quota …\n", "utf8");
}
writeTmuxInstanceMeta({ writeTmuxInstanceMeta({
tmuxSession: session, tmuxSession: session,
launcherPid: process.pid, launcherPid: process.pid,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
tty: process.env.TTY ?? null, tty: process.env.TTY ?? detectControllingTtyPath() ?? null,
}, grokHome); }, grokHome);
writeTmuxConfFile(grokHome); writeTmuxConfFile(grokHome);
+89
View File
@@ -62,6 +62,95 @@ describe("dashboard", () => {
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
}); });
it("new window: old live session with fresher signals mtime does not steal primary", async () => {
// Repro: old tab still live and signals.json keeps updating; new tab has
// later opened_at but no/low context — must pick new (ctx ~0), not old 78%.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-newwin-"));
const cwd = "/Users/dex/AI FILM SPACE/0717";
const oldId = "old-busy-78";
const newId = "new-window-zero";
const enc = encodeURIComponent(cwd);
const oldDir = path.join(tmp, "sessions", enc, oldId);
const newDir = path.join(tmp, "sessions", enc, newId);
fs.mkdirSync(oldDir, { recursive: true });
fs.mkdirSync(newDir, { recursive: true });
const oldSignals = {
contextWindowUsage: 78,
contextTokensUsed: 392180,
contextWindowTokens: 500000,
turnCount: 18,
toolCallCount: 344,
primaryModelId: "grok-4.5",
};
fs.writeFileSync(path.join(oldDir, "signals.json"), JSON.stringify(oldSignals));
fs.writeFileSync(
path.join(oldDir, "summary.json"),
JSON.stringify({
info: { id: oldId, cwd },
last_active_at: "2026-07-17T06:00:00.000Z",
updated_at: "2026-07-17T06:00:00.000Z",
created_at: "2026-07-17T04:00:00.000Z",
current_model_id: "grok-4.5",
}),
);
// Touch signals again so mtime is "now" (old session keepalive)
const now = Date.now();
fs.utimesSync(path.join(oldDir, "signals.json"), new Date(now), new Date(now));
fs.writeFileSync(
path.join(newDir, "summary.json"),
JSON.stringify({
info: { id: newId, cwd },
last_active_at: "2026-07-17T08:00:00.000Z",
updated_at: "2026-07-17T08:00:00.000Z",
created_at: "2026-07-17T08:00:00.000Z",
current_model_id: "grok-4.5",
}),
);
// Explicit empty/low context for new window
fs.writeFileSync(
path.join(newDir, "signals.json"),
JSON.stringify({
contextWindowUsage: 0,
contextTokensUsed: 0,
contextWindowTokens: 500000,
turnCount: 0,
toolCallCount: 0,
primaryModelId: "grok-4.5",
}),
);
fs.writeFileSync(
path.join(tmp, "active_sessions.json"),
JSON.stringify([
{
session_id: oldId,
pid: process.pid,
cwd,
opened_at: "2026-07-17T04:00:00.000Z",
},
{
session_id: newId,
pid: process.pid,
cwd,
opened_at: "2026-07-17T08:00:00.000Z",
},
]),
);
const r = await refreshDashboard({ grokHome: tmp, noUsage: true });
assert.ok(r.session);
assert.equal(r.session!.sessionId, newId);
assert.ok(
Math.round(r.session!.contextPercent) < 5,
`new window must be ~0% ctx, got ${r.session!.contextPercent}`,
);
assert.doesNotMatch(r.title, /78%/);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("after /new: prefers newest active session, not first-listed old 78%", async () => { it("after /new: prefers newest active session, not first-listed old 78%", async () => {
// Repro: active_sessions keeps old tab first; new tab second with low/no signals. // Repro: active_sessions keeps old tab first; new tab second with low/no signals.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-new-")); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-new-"));
+22
View File
@@ -1,11 +1,14 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { import {
uniqueTmuxSessionName, uniqueTmuxSessionName,
sanitizeId, sanitizeId,
isInProcessTree, isInProcessTree,
parentPid, parentPid,
} from "../src/multi-session.js"; } from "../src/multi-session.js";
import { tmuxStatusCommands } from "../src/tmux-hud.js";
describe("multi-session isolation", () => { describe("multi-session isolation", () => {
it("uniqueTmuxSessionName never returns fixed grok-hud", () => { it("uniqueTmuxSessionName never returns fixed grok-hud", () => {
@@ -48,4 +51,23 @@ describe("multi-session isolation", () => {
assert.equal(isInProcessTree(process.pid, pp), true); assert.equal(isInProcessTree(process.pid, pp), true);
} }
}); });
it("tmux status format never falls back to global status (new window isolation)", () => {
const home = path.join(os.tmpdir(), "hud-fake-home");
const cmds = tmuxStatusCommands(home);
const formats = cmds
.filter((a) => a[0] === "set" && String(a[2] ?? "").startsWith("status-format"))
.map((a) => a.slice(3).join(" "));
assert.ok(formats.length >= 1);
for (const f of formats) {
// Must only read per-session path; global fallback leaked old ctx%
assert.match(f, /hud\/tmux\/#\{session_name\}/);
assert.doesNotMatch(
f,
/hud\/tmux-lines\.txt|hud\/tmux-status\.txt(?!\/)/,
);
// No "|| cat …/hud/tmux-status" style global fallback
assert.doesNotMatch(f, /\|\|\s*cat\s+\S*\/hud\/tmux-/);
}
});
}); });