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
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+6
-6
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+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 =
|
||||
options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome);
|
||||
if (annotate) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+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
|
||||
* 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> = {},
|
||||
): 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: {
|
||||
|
||||
+33
-15
@@ -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);
|
||||
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user