diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a6f03..4b54413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.13 + +### Fixes +- **New window context leak**: brand-new Terminal no longer shows the previous + session’s 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 ### Repo / docs diff --git a/package.json b/package.json index 458d10f..44e8ec9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grok-build-hud", - "version": "0.3.12", + "version": "0.3.13", "description": "第三方 Grok Build 状态条(非 xAI 官方):同窗口上下文/配额/token/工具;plugin + CLI + tmux HUD", "type": "module", "main": "dist/src/index.js", diff --git a/plugin.json b/plugin.json index de37ff6..9b0742a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "grok-build-hud", - "version": "0.3.12", + "version": "0.3.13", "description": "第三方 Grok Build 状态条(非官方):同窗口上下文/配额/token/工具/待办;tmux HUD + 会话斜杠命令", "author": { "name": "Redredchen01" diff --git a/src/dashboard.ts b/src/dashboard.ts index 42e5c21..c971400 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -18,7 +18,7 @@ import { } from "./session.js"; import { getCreditUsage } from "./billing.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"; export function dashboardPidPath(grokHome = defaultGrokHome()): string { @@ -178,17 +178,17 @@ export async function refreshDashboard(options: { /* ignore */ } - // Write per-tmux-session status so each Terminal bar shows ITS own Grok - // Layout adapts to each window's client width. + // Write per-tmux-session status so each Terminal bar shows ITS own Grok. + // New windows are seeded at ctx 0% and never read global status (tmux-hud). for (const snap of targets) { - const tmuxSession = findTmuxSessionForPid(snap.pid); + const tmuxSession = resolveTmuxSessionForGrok(snap.pid, grokHome); const tty = ttyForPid(snap.pid); const isPrimary = primary != null && snap.sessionId === primary.sessionId; writeStatusFiles(snap, usage, grokHome, { tmuxSession, 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, }); @@ -202,7 +202,7 @@ export async function refreshDashboard(options: { // If somehow no primary write happened, write one if (primary && !targets.some((t) => t.sessionId === primary!.sessionId)) { writeStatusFiles(primary, usage, grokHome, { - tmuxSession: findTmuxSessionForPid(primary.pid), + tmuxSession: resolveTmuxSessionForGrok(primary.pid, grokHome), writeGlobal: true, }); } diff --git a/src/hook.ts b/src/hook.ts index a10ab10..c88098b 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -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 = options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome); if (annotate) { diff --git a/src/multi-session.ts b/src/multi-session.ts index f11b7c9..5f0d8df 100644 --- a/src/multi-session.ts +++ b/src/multi-session.ts @@ -163,6 +163,70 @@ export function findTmuxSessionForPid(grokPid?: number): string | 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. */ export function listHudTmuxSessions(grokHome = defaultGrokHome()): string[] { const root = path.join(grokHome, "hud", "tmux"); diff --git a/src/session.ts b/src/session.ts index 38a2b41..bf3b4ba 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 * active_sessions.json is insertion order (oldest first), so we must rank by * 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( session: SessionSnapshot, @@ -105,13 +109,19 @@ export function sessionRecencyMs( const lastActive = parseTimeMs(session.summary?.last_active_at); const updated = parseTimeMs(session.summary?.updated_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( - mtimeMs(path.join(session.sessionDir, "signals.json")), mtimeMs(path.join(session.sessionDir, "summary.json")), mtimeMs(path.join(session.sessionDir, "updates.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. */ @@ -136,6 +146,10 @@ export function sortSessionsByRecency( /** * Prefer the newest live session among active_sessions. * 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( grokHome: string, @@ -162,12 +176,54 @@ export function pickFromActiveSessions( const ranked = sortSessionsByRecency(snaps, active); if (options.preferLive !== false) { - const live = ranked.find((s) => s.live); - if (live) return live; + const live = ranked.filter((s) => s.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; } +/** Minimal snapshot for a brand-new Terminal (ctx 0%, no session yet). */ +export function emptySessionSnapshot( + overrides: Partial = {}, +): 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( sessionDir: string, options: { diff --git a/src/tmux-hud.ts b/src/tmux-hud.ts index db534a8..a3ea5a6 100644 --- a/src/tmux-hud.ts +++ b/src/tmux-hud.ts @@ -13,11 +13,31 @@ import { resolveTheme, tmuxStatusChrome } from "./theme.js"; import { loadHudConfig } from "./hud-config.js"; import { installGrokCommandWrap } from "./grok-wrap.js"; import { + detectControllingTtyBase, hudTmuxDir, uniqueTmuxSessionName, writeTmuxInstanceMeta, } 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 { 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 const scopedLines = `${instRoot}/#{session_name}/tmux-lines.txt`; const scopedSingle = `${instRoot}/#{session_name}/tmux-status.txt`; - const fallbackLines = path.join(grokHome, "hud", "tmux-lines.txt"); - const fallbackSingle = path.join(grokHome, "hud", "tmux-status.txt"); + // NEVER fall back to global ~/.grok/hud/tmux-*.txt — that leaked the previous + // 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 const statusLines = Math.max( 1, @@ -91,15 +112,20 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] { "set", "-g", "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 { 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([ "set", "-g", `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); fs.mkdirSync(instDir, { recursive: true }); - const statusFile = path.join(instDir, "tmux-status.txt"); - if (!fs.existsSync(statusFile)) { - 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"); - } + // Seed THIS tmux only with ctx 0% — never copy global / previous window state + seedIdleTmuxStatus(instDir); writeTmuxInstanceMeta({ tmuxSession: session, launcherPid: process.pid, startedAt: new Date().toISOString(), - tty: process.env.TTY ?? null, + tty: process.env.TTY ?? detectControllingTtyPath() ?? null, }, grokHome); writeTmuxConfFile(grokHome); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 460d315..43ec6fe 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -62,6 +62,95 @@ describe("dashboard", () => { 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 () => { // Repro: active_sessions keeps old tab first; new tab second with low/no signals. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-new-")); diff --git a/tests/multi-session.test.ts b/tests/multi-session.test.ts index de245d9..3d5d2eb 100644 --- a/tests/multi-session.test.ts +++ b/tests/multi-session.test.ts @@ -1,11 +1,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; import { uniqueTmuxSessionName, sanitizeId, isInProcessTree, parentPid, } from "../src/multi-session.js"; +import { tmuxStatusCommands } from "../src/tmux-hud.js"; describe("multi-session isolation", () => { it("uniqueTmuxSessionName never returns fixed grok-hud", () => { @@ -48,4 +51,23 @@ describe("multi-session isolation", () => { 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-/); + } + }); });