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:
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## 0.3.12
|
||||||
|
|
||||||
### Repo / docs
|
### Repo / docs
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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-"));
|
||||||
|
|||||||
@@ -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-/);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user