diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 66dc6180ec..bfb33e71b1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -27,7 +27,7 @@ { "name": "genesis-tools-mods", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.1.0", + "version": "1.5.2", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 9b02302176..9f3fc1a885 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,8 +1,9 @@ { "name": "genesis-tools-mods", - "version": "1.1.0", + "version": "1.5.2", "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", "author": { "name": "GenesisTools" - } + }, + "types": "./types/index.d.ts" } diff --git a/plugins/genesis-tools-mods/.gitignore b/plugins/genesis-tools-mods/.gitignore new file mode 100644 index 0000000000..2dc60fe8bb --- /dev/null +++ b/plugins/genesis-tools-mods/.gitignore @@ -0,0 +1,2 @@ +# Claude Code writes this beside .claude-plugin/types/ (ignored there) while it watches the folder. +/tsconfig.json diff --git a/plugins/genesis-tools-mods/hooks/mods.test.ts b/plugins/genesis-tools-mods/hooks/mods.test.ts deleted file mode 100644 index f092db2d93..0000000000 --- a/plugins/genesis-tools-mods/hooks/mods.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - compactionEndsIdleWait, - IDLE_COMPACT_DEFAULTS, - IDLE_NOTICE, - idleCompactSettings, - idleCompactSkip, - idleNotice, -} from "./idle"; -import { fullDiffEnabled, sectionsOf, sectionsOfCalls } from "./sections"; - -const NOTE = [ - "# Diff of toolu_x", - "", - "```sh", - "sed -i s/a/b/ src/a.ts src/b.ts", - "```", - "", - "## /repo/src/a.ts (+1 -1)", - "", - "```diff", - "diff --git a/a.ts b/a.ts", - "index 111..222 100644", - "--- a/a.ts", - "+++ b/a.ts", - "@@ -1,2 +1,2 @@", - "-a", - "+b", - " same", - "```", - "", - "## /repo/src/b.ts (+0 -1)", - "", - "```diff", - "@@ -1,1 +0,0 @@", - "-gone", - "```", - "", - "## Changed, past the file cap", - "- /repo/src/c.ts", -].join("\n"); - -describe("the full-diff mod's note reader", () => { - it("reads each file of a note from its first hunk, and nothing else", () => { - const sections = sectionsOf(NOTE); - - expect(sections.map((section) => section.path)).toEqual(["/repo/src/a.ts", "/repo/src/b.ts"]); - expect(sections[0]?.patch).toBe("@@ -1,2 +1,2 @@\n-a\n+b\n same"); - expect(sections[1]?.patch).toBe("@@ -1,1 +0,0 @@\n-gone"); - expect(sectionsOf("# Diff of toolu_y\n\n```sh\nls\n```\n")).toEqual([]); - }); - - it("reads a group's notes in call order, skips a call with no note, and stops at the budget", async () => { - const notes: Record = { first: NOTE, third: NOTE }; - const read = async (id: string) => { - const note = notes[id]; - - if (note === undefined) { - throw new Error(`no note for ${id}`); - } - - return note; - }; - - const all = await sectionsOfCalls({ ids: ["first", "second", "third"], read, maxChars: 1_000 }); - - expect(all.map((section) => `${section.call} ${section.path}`)).toEqual([ - "first /repo/src/a.ts", - "first /repo/src/b.ts", - "third /repo/src/a.ts", - "third /repo/src/b.ts", - ]); - - const cut = await sectionsOfCalls({ ids: ["first", "third"], read, maxChars: 40 }); - - expect(cut.map((section) => section.path)).toEqual(["/repo/src/a.ts"]); - }); - - it("is on unless the hooks file sets mods.fullDiff to false", () => { - expect(fullDiffEnabled(undefined)).toBe(true); - expect(fullDiffEnabled("not json")).toBe(true); - expect(fullDiffEnabled('{"mods":{"fullDiff":"no"}}')).toBe(true); - expect(fullDiffEnabled('{"mods":{"fullDiff":false}}')).toBe(false); - expect(fullDiffEnabled('{"diff":{}}')).toBe(true); - }); -}); - -describe("idle compaction", () => { - it("reads its settings from the mods section and keeps the default for a bad value", () => { - expect(idleCompactSettings(undefined)).toEqual(IDLE_COMPACT_DEFAULTS); - expect(idleCompactSettings("not json")).toEqual(IDLE_COMPACT_DEFAULTS); - expect( - idleCompactSettings( - JSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 50, idleCompactMinTokens: 0 } }) - ) - ).toEqual({ enabled: false, minutes: 50, minTokens: 0 }); - // 60 minutes or more would fire after the cache expired. - expect(idleCompactSettings(JSON.stringify({ mods: { idleCompactMinutes: 60, idleCompact: "no" } }))).toEqual( - IDLE_COMPACT_DEFAULTS - ); - }); - - it("compacts only when on, drawn and over the floor", () => { - const settings = { enabled: true, minutes: 56, minTokens: 200_000 }; - - expect(idleCompactSkip({ settings, surfaces: 1, tokens: 300_000 })).toBeUndefined(); - expect(idleCompactSkip({ settings: { ...settings, enabled: false }, surfaces: 1, tokens: 300_000 })).toBe( - "mods.idleCompact is off" - ); - expect(idleCompactSkip({ settings, surfaces: 0, tokens: 300_000 })).toContain("no surface"); - expect(idleCompactSkip({ settings, surfaces: 1, tokens: 199_999 })).toContain("under the floor"); - expect(idleCompactSkip({ settings, surfaces: 1, tokens: undefined })).toContain("unknown"); - }); - - it("a precompute, a skipped and a subagent compaction leave the armed timer alone", () => { - expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: false })).toBe(true); - expect(compactionEndsIdleWait({ agentId: undefined, trigger: "manual", skipped: false })).toBe(true); - expect(compactionEndsIdleWait({ agentId: undefined, trigger: "precompute", skipped: false })).toBe(false); - expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: true })).toBe(false); - expect(compactionEndsIdleWait({ agentId: "a1", trigger: "auto", skipped: false })).toBe(false); - }); - - it("writes a notice that starts with the text the session scan counts", () => { - const text = idleNotice({ minutes: 56, tokensBefore: 550_000, tokensAfter: 40_000 }); - - expect(text.startsWith(IDLE_NOTICE)).toBe(true); - expect(text).toContain("550,000 to 40,000 tokens"); - expect(idleNotice({ minutes: 56, tokensBefore: undefined, tokensAfter: 1 })).not.toContain("tokens)"); - }); -}); diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 17590fdc53..0a06750801 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,6 +1,17 @@ -import type { EngineInterface, Register, Timer } from "claude-code"; +import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; +import { atom, read, update } from "claude-code"; +import type { FlippedFiles, ShownCalls } from "../types"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; -import { type CallSection, fullDiffEnabled, sectionsOfCalls } from "./sections"; +import { + type CallSection, + collapseGitSyncEnabled, + commandOf, + fullDiffEnabled, + gitSyncOf, + isDeletion, + openByDefault, + sectionsOfCalls, +} from "./sections"; // The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the // whole diff to ~/.genesis-tools/agents/hook-diffs/.md. This draws those hunks with Claude @@ -27,20 +38,48 @@ async function readHooksFile($: EngineInterface, dir: string | undefined): Promi return dir ? $.fs.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; } -async function fullDiffs($: EngineInterface, ids: readonly string[]): Promise { - const dir = ids.length > 0 ? await agentsDir($) : undefined; +interface Diffs { + sections: CallSection[]; + /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.collapseGitSync` is on. */ + syncs: Map; + /** The notes of the calls past `MAX_CHARS` (`sectionsOfCalls`), named under the diffs so none goes without a trace. */ + omitted: string[]; +} + +async function fullDiffs($: EngineInterface, calls: readonly { id: string; input: unknown }[]): Promise { + const none: Diffs = { sections: [], syncs: new Map(), omitted: [] }; + const dir = calls.length > 0 ? await agentsDir($) : undefined; if (!dir) { - return []; + return none; } const hooksFile = await readHooksFile($, dir); if (!fullDiffEnabled(hooksFile)) { - return []; + return none; + } + + const ids = calls.map((call) => call.id); + const note = (id: string) => `${dir}/hook-diffs/${id}.md`; + const { sections, omitted } = await sectionsOfCalls({ + ids, + read: (id) => $.fs.read(note(id)), + maxChars: MAX_CHARS, + }); + const syncs = new Map(); + + if (collapseGitSyncEnabled(hooksFile)) { + for (const call of calls) { + const sync = gitSyncOf(commandOf(call.input)); + + if (sync !== undefined) { + syncs.set(call.id, sync); + } + } } - return sectionsOfCalls({ ids, read: (id) => $.fs.read(`${dir}/hook-diffs/${id}.md`), maxChars: MAX_CHARS }); + return { sections, syncs, omitted: omitted.map(note) }; } // The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last @@ -88,9 +127,166 @@ async function compactIfIdle($: EngineInterface): Promise { } } +/** + * The files the person folded or opened against their default (`openByDefault`), each as `fileKey`: its row + * (a ToolGroup's `requestId`, or a call's `tool_use_id` in an expanded group), its call and its path. Session + * state, so a fold survives a redraw and a reload of the mod. The newest 500 are kept. + */ +const flipped = atom({ plugin: "genesis-tools-mods", key: "flipped" } as const, [] as FlippedFiles); + +function fileKey(row: string, section: CallSection): string { + return `${row}|${section.call}|${section.path}`; +} + +/** The git-sync calls whose hidden diff the person opened, as `|`; the newest 500. */ +const shownCalls = atom({ plugin: "genesis-tools-mods", key: "shown" } as const, [] as ShownCalls); + +/** + * The full diffs under a row. The diff of a call that runs a git sync (`git pull`, …) starts hidden behind one + * `show diff from git pull` Button, and a `hide diff from git pull` Button folds it back. Each file has its own `[-]`/`[+]` Button that folds its diff to its name and + * back; the row's Button folds every file, or opens them all once every file is folded. A click elsewhere on the + * drawing reaches the engine's row, which opens the group's calls (the person's "expansion in expansion"). + * + * The inner Box's `width="100%"` stretches the diffs to the transcript's width: without it they were as wide as + * the engine's row line, and the Code element wrapped them there. The width cannot sit on the outer Box: the + * engine refuses a tree that puts its own row under a Box with a `width` (seen live 2026-10-18). + */ +async function drawDiffs( + $: EngineInterface, + ui: ElementTable, + row: string, + diffs: Diffs, + drawn: RenderElement +): Promise { + const { Box, Button, Code, Text } = ui; + const shown = new Set(await read($, shownCalls)); + const callKey = (call: string) => `${row}|${call}`; + const isHidden = (call: string) => diffs.syncs.has(call) && !shown.has(callKey(call)); + const sections = diffs.sections.filter((section) => !isHidden(section.call)); + const hidden = [...new Set(diffs.sections.map((section) => section.call))].filter(isHidden); + const setShown = (call: string, on: boolean) => { + const key = callKey(call); + + void update($, shownCalls, (list) => + [...list.filter((item) => item !== key), ...(on ? [key] : [])].slice(-500) + ); + }; + const flips = new Set(await read($, flipped)); + const keys = sections.map((section) => fileKey(row, section)); + const defaults = openByDefault(sections); + const isOpen = (index: number) => (defaults[index] ?? true) !== flips.has(keys[index] ?? ""); + const isAllFolded = keys.every((_, index) => !isOpen(index)); + const totals = (of: readonly CallSection[]) => ({ + files: new Set(of.map((section) => section.path)).size, + added: of.reduce((sum, section) => sum + section.added, 0), + removed: of.reduce((sum, section) => sum + section.removed, 0), + }); + const counts = ({ files, added, removed }: ReturnType) => [ + + {files} {files === 1 ? "file" : "files"} + , + + +{added} + , + + -{removed} + , + ]; + /** Opens or folds the files at `indexes`: a file is flipped exactly when that differs from its default. */ + const setOpen = (indexes: readonly number[], open: boolean) => { + const targets = indexes.map((index) => keys[index] ?? ""); + const flip = indexes.filter((index) => open !== (defaults[index] ?? true)).map((index) => keys[index] ?? ""); + + void update($, flipped, (list) => [...list.filter((key) => !targets.includes(key)), ...flip].slice(-500)); + }; + const all = keys.map((_, index) => index); + + return ( + + {drawn} + + {hidden.map((call) => ( + + + {counts(totals(diffs.sections.filter((section) => section.call === call)))} + + ))} + {sections.length === 0 ? null : ( + + + {counts(totals(sections))} + + )} + {sections.map((section, index) => { + const key = keys[index] ?? fileKey(row, section); + const isFolded = !isOpen(index); + const sync = diffs.syncs.get(section.call); + const isFirstOfCall = sections.findIndex((other) => other.call === section.call) === index; + + return ( + + {sync !== undefined && isFirstOfCall ? ( + + ) : null} + + + {section.path} + +{section.added} + -{section.removed} + {isDeletion(section) ? deleted : null} + + {isFolded ? null : } + + ); + })} + {diffs.omitted.map((path) => ( + + past the view's budget, full diff: {path} + + ))} + + + ); +} + +/** + * The diff hook leaves a call's diff to this mod only in a session that set `GENESIS_TOOLS_MODS_SESSION` to its own + * id (`MODS_SESSION_VAR` in src/agents/lib/hooks/diff/run.ts; mods-plugin.test.ts keeps the two equal). The hooks of + * the session see a mod's `env.set`, so a session that never loaded the mod keeps the hook's own diff message. The + * engine wants the name as a literal, so it can list the variables a module writes. + */ +let announcedSession: string | undefined; + +/** Marks this session as one the mod draws in; again when the id changes (a resumed or forked session). */ +async function announceSession($: EngineInterface): Promise { + try { + const id = String(await $.session.id()); + + if (id !== announcedSession) { + await $.env.set("GENESIS_TOOLS_MODS_SESSION", id); + announcedSession = id; + } + } catch { + // No mark means the hook draws the diff itself: never worse than before the mark existed. + } +} + export const register: Register = (on) => { - on("turn.start", (_$, e, next) => { + on("turn.start", async ($, e, next) => { stopIdleTimer(); + await announceSession($); return next(e); }); @@ -131,28 +327,18 @@ export const register: Register = (on) => { return drawn; } - const ids = e.props.calls.flatMap((call) => - call.tool === "Bash" && !call.isRunning && call.tool_use_id ? [call.tool_use_id] : [] + const calls = e.props.calls.flatMap((call) => + call.tool === "Bash" && !call.isRunning && call.tool_use_id + ? [{ id: call.tool_use_id, input: call.input }] + : [] ); - const sections = await fullDiffs($, ids); + const diffs = await fullDiffs($, calls); - if (sections.length === 0) { + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { return drawn; } - const { Box, Code, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {sections.map((section) => ( - - {section.path} - - - ))} - - ); + return drawDiffs($, $.ui.resolve(e), e.requestId, diffs, drawn); }); on("ui.render", { component: "ToolUse" }, async ($, e, next) => { @@ -162,24 +348,12 @@ export const register: Register = (on) => { return drawn; } - const sections = await fullDiffs($, [e.props.tool_use_id]); + const diffs = await fullDiffs($, [{ id: e.props.tool_use_id, input: e.props.input }]); - if (sections.length === 0) { + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { return drawn; } - const { Box, Code, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {sections.map((section) => ( - - {section.path} - - - ))} - - ); + return drawDiffs($, $.ui.resolve(e), e.props.tool_use_id, diffs, drawn); }); }; diff --git a/plugins/genesis-tools-mods/hooks/sections.ts b/plugins/genesis-tools-mods/hooks/sections.ts index ed1f8ca70d..ea8fab3784 100644 --- a/plugins/genesis-tools-mods/hooks/sections.ts +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -1,6 +1,8 @@ -/** One file of a diff-hook full-diff note: its path and its unified-diff hunks. */ +/** One file of a diff-hook full-diff note: its path, its line counts and its unified-diff hunks. */ export interface Section { path: string; + added: number; + removed: number; patch: string; } @@ -12,7 +14,7 @@ export interface CallSection extends Section { * The files of `~/.genesis-tools/agents/hook-diffs/.md`, which `tools agents hooks` writes when * a message cuts lines: `## (+a -b)`, a blank line, then the hunks in a ```diff fence. */ -const SECTION = /^## (.+?) \(\+\d+ -\d+\)\n\n```diff\n([\s\S]*?)\n```$/gm; +const SECTION = /^## (.+?) \(\+(\d+) -(\d+)\)\n\n```diff\n([\s\S]*?)\n```$/gm; /** * The hunks only. The note's fence starts with git's `diff --git` and `index` lines, which the Code @@ -26,9 +28,65 @@ function hunksOf(patch: string): string { } export function sectionsOf(text: string): Section[] { + const seen = new Set(); + return [...text.matchAll(SECTION)] - .map((match) => ({ path: match[1] ?? "", patch: hunksOf(match[2] ?? "") })) - .filter((section) => section.patch.length > 0); + .map((match) => ({ + path: match[1] ?? "", + added: Number(match[2]), + removed: Number(match[3]), + patch: hunksOf(match[4] ?? ""), + })) + .filter((section) => { + // A file the note holds twice (its block and its overflow part) is drawn once. + const key = `${section.path}\n${section.patch}`; + + if (section.patch.length === 0 || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +/** A file the call deleted whole: no line added, and its one hunk ends at line 0. */ +export function isDeletion(section: Section): boolean { + return section.added === 0 && /^@@ -\d+(?:,\d+)? \+0,0 @@/.test(section.patch); +} + +/** A file over this many changed lines starts folded; so does every file once a row has opened this many. */ +export const OPEN_FILE_LINES = 120; +export const OPEN_ROW_LINES = 300; + +/** + * Which files start open: not a whole deletion (its text is gone, the name says enough), not a file over + * `OPEN_FILE_LINES`, and only while the row's open files stay within `OPEN_ROW_LINES`. A click on `[+]` opens + * any of them (2026-10-18: a `git mv` call drew 1,300 deleted lines). + */ +export function openByDefault(sections: readonly Section[]): boolean[] { + let open = 0; + + return sections.map((section) => { + const lines = section.added + section.removed; + + if (isDeletion(section) || lines > OPEN_FILE_LINES || open + lines > OPEN_ROW_LINES) { + return false; + } + + open += lines; + return true; + }); +} + +export interface CallSections { + sections: CallSection[]; + /** + * The calls with a file past the budget, drawn in part or not at all. The hook checks each call's size on its own + * and drops its full-diff link when that fits, so a group of two 40,000-character calls lost the second one + * without a trace; the mod names these calls' notes instead. + */ + omitted: string[]; } /** @@ -43,9 +101,11 @@ export async function sectionsOfCalls({ ids: readonly string[]; read: (id: string) => Promise; maxChars: number; -}): Promise { - const found: CallSection[] = []; +}): Promise { + const sections: CallSection[] = []; + const omitted: string[] = []; let spent = 0; + let full = false; for (const call of ids) { let note: string; @@ -57,17 +117,19 @@ export async function sectionsOfCalls({ } for (const section of sectionsOf(note)) { - spent += section.patch.length; - - if (spent > maxChars) { - return found; + if (!full && spent + section.patch.length <= maxChars) { + spent += section.patch.length; + sections.push({ ...section, call }); + continue; } - found.push({ ...section, call }); + full = true; + omitted.push(call); + break; } } - return found; + return { sections, omitted }; } function isRecord(value: unknown): value is Record { @@ -75,11 +137,11 @@ function isRecord(value: unknown): value is Record { } /** - * `mods.fullDiff` from the hooks file (`~/.genesis-tools/agents/hooks.json`), which - * `tools agents hooks config set mods.fullDiff false` writes. A file that is missing, unreadable or - * not JSON, and a value that is not a boolean, leave the default: on. + * A boolean of the `mods` section of the hooks file (`~/.genesis-tools/agents/hooks.json`), which + * `tools agents hooks config set mods. false` writes. A file that is missing, unreadable or not JSON, + * and a value that is not a boolean, leave the default: on. */ -export function fullDiffEnabled(hooksFile: string | undefined): boolean { +function modsSwitch(hooksFile: string | undefined, key: "fullDiff" | "collapseGitSync"): boolean { if (hooksFile === undefined) { return true; } @@ -87,10 +149,46 @@ export function fullDiffEnabled(hooksFile: string | undefined): boolean { try { const stored: unknown = JSON.parse(hooksFile); const mods = isRecord(stored) ? stored.mods : undefined; - const value = isRecord(mods) ? mods.fullDiff : undefined; + const value = isRecord(mods) ? mods[key] : undefined; return typeof value === "boolean" ? value : true; } catch { return true; } } + +export function fullDiffEnabled(hooksFile: string | undefined): boolean { + return modsSwitch(hooksFile, "fullDiff"); +} + +export function collapseGitSyncEnabled(hooksFile: string | undefined): boolean { + return modsSwitch(hooksFile, "collapseGitSync"); +} + +/** The command of a Bash call's input, or an empty string. */ +export function commandOf(input: unknown): string { + return isRecord(input) && typeof input.command === "string" ? input.command : ""; +} + +/** + * `git` as the command word of a statement (after `;`, `&&`, `|`, `(` or a line start, and any `VAR=x` assignments), + * then git's own leading options (`-C `, `-c `, `--no-pager`), then the sync verb. `echo git pull` + * runs no pull. + */ +const GIT_SYNC = + /(?:^|[;&|(])[ \t]*(?:[A-Za-z_]\w*=\S*[ \t]+)*(?:\S*\/)?git(?:[ \t]+(?:-[Cc][ \t]+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*[ \t]+(pull|merge|rebase|checkout|switch|reset|cherry-pick|revert|am|stash[ \t]+(?:pop|apply))(?=$|[\s;&|)])/m; + +/** + * `git pull` (or `git merge`, `git stash pop`, …) when the command runs one, else `undefined`. Its diff is other + * people's work arriving, so the mod starts it hidden behind one button (2026-10-20: a `git pull` in devulinka-infra + * drew every pulled line). Heredoc bodies are dropped first and each quoted string becomes one word: `git commit -m + * "after git pull"` runs no pull, and `git -C "/repo/a b" pull` keeps its folder as one argument. + */ +export function gitSyncOf(command: string): string | undefined { + const bare = command + .replace(/<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, " ") + .replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "Q"); + const verb = GIT_SYNC.exec(bare)?.[1]; + + return verb === undefined ? undefined : `git ${verb.replace(/\s+/g, " ")}`; +} diff --git a/plugins/genesis-tools-mods/tests/fold.test.tsx b/plugins/genesis-tools-mods/tests/fold.test.tsx new file mode 100644 index 0000000000..155b1fa549 --- /dev/null +++ b/plugins/genesis-tools-mods/tests/fold.test.tsx @@ -0,0 +1,190 @@ +import type { On } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; + +// Run with `claude plugin test plugins/genesis-tools-mods`; the repo's bun runner skips this folder. + +const NOTE = [ + "# Diff of toolu_a", + "", + "## /repo/src/a.ts (+2 -1)", + "", + "```diff", + "@@ -1,2 +1,3 @@", + "-old", + "+new", + "+more", + " same", + "```", + "", + "## /repo/src/b.ts (+1 -0)", + "", + "```diff", + "@@ -1,1 +1,2 @@", + " keep", + "+added", + "```", +].join("\n"); + +const CALL = { + tool_use_id: "toolu_a", + tool: "Bash", + input: { command: "perl -pi -e s/old/new/ src/a.ts" }, + isRunning: false, + isErrored: false, + isInterrupted: false, +}; + +const GROUP = { + calls: [CALL], + isActive: false, + isExpanded: false, +}; + +/** The engine's own group line, beneath the plugin, and the files the plugin reads. */ +function engine(on: On, files: Record): void { + mock.env(on, { HOME: "/home/test" }); + on("fs.read", async (_$, e) => { + const text = files[e.path]; + + if (text === undefined) { + throw new Error(`no file ${e.path}`); + } + + return { value: text }; + }); + on("ui.render", { component: "ToolGroup" }, async ($, e) => { + const { Text } = $.ui.resolve(e); + + return ran 1 shell command; + }); +} + +const NOTE_PATH = "/home/test/.genesis-tools/agents/hook-diffs/toolu_a.md"; + +test("each file folds on its own, and the row's Button folds or opens them all", async ($, on) => { + engine(on, { [NOTE_PATH]: NOTE }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: GROUP, + requestId: "group-1", + viewport: { columns: 160, rows: 40 }, + }); + const A = "group-1|toolu_a|/repo/src/a.ts:fold"; + const B = "group-1|toolu_a|/repo/src/b.ts:fold"; + + expect(await ui.find({ text: "ran 1 shell command" })).toBeDefined(); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("files only"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + expect((await ui.find({ key: A }))?.text).toBe("[-]"); + + await ui.press({ key: A }); + + expect((await ui.find({ key: A }))?.text).toBe("[+]"); + expect((await ui.find({ key: B }))?.text).toBe("[-]"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(1); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("files only"); + + await ui.press({ key: "group-1:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + expect((await ui.find({ key: "group-1:fold" }))?.text).toBe("show diff"); + expect(await ui.find({ text: "/repo/src/b.ts" })).toBeDefined(); + + await ui.press({ key: "group-1:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + await ui.unmount(); +}); + +test("a deleted file starts folded, and its [+] opens it", async ($, on) => { + const gone = ["## /repo/src/gone.ts (+0 -2)", "", "```diff", "@@ -1,2 +0,0 @@", "-one", "-two", "```"].join("\n"); + + engine(on, { [NOTE_PATH]: `${NOTE}\n\n${gone}` }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: GROUP, + requestId: "group-2", + }); + const G = "group-2|toolu_a|/repo/src/gone.ts:fold"; + + expect((await ui.find({ key: G }))?.text).toBe("[+]"); + expect(await ui.find({ text: "deleted" })).toBeDefined(); + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + + await ui.press({ key: G }); + + expect((await ui.find({ key: G }))?.text).toBe("[-]"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(3); + + await ui.press({ key: "group-2:fold" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + await ui.unmount(); +}); + +test("an expanded group draws no diffs of its own", async ($, on) => { + engine(on, { [NOTE_PATH]: NOTE }); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { ...GROUP, isExpanded: true }, + }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + await ui.unmount(); +}); + +test("a git pull's diff starts hidden behind one button, which shows it and hides it again", async ($, on) => { + engine(on, { [NOTE_PATH]: NOTE }); + + const pull = { ...CALL, input: { command: "cd /repo && git pull -q --ff-only 2>&1 | tail -2" } }; + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { ...GROUP, calls: [pull] }, + requestId: "group-3", + }); + + expect((await ui.find({ key: "group-3|toolu_a:show" }))?.text).toBe("show diff from git pull"); + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + expect(await ui.find({ text: "/repo/src/a.ts" })).toBeUndefined(); + + await ui.press({ key: "group-3|toolu_a:show" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + expect((await ui.find({ key: "group-3|toolu_a:hide" }))?.text).toBe("hide diff from git pull"); + + await ui.press({ key: "group-3|toolu_a:hide" }); + + expect(await ui.findAll({ type: "Code" })).toHaveLength(0); + await ui.unmount(); +}); + +test("mods.collapseGitSync off draws a git pull like any other call", async ($, on) => { + engine(on, { + [NOTE_PATH]: NOTE, + "/home/test/.genesis-tools/agents/hooks.json": '{"mods":{"collapseGitSync":false}}', + }); + + const pull = { ...CALL, input: { command: "git pull" } }; + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { ...GROUP, calls: [pull] }, + requestId: "group-4", + }); + + expect(await ui.find({ key: "group-4|toolu_a:show" })).toBeUndefined(); + expect(await ui.findAll({ type: "Code" })).toHaveLength(2); + await ui.unmount(); +}); diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts new file mode 100644 index 0000000000..8f4f0c4329 --- /dev/null +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -0,0 +1,11 @@ +/** The files folded or opened against their default: `||`, the newest 500. */ +export type FlippedFiles = string[]; + +/** The git-sync calls whose hidden diff the person opened: `|`, the newest 500. */ +export type ShownCalls = string[]; + +declare module "claude-code" { + interface PluginState { + "genesis-tools-mods": { flipped: FlippedFiles; shown: ShownCalls }; + } +} diff --git a/scripts/test-args.ts b/scripts/test-args.ts index eb7820cb7e..8c636485c4 100644 --- a/scripts/test-args.ts +++ b/scripts/test-args.ts @@ -32,6 +32,10 @@ export const ALWAYS_EXCLUDES = [ // is excluded: `cloud/shared` holds real bun tests, which `bun test ../shared` // runs from that package. "**/DevDashboard/cloud/web/**", + // Claude Code mod tests: they import `claude-code/testing`, which only `claude plugin test + // plugins/genesis-tools-mods` provides. The mod's pure helpers are tested by bun in + // src/agents/lib/hooks/mods-plugin.test.ts. + "**/plugins/genesis-tools-mods/tests/**", ]; /** diff --git a/src/agents/README.md b/src/agents/README.md index d4f717c50a..613af2134d 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -213,7 +213,14 @@ message cut lines, it draws the call's whole diff (the `hook-diffs/.md` no diff element under the call's row, so the 10,000-character message limit no longer hides the rest. A running session needs `/reload-plugins`. `tools agents hooks mods` shows whether it is installed; `tools agents hooks config set mods.fullDiff false --write` turns it off without an uninstall (the mod reads -the hooks file). It draws nothing while `diff.fullDiff` is `off`. The mod API is early access in Claude Code +the hooks file). It draws nothing while `diff.fullDiff` is `off`. While the plugin is enabled in the user settings +and `mods.fullDiff` is on, the diff hook prints no diff for Claude: it writes every diff to its note, and the mod +draws it, so it is not drawn twice (Codex and Grok keep the hook's message). Each file has a `[-]` / `[+]` button +that folds its diff to its name and back, and `[ files only ]` above them folds every file; a click on the diff +itself still opens the group's calls. The diff of a call that runs `git pull`, `merge`, `rebase`, `checkout`, +`switch`, `reset`, `cherry-pick`, `revert`, `am` or `stash pop|apply` starts hidden behind one +`[ show diff from git pull ]` button, because it is other people's work arriving; `config set mods.collapseGitSync false +--write` draws it like any other call. Its tests run with `claude plugin test plugins/genesis-tools-mods`. The mod API is early access in Claude Code (2.1.287 and later), so a release can change it; the mod then draws nothing and the hook message is unchanged. **The idle-compaction mod.** The same plugin compacts an idle Claude session 56 minutes after its last turn diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index 726f14b7ff..2fef996364 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; import { suggestCommand, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { ui } from "@genesiscz/utils/cli/ui"; @@ -37,13 +38,26 @@ import { hooksDistPath, type InstallResult, installAndPoint, + readSettings, repoRoot, uninstallHooks, wiringStatus, } from "../lib/hooks/install"; -import { claudePlugin, MODS_PLUGIN, MODS_SCOPES, type ModsScope, modsPluginState } from "../lib/hooks/mods"; +import { + claudePlugin, + claudeUserSettingsPath, + MODS_PLUGIN, + MODS_SCOPES, + type ModsScope, + modsPluginState, + modsWatchState, + PLUGIN_DIRS_VAR, + pluginDirsOf, + withModsWatch, +} from "../lib/hooks/mods"; import { nativeDiffForced, nativeDiffState, setNativeDiff } from "../lib/hooks/native-diff"; import { SETTABLE_KEYS, type SetResult, setHooksConfig } from "../lib/hooks/set-config"; +import { writeJsonFile } from "../lib/hooks/write-json"; import { registerRuleCommands, registerRulePicker, ruleState, shortPath } from "./hooks-rules"; const HARNESSES: readonly HarnessName[] = ["claude", "codex", "grok"]; @@ -753,6 +767,7 @@ function registerMods(hooks: Command): void { } ui.raw(`mods.fullDiff: ${config.mods.fullDiff ? pc.green("on") : pc.dim("off")}`); + ui.raw(`mods.collapseGitSync: ${config.mods.collapseGitSync ? pc.green("on") : pc.dim("off")}`); ui.raw( `mods.idleCompact: ${config.mods.idleCompact ? pc.green("on") : pc.dim("off")}${pc.dim(` · after ${config.mods.idleCompactMinutes} min · floor ${config.mods.idleCompactMinTokens} tokens`)}` ); @@ -761,11 +776,56 @@ function registerMods(hooks: Command): void { ui.raw(pc.yellow(`diff.fullDiff is off, so no call has a full-diff note for the mod to draw.`)); } - if (!state.installed) { + const settingsPath = claudeUserSettingsPath(); + const watched = modsWatchState(settingsPath); + const hotReload = + watched === undefined + ? pc.yellow(`unknown (${settingsPath} could not be read)`) + : watched + ? pc.green(`on (${PLUGIN_DIRS_VAR} in the settings env)`) + : pc.dim(`off: ${toolCommand("agents hooks mods watch", "--write")}`); + + ui.raw(`hot reload: ${hotReload}`); + + if (!state.installed && watched === false) { ui.raw(`Install: ${toolCommand("agents hooks mods install", "--write")}`); } }); + mods.command("watch") + .description( + `Load the mods from this checkout in every new session and reload them on each save (${PLUGIN_DIRS_VAR} in the Claude settings env)` + ) + .option("--off", "Stop: take the folder out of the settings env") + .option("--write", "Actually change the settings; without it this is a dry run") + .action((options: { off?: boolean; write?: boolean }) => { + const path = claudeUserSettingsPath(); + const folder = join(repoRoot(), "plugins", "genesis-tools-mods"); + const before = readSettings(path); + const after = withModsWatch(before, folder, !options.off); + const changed = SafeJSON.stringify(after) !== SafeJSON.stringify(before); + + ui.raw(`${PLUGIN_DIRS_VAR}: ${pluginDirsOf(after).join(", ") || "(unset)"}`); + + if (!changed) { + ui.raw(pc.dim(`${path} already says so.`)); + return; + } + + if (!options.write) { + ui.raw(pc.yellow(`dry run: ${path} was not changed. Pass --write to apply.`)); + return; + } + + writeJsonFile(path, after); + ui.raw(pc.green(`wrote ${path}`)); + ui.raw( + pc.dim( + "A session started from now on loads the folder and reloads the mod on each save. A running session keeps what it loaded until it restarts." + ) + ); + }); + mods.command("install") .description("Install the mods plugin from this repository's marketplace") .option("--scope ", `Where Claude Code records it: ${MODS_SCOPES.join(", ")}`, "user") diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index 5486c917b5..bd9a068fdc 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -272,6 +272,12 @@ export interface ModsConfig { * under the call's row. Reads `hook-diffs/.md`, so it needs `diff.fullDiff` not `off`. */ fullDiff: boolean; + /** + * Start the diff of a call that runs `git pull`, `merge`, `rebase`, `checkout`, `switch`, `reset`, + * `cherry-pick`, `revert`, `am` or `stash pop|apply` hidden behind one `show diff from git pull` button: + * those diffs are other people's work arriving, often hundreds of lines. + */ + collapseGitSync: boolean; /** * Compact an idle Claude session `idleCompactMinutes` after its last turn, before the 1-hour prompt * cache expires, when the engine's own idle compaction did not (it gives up at its first refused gate, @@ -426,7 +432,13 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { staleness: { warnAfterMinutes: 30, alarmAfterMinutes: 120, notify: true }, }, agentsTalk: { hint: true }, - mods: { fullDiff: true, idleCompact: true, idleCompactMinutes: 56, idleCompactMinTokens: 200_000 }, + mods: { + fullDiff: true, + collapseGitSync: true, + idleCompact: true, + idleCompactMinutes: 56, + idleCompactMinTokens: 200_000, + }, unpushed: { enabled: true, maxCommits: 3, maxAgeMinutes: 60, remindEveryMinutes: 30 }, logPath: defaultLogPath(), // 🛑 `shadow: true` AND `logCommands: "shadow"` together mean `keepsCommand()` is true out @@ -1009,6 +1021,7 @@ function mergeMods(stored: Partial | undefined): ModsConfig { return { fullDiff: boolOr(stored?.fullDiff, base.fullDiff), + collapseGitSync: boolOr(stored?.collapseGitSync, base.collapseGitSync), idleCompact: boolOr(stored?.idleCompact, base.idleCompact), // The mod reads the file itself and drops 60 or more the same way. idleCompactMinutes: minutes < 60 ? minutes : base.idleCompactMinutes, diff --git a/src/agents/lib/hooks/diff/before.ts b/src/agents/lib/hooks/diff/before.ts index 37cdbcfd11..18963f47b1 100644 --- a/src/agents/lib/hooks/diff/before.ts +++ b/src/agents/lib/hooks/diff/before.ts @@ -130,6 +130,61 @@ export function beforeCopy({ dir, root, absolute }: { dir: string; root: string; } } +/** + * `beforeCopy` for many files of one root, with the archive listed once: a file the listing lacks was clean when + * the command began, so it gets `null` without a `tar` spawn of its own. `renamesIn` asks for every deleted file, + * and each one cost a `tar -xOf` even when absent (up to `RENAME_SCAN_LIMIT` of them). `copy` is the extraction, + * a parameter so a test can count it. + */ +export function beforeCopier({ + dir, + root, + copy = beforeCopy, +}: { + dir: string; + root: string; + copy?: typeof beforeCopy; +}): (absolute: string) => string | null { + let members: Set | null | undefined; + + return (absolute) => { + if (members === undefined) { + members = archiveMembers(dir, root); + } + + // An archive that could not be listed falls back to one extraction per file. + if (members !== null && !members.has(relative(root, absolute).normalize("NFC"))) { + return null; + } + + return copy({ dir, root, absolute }); + }; +} + +/** The archive's member names in NFC (bsdtar stores NFD, see `extract`), empty with no archive, `null` when `tar -tf` failed. */ +function archiveMembers(dir: string, root: string): Set | null { + const index = rootIndex(dir, root); + const tar = join(dir, `${index + 1}.tar`); + + if (index < 0 || !existsSync(tar)) { + return new Set(); + } + + const listed = spawnSync("tar", ["-tf", tar], { encoding: "utf8", maxBuffer: 16_000_000 }); + + if (listed.status !== 0 || typeof listed.stdout !== "string") { + hookDiag("Could not list the capture archive", { tar, status: listed.status }); + return null; + } + + return new Set( + listed.stdout + .split("\n") + .filter(Boolean) + .map((name) => name.normalize("NFC")) + ); +} + /** * `--` before the member name, for the same reason the capture uses it: a repository file * named `-C` would otherwise be read by tar as an option. diff --git a/src/agents/lib/hooks/diff/command-paths.ts b/src/agents/lib/hooks/diff/command-paths.ts index 591f1dc073..ca2b2a8c6e 100644 --- a/src/agents/lib/hooks/diff/command-paths.ts +++ b/src/agents/lib/hooks/diff/command-paths.ts @@ -1,15 +1,17 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; import { commandTokenIndex, commandWord, + lastSimpleCommandOnLine, nextRawArgument, rawToken, type ShellScan, type Span, scanShell, splitPipeline, + stripShellNoise, tokenize, } from "@genesiscz/utils/shell/scan"; import { hookDiag } from "../log"; @@ -184,7 +186,9 @@ function cdTargets(command: string): string[] { /** * Every directory this command could have worked in: the session cwd, plus the target of - * EVERY `cd` it names, relative ones included. + * EVERY `cd` it names, relative ones included. The first entry is the session cwd and the last + * is the folder the command ends in, so a folder it returns to (`cd sub && cd ..`) is named again + * at the end. * * A directory that does not exist cannot be one the command changed, so it is dropped here. * That is also the backstop for a target the raw read got wrong in a way `plainArgument` did @@ -213,6 +217,10 @@ export function commandDirs(command: string, cwd: string): string[] { } } + if (dirs.at(-1) !== current) { + dirs.push(current); + } + return dirs; } @@ -409,7 +417,171 @@ function gitDirectory({ * produces no token of its own. */ export function namedArguments(command: string, bases: string[]): string[] { - return walkNamed({ command, bases, readings: false }).found; + const found = walkNamed({ command, bases, readings: false }).found; + + return [...new Set([...found, ...scriptPaths(command, bases)])]; +} + +const INTERPRETER = /^(?:python[0-9.]*|node|bun|deno|perl|ruby|php)$/; +const HEREDOC_OPENER = /<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/; +const INLINE_SCRIPT = + /(?:^|[\s;&|(])(?:\S*\/)?(?:python[0-9.]*|node|bun|deno|perl|ruby|php)(?:\s+-[A-Za-z-]+)*?\s+(?:-c|-e|-r|--eval)\s+(['"])/g; +const SCRIPT_LITERAL = /(["'`])([^"'`\n]+)\1/g; + +/** + * Existing files named by a quoted literal (absolute, `~/`, or relative to one of `bases`) inside a + * script an interpreter RUNS: the body of `python3 - <<'EOF'` (no script file, so the heredoc is the program) and the + * argument of `-c`/`-e`/`-r`/`--eval`. The scanner blanks both, so `open(os.path.expanduser( + * '~/.ssh/config'),'w')` in a heredoc edited a file outside every repository with no diff + * (2026-10-20, session be21be54). A heredoc fed to a command with a script file (`bun cli.ts + * <<'SPEC'`) stays data. Only files that exist now: a literal that names nothing (`"/api/x"`) + * would only take one of the few named slots, and a creation found this way is not shown anyway. + */ +export function scriptPaths(command: string, bases: string[] = []): string[] { + const found: string[] = []; + let cleaned = command; + + try { + cleaned = stripShellNoise(command); + } catch (err) { + // Without the scanner every character counts as code, which is how scripts were read before it was asked. + hookDiag("Could not scan the command for script paths", { err }); + } + + for (const script of [...interpreterHeredocs(command, cleaned), ...inlineScripts(command, cleaned)]) { + for (const match of script.matchAll(SCRIPT_LITERAL)) { + const value = match[2] ?? ""; + + if (/[$*?{}%\\]/.test(value) || value.includes("://")) { + continue; + } + + // `open('config.json')` names a file of the folder the command runs in. A relative literal with a + // space or a leading dash is prose or a flag, never a path. + const relativeOk = !/\s/.test(value) && !value.startsWith("-"); + const candidates = + value.startsWith("/") || value.startsWith("~/") + ? [expandHome(value)] + : relativeOk + ? // The folder the command last `cd`-ed into first: `cd /other && python3 -c "open('x')"` runs there. + [...bases].reverse().map((base) => resolve(base, value)) + : []; + const path = candidates.find(isFile); + + if (path && !found.includes(path)) { + found.push(path); + } + } + } + + return found; +} + +/** + * A literal is any quoted text, so it can name a path the system refuses to stat: `/repo/package.json/x` is + * ENOTDIR, a long one ENAMETOOLONG. `throwIfNoEntry` covers ENOENT only, and one such literal must not cost + * the command every other path it names. + */ +function isFile(path: string): boolean { + try { + return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; + } catch (err) { + hookDiag("Could not stat a path a script names", { err, path }); + return false; + } +} + +/** + * Whether the character at `index` is shell code: the scanner blanks quoted spans, comments and heredoc bodies in + * `cleaned` and keeps every offset. A `node -e '…'` example inside `cat > notes.md <<'SPEC'` is data, not a script. + */ +function isCode(command: string, cleaned: string, index: number): boolean { + const char = command[index] ?? " "; + + return cleaned[index] === char && !/\s/.test(char); +} + +function interpreterHeredocs(command: string, cleaned: string): string[] { + const lines = command.split("\n"); + const bodies: string[] = []; + const starts: number[] = []; + + lines.reduce((offset, line) => { + starts.push(offset); + return offset + line.length + 1; + }, 0); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const opener = HEREDOC_OPENER.exec(line); + const simple = opener ? lastSimpleCommandOnLine(line.slice(0, opener.index)) : ""; + const word = simple.trim() ? line.lastIndexOf(simple.trim(), opener?.index) : -1; + + if (!opener || !runsStdin(simple) || word < 0 || !isCode(command, cleaned, (starts[i] ?? 0) + word)) { + continue; + } + + const body: string[] = []; + const delim = opener[3]; + let j = i + 1; + + for (; j < lines.length; j++) { + const text = lines[j] ?? ""; + + if ((opener[1] === "-" ? text.replace(/^\t+/, "") : text) === delim) { + break; + } + + body.push(text); + } + + bodies.push(body.join("\n")); + i = j; + } + + return bodies; +} + +/** `python3 -`, `node`, `env X=1 perl -w`: an interpreter whose arguments are all flags, so it reads its program from stdin. */ +function runsStdin(simple: string): boolean { + const words = simple.split(/\s+/).filter((word) => word && !ASSIGNMENT.test(word)); + + if (words[0] === "env") { + words.shift(); + } + + const program = words[0]?.split("/").pop() ?? ""; + + return ( + INTERPRETER.test(program) && words.slice(1).every((word) => word.startsWith("-") && !/^-(c|e|r)$/.test(word)) + ); +} + +function inlineScripts(command: string, cleaned: string): string[] { + const scripts: string[] = []; + + for (const match of command.matchAll(INLINE_SCRIPT)) { + const program = (match.index ?? 0) + (/^[\s;&|(]/.test(match[0]) ? 1 : 0); + + if (!isCode(command, cleaned, program)) { + continue; + } + + const quote = match[1] ?? "'"; + const start = (match.index ?? 0) + match[0].length; + let end = start; + + // A single-quoted span has no escapes in the shell; a double-quoted one ends at the first unescaped quote. + while (end < command.length && command[end] !== quote) { + end += quote === '"' && command[end] === "\\" ? 2 : 1; + } + + // The interpreter receives the shell's decoded text: `"open(\"/tmp/x\")"` reaches python as `open("/tmp/x")`. + const raw = command.slice(start, end); + scripts.push(quote === '"' ? raw.replace(/\\(["\\$`])/g, "$1") : raw); + } + + return scripts; } /** diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index 72d247dd4b..9e32a84bd0 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -33,12 +33,13 @@ import { isDeleted, statusEntries, untrackedFilesIn } from "../git"; import { callDir, claimsRoot, sessionDir } from "../paths"; import type { HookPayload } from "../payload"; import { readOnlyCommand, recordTouches } from "./attribution"; -import { beforeCopy } from "./before"; +import { beforeCopier, beforeCopy } from "./before"; import { type CaptureResult, capturePre, captureRoots, harnessOwnFile } from "./capture"; import { claimChange, claimFileName } from "./claim"; import { classifyChange, type DiffCategory } from "./classify"; import { changedFiles } from "./collect"; import { commandDirs, namedArguments } from "./command-paths"; +import { writeFullDiff } from "./full"; import { covers, mentionsFrom, mentionsOf, textPaths } from "./mentions"; import { assembleMessage, @@ -62,8 +63,12 @@ import { commandLines, composePostDiff, type DiffDecision, + deferredTaskHeading, flushDeferred, + modDrawsDiffs, + modLoadedIn, noteFileToolInput, + readHead, renamesIn, runDiffPost, silentReason, @@ -216,6 +221,29 @@ describe("beforeCopy", () => { expect(beforeCopy({ dir, root: repo, absolute: join(repo, "kept.ts") })).toBeNull(); }); + + it("extracts only the files the archive holds when asked for many", () => { + mkdirSync(join(repo, "scratch"), { recursive: true }); + writeFileSync(join(repo, "scratch/deep.ts"), "one\ntwo\nthree\n"); + + const current = begin(); + const dir = callDir(current.harness, current.sessionId ?? "", current.toolUseId ?? ""); + const asked: string[] = []; + const copyOf = beforeCopier({ + dir, + root: repo, + copy: (args) => { + asked.push(args.absolute); + return beforeCopy(args); + }, + }); + const clean = Array.from({ length: 20 }, (_, i) => copyOf(join(repo, `gone-${i}.ts`))); + const copy = copyOf(join(repo, "scratch/deep.ts")); + + expect(clean.every((item) => item === null)).toBe(true); + expect(asked).toEqual([join(repo, "scratch/deep.ts")]); + expect(readFileSync(copy ?? "", "utf8")).toBe("one\ntwo\nthree\n"); + }); }); describe("runDiffPost", () => { @@ -900,6 +928,108 @@ describe("files the command NAMES rather than works in", () => { expect(runDiffPost({ payload: current, config: plain }).message).toContain("TWO-OUTSIDE-GIT"); }); + it("renders an edit made by a python heredoc that names the file only in its body", () => { + const config = join(outside, "ssh-config"); + writeFileSync(config, "Host a\n HostName 10.0.0.1\n"); + const current = begin({ command: `python3 - <<'EOF'\np='${config}'\nopen(p,'a').write('x')\nEOF` }); + + writeFileSync(config, "Host a\n HostName 10.0.0.1\nHost HEREDOC-ADDED\n"); + + expect(runDiffPost({ payload: current, config: plain }).message).toContain("HEREDOC-ADDED"); + }); + + it("shows only the shape of a change to a private key file, whose hunk lacks the key's markers", () => { + const key = join(outside, "id_fixture"); + const body = (middle: string) => + [ + "-----BEGIN OPENSSH PRIVATE KEY-----", + ...Array.from({ length: 20 }, (_, i) => + i === 10 ? middle : `AAAAFIXTUREKEYLINE${String(i).padStart(2, "0")}` + ), + "-----END OPENSSH PRIVATE KEY-----", + "", + ].join("\n"); + writeFileSync(key, body("OLDFIXTURESECRETLINE")); + const current = begin({ command: `bun /x/rotate.mjs ${key}` }); + + writeFileSync(key, body("NEWFIXTURESECRETLINE")); + const message = runDiffPost({ payload: current, config: plain }).message ?? ""; + + expect(message).toContain("[redacted]"); + expect(message).not.toContain("FIXTURESECRETLINE"); + expect(message).not.toContain("AAAAFIXTUREKEYLINE"); + }); + + it("shows the rows of a source file that only quotes a key marker, as a test fixture does", () => { + const source = join(outside, "key-fixture.test.ts"); + const lines = (row: string) => + [`const KEY = ["-----BEGIN OPENSSH PRIVATE KEY-----", "x"];`, row, ""].join("\n"); + writeFileSync(source, lines("const a = 1;")); + const current = begin({ command: `bun /x/edit.mjs ${source}` }); + + writeFileSync(source, lines("const QUOTED_MARKER_ROW = 2;")); + + expect(runDiffPost({ payload: current, config: plain }).message ?? "").toContain("QUOTED_MARKER_ROW"); + }); + + it("masks the cookie values of a browser login it shows", () => { + const jar = join(outside, "localhost-3000.json"); + const cookie = (value: string) => `{"cookies":[{"name":"JSESSIONID","value":"${value}","httpOnly":true}]}`; + writeFileSync(jar, cookie("OLD-SESSION-288F69E6")); + const current = begin({ command: `bun /x/login.mjs ${jar}` }); + + writeFileSync(jar, cookie("NEW-SESSION-4F456B42")); + const message = runDiffPost({ payload: current, config: plain }).message ?? ""; + + expect(message).toContain('"value":"[redacted]"'); + expect(message).not.toContain("SESSION-"); + }); + + it("masks a changed cookie value whose hunk shows none of the jar's own keys", () => { + // A formatted storageState: only the second cookie's value changes, and its three context lines + // hold name, domain, path and expires, none of `cookies`, `httpOnly` or `sameSite`. + const jar = join(outside, "storage-state.json"); + const state = (second: string) => + [ + "{", + ' "cookies": [', + " {", + ' "name": "first",', + ' "value": "FIRST-SESSION-0A1B2C3D",', + ' "domain": "localhost",', + ' "path": "/",', + ' "expires": -1,', + ' "httpOnly": true,', + ' "secure": false,', + ' "sameSite": "Lax"', + " },", + " {", + ' "name": "second",', + ' "domain": "localhost",', + ' "path": "/",', + ' "expires": -1,', + ` "value": "${second}",`, + ' "secure": false,', + ' "flagA": 1,', + ' "flagB": 2,', + ' "flagC": 3,', + ' "httpOnly": true,', + ' "sameSite": "Lax"', + " }", + " ]", + "}", + "", + ].join("\n"); + writeFileSync(jar, state("OLD-SECOND-SESSION-11")); + const current = begin({ command: `bun /x/login.mjs ${jar}` }); + + writeFileSync(jar, state("NEW-SECOND-SESSION-22")); + const message = runDiffPost({ payload: current, config: plain }).message ?? ""; + + expect(message).toContain('"value": "[redacted]"'); + expect(message).not.toContain("SECOND-SESSION"); + }); + it("a read-only command that names an absolute path never claims a concurrent edit to it", () => { // `cat /abs/MEMORY.md` while another writer edits it: the change is not this session's. const note = join(outside, "MEMORY.md"); @@ -1025,6 +1155,73 @@ describe("files the command NAMES rather than works in", () => { const note = join(vault, "wrapup.md"); expect(namedArguments(`bun /x/cli.ts <<'EOF'\n- see ${note}\nEOF`, [vault])).not.toContain(note); + expect(namedArguments(`bun /x/cli.ts <<'EOF'\nopen('${note}')\nEOF`, [vault])).not.toContain(note); + }); + + it("reads a quoted file out of a script an interpreter runs from a heredoc or -c/-e", () => { + const note = join(vault, "wrapup.md"); + const missing = join(vault, "nope.md"); + + // Observed 2026-10-20: `python3 - <<'EOF'` rewrote ~/.ssh/config and the hook named nothing. + const heredoc = namedArguments(`python3 - <<'EOF'\np='${note}'\nq="${missing}"\nopen(p,'w')\nEOF`, [vault]); + + expect(heredoc).toEqual([note]); + expect(namedArguments(`node -e 'require("fs").writeFileSync("${note}", "x")'`, [vault])).toEqual([note]); + expect(namedArguments(`python3 -c "open('${note}','a')"`, [vault])).toEqual([note]); + expect(namedArguments(`python3 - <<'EOF'\nopen(f'${vault}/{n}.md')\nEOF`, [vault])).toEqual([]); + }); + + it("reads a relative quoted file of a script against the folder the command runs in", () => { + const note = join(vault, "wrapup.md"); + + expect(namedArguments(`python3 -c "open('wrapup.md','a')"`, [vault])).toEqual([note]); + expect(namedArguments(`python3 - <<'EOF'\nprint('wrapup.md is long')\nEOF`, [vault])).toEqual([]); + }); + + it("reads a relative literal in the folder the command last cd-ed into, before the session folder", () => { + const sub = join(vault, "relative-cd"); + mkdirSync(sub, { recursive: true }); + writeFileSync(join(sub, "wrapup.md"), "x\n"); + + expect(namedArguments(`python3 -c "open('wrapup.md','a')"`, [vault, sub])).toEqual([join(sub, "wrapup.md")]); + }); + + it("reads a relative literal in the folder the command returned to, not the one it left", () => { + const sub = join(vault, "relative-cd"); + mkdirSync(sub, { recursive: true }); + writeFileSync(join(sub, "wrapup.md"), "x\n"); + const command = `cd relative-cd && cd .. && python3 -c "open('wrapup.md','a')"`; + + expect(commandDirs(command, vault)).toEqual([vault, sub, vault]); + const named = namedArguments(command, commandDirs(command, vault)); + expect(named).toContain(join(vault, "wrapup.md")); + expect(named).not.toContain(join(sub, "wrapup.md")); + }); + + it("reads a double-quoted script as the interpreter gets it, with the shell's escapes decoded", () => { + const note = join(vault, "wrapup.md"); + + expect(namedArguments(`python3 -c "open(\\"${note}\\",\\"a\\").write(\\"x\\")"`, [vault])).toEqual([note]); + }); + + it("keeps the other paths of a script when one literal cannot be stat'ed", () => { + // `/…/wrapup.md/x` is ENOTDIR, which `throwIfNoEntry: false` does not cover; it threw out of the hook. + const note = join(vault, "wrapup.md"); + const command = `python3 - <<'EOF'\nprint('${note}/x')\nopen('${note}','a')\nEOF`; + + expect(namedArguments(command, [vault])).toEqual([note]); + }); + + it("reads no script out of an interpreter example inside a data heredoc or a quoted string", () => { + const note = join(vault, "wrapup.md"); + + expect( + namedArguments(`cat > n.md <<'SPEC'\nnode -e 'require("fs").writeFileSync("${note}", "x")'\nSPEC`, []) + ).toEqual([]); + expect(namedArguments(`cat > n.md <<'SPEC'\npython3 - <<'EOF'\nopen('${note}','a')\nEOF\nSPEC`, [])).toEqual( + [] + ); + expect(namedArguments(`echo "run: node -e 'x(\\"${note}\\")'"`, [])).toEqual([]); }); it("finds a quoted path, which the scanner blanks out of the token stream", () => { @@ -1786,6 +1983,37 @@ describe("a command that edits AND commits in the same call", () => { expect(decision.message).toContain("(+1 -1)"); }); + it("shows only the shape of a key file whose BEGIN line the command removed, read against the base commit", () => { + const key = join(repo2, "id_base_fixture"); + const rows = Array.from({ length: 40 }, (_, i) => `AAAABASEKEYLINE${String(i).padStart(2, "0")}`); + writeFileSync( + key, + ["-----BEGIN OPENSSH PRIVATE KEY-----", ...rows, "-----END OPENSSH PRIVATE KEY-----", ""].join("\n") + ); + run(["add", "-A"]); + run(["commit", "-qm", "seed key"]); + + const current = begin({ cwd: repo2, toolUseId: "commit-key" }); + + // The BEGIN line is gone and a key row far from the END line changed: the patch shows no END marker, so the + // key pattern cannot mask it, and the current file no longer looks like a key. + writeFileSync( + key, + [...rows.slice(0, 15), "NEWBASESECRETROW", ...rows.slice(16), "-----END OPENSSH PRIVATE KEY-----", ""].join( + "\n" + ) + ); + run(["add", "-A"]); + run(["commit", "-qm", "strip key"]); + + const message = runDiffPost({ payload: current, config: plain }).message ?? ""; + + expect(message).toContain("id_base_fixture"); + expect(message).not.toContain("BASESECRETROW"); + expect(message).not.toContain("AAAABASEKEYLINE"); + expect(message).not.toContain("BEGIN OPENSSH"); + }); + it("renders a file ONCE when the command commits it and then edits it again", () => { // Such a file is in the committed list AND still in `git status`, so it used to be // collected twice and rendered twice. `dedupeAcrossSessions` is off on purpose: the @@ -2225,15 +2453,32 @@ describe("the budget buys the change before the context", () => { const one = edit("a"); let spawns = 0; + const tint = (text: string) => `\x1b[36m${text}\x1b[0m`; one.colour = () => { spawns += 1; - return ["TINTED one", "TINTED two", "TINTED three", "TINTED four"]; + return one.body.filter((line) => line.at !== null).map((line) => tint(line.text)); }; const message = assembleMessage([one], budget(100_000)); expect(spawns).toBe(1); - expect(message).toContain("TINTED one"); + expect(message).toContain(tint(one.body[0]?.text ?? "")); + }); + + it("keeps a masked context line plain when the file's coloured line still holds the secret", () => { + // `bat` reads the file on disk; the patch was masked. The coloured original must not replace the mask. + const one = edit("a"); + const masked = one.body[0]; + if (masked) { + masked.text = 'a context one token = "[redacted]"'; + } + + one.colour = () => ['\x1b[36ma context one token = "sk-ant-FIXTURE-SECRET-0123456789"\x1b[0m']; + + const message = assembleMessage([one], budget(100_000)); + + expect(message).toContain('token = "[redacted]"'); + expect(message).not.toContain("FIXTURE-SECRET"); }); }); @@ -3771,6 +4016,119 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () } }); + it("leaves a small diff to the full-diff mod: the note holds it and the message is empty", () => { + const current = begin({ command: "perl -pi -e 1 small.ts", toolUseId: `call-mod-${calls}` }, quiet); + writeFileSync(join(repo, "small.ts"), "one\ntwo\n"); + const decision = runDiffPost({ + payload: current, + config: { ...DEFAULT_HOOKS_CONFIG, diff: { ...quiet, fullDiff: "path" } }, + drawnByMod: true, + }); + const path = decision.stats?.fullDiff ?? ""; + + try { + expect(decision.decision).toBe("silent"); + expect(decision.message).toBeUndefined(); + expect(decision.files).toEqual([join(repo, "small.ts")]); + expect(readFileSync(path, "utf8")).toContain("+two"); + } finally { + rmSync(join(repo, "small.ts"), { force: true }); + rmSync(path, { force: true }); + } + }); + + it("keeps the full-diff link when the mod cannot draw the patch: past its budget", () => { + const current = begin({ command: "perl -pi -e 1 huge.ts", toolUseId: `call-mod-big-${calls}` }, quiet); + writeFileSync( + join(repo, "huge.ts"), + `${Array.from({ length: 4_000 }, (_, i) => `row ${i} ${"y".repeat(20)}`).join("\n")}\n` + ); + const decision = runDiffPost({ + payload: current, + config: { ...DEFAULT_HOOKS_CONFIG, diff: { ...quiet, fullDiff: "path" } }, + drawnByMod: true, + }); + const path = decision.stats?.fullDiff ?? ""; + + try { + expect(decision.decision).toBe("noted"); + expect(decision.message).toContain(`Full diff: ${path}`); + } finally { + rmSync(join(repo, "huge.ts"), { force: true }); + rmSync(path, { force: true }); + } + }); + + it("keeps the header of a file the mod cannot draw: an empty file deleted has no hunk", () => { + writeFileSync(join(repo, "empty-gone.txt"), ""); + git(["add", "empty-gone.txt"]); + git(["commit", "-qm", "empty file"]); + + const current = begin({ command: "rm empty-gone.txt", toolUseId: `call-mod-empty-${calls}` }, quiet); + rmSync(join(repo, "empty-gone.txt")); + const decision = runDiffPost({ + payload: current, + config: { ...DEFAULT_HOOKS_CONFIG, diff: { ...quiet, fullDiff: "path" } }, + drawnByMod: true, + }); + + try { + expect(decision.decision).toBe("noted"); + expect(stripAnsi(decision.message ?? "")).toContain("empty-gone.txt"); + } finally { + git(["commit", "-qam", "empty file gone"]); + rmSync(decision.stats?.fullDiff ?? "", { force: true }); + } + }); + + it("reads a missing file as empty and an unreadable one as null, which the patch masks as a jar", () => { + const folder = mkdtempSync(join(tmpdir(), "read-head-")); + try { + expect(readHead(join(folder, "never-written.json"))).toBe(""); + // A folder opens but cannot be read (EISDIR): not proven harmless. + expect(readHead(folder)).toBeNull(); + } finally { + rmSync(folder, { recursive: true, force: true }); + } + }); + + it("leaves the diff to the mod only in the session the mod announced", () => { + expect(modLoadedIn({ sessionId: "s-1", announced: "s-1" })).toBe(true); + // A session started before `mods watch` never loaded the mod: no mark, the hook draws. + expect(modLoadedIn({ sessionId: "s-1" })).toBe(false); + // A nested session inherits its parent's mark. + expect(modLoadedIn({ sessionId: "s-2", announced: "s-1" })).toBe(false); + expect(modLoadedIn({ announced: "s-1" })).toBe(false); + expect(modLoadedIn({ sessionId: "", announced: "" })).toBe(false); + }); + + it("lets the mod draw only for Claude, with mods.fullDiff on, a note to read and the plugin enabled", () => { + const on = { ...DEFAULT_HOOKS_CONFIG, mods: { ...DEFAULT_HOOKS_CONFIG.mods, fullDiff: true } }; + + const yes = () => true; + expect(modDrawsDiffs({ config: on, harness: "claude", isEnabled: yes })).toBe(true); + expect(modDrawsDiffs({ config: on, harness: "claude", isEnabled: () => false })).toBe(false); + expect(modDrawsDiffs({ config: on, harness: "codex", isEnabled: yes })).toBe(false); + expect( + modDrawsDiffs({ + config: { ...on, mods: { ...on.mods, fullDiff: false } }, + harness: "claude", + isEnabled: yes, + }) + ).toBe(false); + expect( + modDrawsDiffs({ + config: { ...on, diff: { ...on.diff, fullDiff: "off" } }, + harness: "claude", + isEnabled: yes, + }) + ).toBe(false); + // The session id reaches the check, which asks whether the mod announced this very session. + expect( + modDrawsDiffs({ config: on, harness: "claude", sessionId: "s-1", isEnabled: (id) => id === "s-1" }) + ).toBe(true); + }); + it("renders a file once when the command names it through another path to the same folder", () => { // `/tmp` is a symlink to `/private/tmp` on macOS: git names the root by its real path and // the command by the typed one, and both used to render, 84 times in one day. @@ -3927,6 +4285,20 @@ describe("tools agents hooks diff log", () => { expect(read.notes).toEqual(["1 file(s) changed by others in this root: b.ts"]); }); + it("marks a background task's command that the heading cuts short", () => { + const long = `mkdir -p /x/aside && mv ~/a/auth.json /x/aside/ ; node ${"/x/".repeat(20)}login.mjs`; + + expect(deferredTaskHeading({ id: "bgA", command: "bun /x/long.ts" })).toBe( + "background task bgA ended (bun /x/long.ts):\n" + ); + expect(deferredTaskHeading({ id: "bgA", command: long })).toBe( + `background task bgA ended (${long.slice(0, 79)}…):\n` + ); + expect(deferredTaskHeading({ id: "bgA", command: "bun /x/a.ts\nbun /x/b.ts" })).toBe( + "background task bgA ended (bun /x/a.ts…):\n" + ); + }); + it("splits a message at background task headings: the call's own part first, then each task's", () => { const message = [ "Updated src/own.ts (+1 -0)", @@ -4193,6 +4565,60 @@ describe("the misfires of 2026-10-14", () => { git(["commit", "-qm", "mv done"]); }); + it("keeps a blank context line at the end of a hunk in the full-diff note", () => { + const path = writeFullDiff({ + toolUseId: `call-blank-${calls}`, + command: "perl -pi -e 1 a.ts", + parts: [{ label: "a.ts (+1 -0)", patch: "@@ -1,2 +1,3 @@\n one\n+two\n \n" }], + notShown: [], + }); + + try { + expect(readFileSync(path ?? "", "utf8")).toContain("@@ -1,2 +1,3 @@\n one\n+two\n \n```"); + } finally { + rmSync(path ?? "", { force: true }); + } + }); + + it("masks a token in the command the full-diff note saves", () => { + const token = `ghp_${"A1b2C3d4".repeat(5)}`; + const path = writeFullDiff({ + toolUseId: `call-cmd-secret-${calls}`, + command: `printf "${token}" > config.txt`, + parts: [{ label: "config.txt (+1 -0)", patch: "@@ -0,0 +1 @@\n+[redacted]\n" }], + notShown: [], + }); + + try { + const note = readFileSync(path ?? "", "utf8"); + + expect(note).toContain('printf "[redacted]" > config.txt'); + expect(note).not.toContain(token); + } finally { + rmSync(path ?? "", { force: true }); + } + }); + + it("names a file with uncommitted edits, moved into a new folder, in one line and not as a deletion", () => { + writeFileSync(join(repo, "dirty-src.ts"), "committed\nbody\n"); + git(["add", "dirty-src.ts"]); + git(["commit", "-qm", "dirty mv"]); + writeFileSync(join(repo, "dirty-src.ts"), "committed\nbody\nUNCOMMITTED-EDIT\n"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(repo, "dirty-src.ts"), past, past); + + const current = begin({ command: "mkdir -p moved/deeper && git mv dirty-src.ts moved/deeper/dirty-dst.ts" }); + + mkdirSync(join(repo, "moved", "deeper"), { recursive: true }); + git(["mv", "dirty-src.ts", "moved/deeper/dirty-dst.ts"]); + + const decision = runDiffPost({ payload: current, config: plain }); + + expect(decision.message ?? "").not.toContain("Deleted"); + expect(decision.message).toContain("dirty-src.ts → moved/deeper/dirty-dst.ts"); + git(["commit", "-qam", "dirty mv done"]); + }); + it("prints a block past maxBlockLines as its header and a count, and links the full diff", () => { const big = join(repo, "big.ts"); const diff = { ...plain.diff, maxBlockLines: 5, fullDiff: "path" as const }; @@ -4463,6 +4889,42 @@ describe("the misfires of 2026-10-14", () => { } }); + it("saves a cut background diff under a note name the full-diff mod does not draw", () => { + const project = `gt-bg-note-${process.pid}`; + const tasks = join(tmpdir(), `claude-${process.getuid?.() ?? 0}`, project, "gt-diff-test", "tasks"); + const output = join(tasks, "bgnote1.output"); + const transcript = join(tmpdir(), project, "transcript.jsonl"); + const target = join(repo, "background-note.ts"); + + mkdirSync(tasks, { recursive: true }); + writeFileSync(output, ""); + + try { + const started = begin({ toolUseId: "bg-note-call", command: `bun /x/long.ts ${target}` }); + runDiffPost({ + payload: { + ...started, + raw: { transcript_path: transcript, tool_response: { backgroundTaskId: "bgnote1" } }, + }, + config: plain, + }); + writeFileSync(target, Array.from({ length: 300 }, (_, i) => `BACKGROUND-ROW-${i}`).join("\n")); + writeFileSync(output, "done\n\n[exited with code 0]\n"); + + const next = begin({ command: "true", raw: { transcript_path: transcript } }); + const note = flushDeferred({ payload: next, config: plain, maxChars: 2000 })[0]?.stats?.fullDiff ?? ""; + + try { + expect(basename(note)).toBe("bg-note-call.task.md"); + } finally { + rmSync(note, { force: true }); + } + } finally { + rmSync(join(tmpdir(), `claude-${process.getuid?.() ?? 0}`, project), { recursive: true, force: true }); + rmSync(target, { force: true }); + } + }); + it("does not announce a rename that was already gone when this command began", () => { writeFileSync(join(repo, "owned-src.ts"), "moved\nbody\n"); git(["add", "owned-src.ts"]); diff --git a/src/agents/lib/hooks/diff/full.ts b/src/agents/lib/hooks/diff/full.ts index b14fadd968..52f0147362 100644 --- a/src/agents/lib/hooks/diff/full.ts +++ b/src/agents/lib/hooks/diff/full.ts @@ -2,6 +2,7 @@ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs import { join } from "node:path"; import { routerLink } from "@genesiscz/utils/browser-router/router-link"; import { genesisToolsDir } from "@genesiscz/utils/storage/root"; +import { redactSecretsInText } from "@genesiscz/utils/transclude/redact"; import { hookDiag } from "../log"; import { safeSegment } from "../paths"; @@ -25,14 +26,49 @@ export function fullDiffDir(): string { export interface FullDiffPart { label: string; patch: string; + /** The changed file, when `label` adds its counts. */ + path?: string; +} + +/** + * A file past the cap, headed ` (+a -b)` like a drawn block: the full-diff mod parses only that heading + * (`plugins/genesis-tools-mods/hooks/sections.ts`), so a bare path heading was dropped from its view. + */ +export function overflowPart(path: string, patch: string): FullDiffPart { + // Only rows inside a hunk count: an added source line that starts with `++` is `+++…` in the patch too. + let added = 0; + let removed = 0; + let inHunk = false; + + for (const line of patch.split("\n")) { + if (line.startsWith("@@")) { + inHunk = true; + } else if (line.startsWith("diff --git ")) { + inHunk = false; + } else if (inHunk && line.startsWith("+")) { + added += 1; + } else if (inHunk && line.startsWith("-")) { + removed += 1; + } + } + + return { label: `${path} (+${added} -${removed})`, path, patch }; } /** Writes the call's full diff as markdown and returns its path, or `null` when it could not. */ +/** + * The note of a background call written when its task ends: the mod draws `.md` under that call's row, which a + * finished task's diff already reaches through the hook's message, so this name keeps the mod from drawing it twice. + * `diff show ` still finds it, by prefix. + */ +export const DEFERRED_NOTE_SUFFIX = ".task"; + export function writeFullDiff(options: { toolUseId: string | undefined; command: string; parts: FullDiffPart[]; notShown: string[]; + deferred?: boolean; }): string | null { const call = safeSegment(options.toolUseId); @@ -41,7 +77,9 @@ export function writeFullDiff(options: { } const fence = "```"; - const command = options.command.trim(); + // The patches come masked (`gitPatch` in run.ts); the command is masked here, because the note keeps it on disk + // whatever `logCommands` and `diff.commandLines` say: `printf "ghp_…" > config.txt` saved the token in plain text. + const command = redactSecretsInText(options.command.trim()); const sections = [ `# Diff of ${call}`, "", @@ -51,12 +89,21 @@ export function writeFullDiff(options: { command.length > 4000 ? `${command.slice(0, 4000)}\n…` : command, fence, "", - ...options.parts.flatMap((part) => [`## ${part.label}`, "", `${fence}diff`, part.patch.trimEnd(), fence, ""]), + // Trailing newlines only: a blank context line is a lone space, and `trimEnd()` cut it, which left the hunk a + // line short of its header, so Claude Code drew the full-diff mod's copy as plain code (2026-10-19). + ...options.parts.flatMap((part) => [ + `## ${part.label}`, + "", + `${fence}diff`, + part.patch.replace(/\n+$/, ""), + fence, + "", + ]), ...(options.notShown.length > 0 ? ["## Changed, past the file cap", "", ...options.notShown.map((path) => `- ${path}`), ""] : []), ]; - const path = join(fullDiffDir(), `${call}.md`); + const path = join(fullDiffDir(), `${call}${options.deferred ? DEFERRED_NOTE_SUFFIX : ""}.md`); try { mkdirSync(fullDiffDir(), { recursive: true, mode: 0o700 }); diff --git a/src/agents/lib/hooks/diff/render.ts b/src/agents/lib/hooks/diff/render.ts index 018191d97e..8322aad7c9 100644 --- a/src/agents/lib/hooks/diff/render.ts +++ b/src/agents/lib/hooks/diff/render.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { basename } from "node:path"; import { env } from "@genesiscz/utils/env"; +import { stripAnsi } from "@genesiscz/utils/string"; import type { DiffConfig, FillMode } from "../config"; import type { DiffCategory } from "./classify"; import type { ChangedFile } from "./collect"; @@ -848,6 +849,11 @@ function textLength(text: string): number { return text.length; } +/** Equal once colour and whitespace are gone. */ +function sameText(a: string, b: string): boolean { + return stripAnsi(a).replace(/\s+/g, "") === stripAnsi(b).replace(/\s+/g, ""); +} + /** * The right edge of a `block` fill: the widest kept CHANGED row of the file plus two columns (a context * row carries no band), and never past `fillMax` columns in all (the gutter is `INDENT` of them). A hook @@ -1044,6 +1050,14 @@ export function fitMessage(blocks: DiffBlock[], config: DiffConfig): { message: } const next = contextLine(absolute, paint); + + // `bat` colours the file on disk, the patch was masked (redact.ts): a line whose secret was masked + // reads differently once plain, and keeps its masked plain text instead of the coloured original. + // Whitespace is left out of the comparison, because `bat` expands tabs. + if (!sameText(paint, line.content ?? line.text)) { + continue; + } + const extra = textLength(next) - textLength(line.text); // Colour is a luxury: it is applied only while it still fits. Without this the diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index fd80528a2a..5a1b5b6011 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -12,10 +12,12 @@ import { writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { basename, dirname, join, relative } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { env } from "@genesiscz/utils/env"; import type { StatusEntry } from "@genesiscz/utils/git/porcelain"; import { SafeJSON } from "@genesiscz/utils/json"; import { isTestProcess } from "@genesiscz/utils/test-process"; +import { looksLikeCookieJar, redactSecretsInText } from "@genesiscz/utils/transclude/redact"; import { type ChangeBytes, capBlob, @@ -30,6 +32,7 @@ import { gitObjectSink } from "../../changes/objects"; import { type DiffConfig, diffFor, type HooksConfig } from "../config"; import { committedPaths, gitOut, lastHistoryMove, objectId, statusOf, untrackedFilesIn } from "../git"; import { hookDiag } from "../log"; +import { modsPluginDisabled } from "../mods"; import { callDir, safeSegment } from "../paths"; import { type HookPayload, normalizeEvent } from "../payload"; import { @@ -45,13 +48,13 @@ import { summaryLine, type Touch, } from "./attribution"; -import { alreadyGone, beforeCopy, leftOutOfCapture } from "./before"; +import { alreadyGone, beforeCopier, beforeCopy, leftOutOfCapture } from "./before"; import { transcriptOf } from "./capture"; import { claimChange } from "./claim"; import { classifyChange, type DiffCategory } from "./classify"; import { type ChangedFile, changedFiles } from "./collect"; import { commandDirs, namedArguments } from "./command-paths"; -import { type FullDiffPart, fullDiffLine, writeFullDiff } from "./full"; +import { type FullDiffPart, fullDiffLine, overflowPart, writeFullDiff } from "./full"; import { appendMentions, canonical, covers, loadMentions, type Mentions, mentionsFrom, mentionsOf } from "./mentions"; import { namedChanges } from "./named"; import { type DiffBlock, fitMessage, highlightRange, hunkRange, renderBlock, renderPatch, themedDress } from "./render"; @@ -96,23 +99,89 @@ function patchFor({ const context = `-U${config.contextLines}`; if (before) { - return gitOut(file.root, ["diff", "--no-index", context, "--", before, file.path]); + return gitPatch(file.root, ["--no-index", context, "--", before, file.path]); } if (file.untracked) { - return gitOut(file.root, ["diff", "--no-index", context, "--", "/dev/null", file.path]); + return gitPatch(file.root, ["--no-index", context, "--", "/dev/null", file.path]); } // `base` is the HEAD the command STARTED on, not today's HEAD. The two differ exactly // when the command committed, and diffing a just-committed file against the commit that // created it reports nothing at all. - return gitOut(file.root, ["diff", base, context, "--", file.path]); + return gitPatch(file.root, [base, context, "--", file.path], [baseHead(file, base)]); +} + +/** + * The first 256 KB of the file as `base` holds it: a command that removed a key's BEGIN line from a file with no + * captured copy leaves a current head that no longer looks like a key. "" for a file absent at `base`. + */ +function baseHead(file: ChangedFile, base: string): string { + const path = relative(file.root, resolve(file.root, file.path)); + return gitOut(file.root, ["show", `${base}:${path}`], { quiet: true }).slice(0, 256 * 1024); +} + +/** + * `git diff ` with every secret-shaped value masked. Every patch the hook shows or saves comes from here, + * so neither the model, the screen nor the full-diff note gets a token or a login cookie (a re-login rewrote a + * Playwright storageState, and its note kept the session cookies in plain text, 2026-10-20). + */ +function gitPatch(root: string, args: string[], baseHeads: string[] = []): string { + const paths = args.slice(args.indexOf("--") + 1).filter((path) => path !== "/dev/null"); + const heads = [...paths.map((path) => readHead(resolve(root, path))), ...baseHeads]; + const patch = gitOut(root, ["diff", ...args]); + + // A hunk of a key file can show key lines without the BEGIN and END markers the key pattern needs, so a + // file that holds a private key shows its changed rows, never their text. + if (heads.some((head) => head !== null && PRIVATE_KEY_FILE.test(head))) { + return withheldPatch(patch); + } + + // A file that could not be read is not proven harmless: it is masked as a cookie jar would be. + return redactSecretsInText(patch, { cookieJar: heads.some((head) => head === null || looksLikeCookieJar(head)) }); +} + +// The marker on a line of its own, as a key file holds it: a source file that quotes it (a test fixture) is not a key. +const PRIVATE_KEY_FILE = /^-----BEGIN [A-Z ]*PRIVATE KEY-----\r?$/m; + +/** The patch with every body row's text replaced: the row count and the +/- shape stay, the content does not. */ +export function withheldPatch(patch: string): string { + return patch + .split("\n") + .map((line) => (/^[-+ ]/.test(line) && !/^(\+\+\+|---) /.test(line) ? `${line[0]}[redacted]` : line)) + .join("\n"); +} + +/** + * The first 256 KB of a file: enough to find a storage-state file's `"cookies"` or a key's BEGIN line. "" for a file + * that does not exist (a deleted side), null for one that exists but could not be read, which the caller fails closed on. + */ +export function readHead(path: string): string | null { + try { + const fd = openSync(path, "r"); + try { + const buffer = Buffer.alloc(256 * 1024); + return buffer.toString("utf8", 0, readSync(fd, buffer, 0, buffer.length, 0)); + } finally { + closeSync(fd); + } + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return ""; + } + + hookDiag("Could not read the head of a changed file; its patch is masked as a cookie jar", { err, path }); + return null; + } } /** * The rendered block for one changed file, or `null` when the diff turned out empty. A * DELETED file always renders: its block is the header alone when git has no patch for it. */ +/** The patch characters the full-diff mod draws per view (`MAX_CHARS` in plugins/genesis-tools-mods/hooks/register.tsx). */ +export const MOD_MAX_PATCH_CHARS = 60_000; + /** * Columns a band leaves free: Claude Code draws a hook message 6 columns in (under its `⎿`), so a * row filled to the full terminal width wraps and leaves an empty band row under every changed row @@ -247,11 +316,18 @@ export function renamesIn({ base, files, entries, + beforeOf, }: { root: string; base: string; files: ChangedFile[]; entries: StatusEntry[]; + /** + * The file's copy from before the call (`beforeCopy`), when it was dirty then. Its blob in `base` lacks the + * earlier uncommitted edits, so a dirty file moved by `git mv` matched nothing and printed as a whole + * deletion (2026-10-18: three Swift files of 782, 305 and 218 lines). + */ + beforeOf?: (absolute: string) => string | null; }): Array<{ from: string; to: string; @@ -326,8 +402,26 @@ export function renamesIn({ } const blobs = new Map(); + const copies = gone.flatMap((file) => { + const copy = beforeOf?.(file.path) ?? null; + + return copy ? [{ file, copy }] : []; + }); + const fromHead = gone.filter((file) => !copies.some((item) => item.file === file)); + + for (const chunk of inChunks(copies, RENAME_ARGS)) { + const out = gitOut(root, ["hash-object", "--", ...chunk.map((item) => item.copy)], { quiet: true }).split("\n"); + + chunk.forEach((item, index) => { + const hash = out[index]?.trim(); - for (const chunk of inChunks(gone, RENAME_ARGS)) { + if (hash) { + blobs.set(hash, [...(blobs.get(hash) ?? []), item.file.path]); + } + }); + } + + for (const chunk of inChunks(fromHead, RENAME_ARGS)) { const listing = gitOut(root, ["ls-tree", "-z", base, "--", ...chunk.map((file) => relative(root, file.path))], { quiet: true, }); @@ -529,9 +623,14 @@ function taskEnded(output: string): boolean { */ const DEFERRED_MIN_ROOM = 200; -/** The line `hook-diff-post` prints above a background task's diff. Its length is part of the budget. */ +/** + * The line `hook-diff-post` prints above a background task's diff. Its length is part of the budget. A command + * cut short ends in `…`, so a chain like `mkdir … && mv … ; node login.mjs` does not read as if its first + * 80 characters made the change (2026-10-20). + */ export function deferredTaskHeading(task: { id: string; command: string }): string { - const first = task.command.split("\n")[0]?.slice(0, 80) ?? ""; + const [line = "", ...rest] = task.command.split("\n"); + const first = line.length > 80 ? `${line.slice(0, 79)}…` : rest.length > 0 ? `${line}…` : line; return `background task ${task.id} ended (${first}):\n`; } @@ -605,6 +704,54 @@ export function deferredMessageParts(flushed: DiffDecision[]): string[] { ); } +/** + * Set by the full-diff mod, on every turn, to the id of the session it runs in (`MODS_SESSION_VAR` in + * plugins/genesis-tools-mods/hooks/register.tsx; the two names must stay equal). Claude Code passes a mod's + * `env.set` to the hooks of that session (probed 2026-10-22: the PostToolUse hook read the mod's value, equal to + * the payload's `session_id`). The settings file only says the mod is installed; a session started before + * `mods watch` has not loaded it, and its diffs would be drawn by no one. + */ +export const MODS_SESSION_VAR = "GENESIS_TOOLS_MODS_SESSION"; + +/** Whether the mod announced itself in this very session: a nested session inherits another session's id. */ +export function modLoadedIn({ sessionId, announced }: { sessionId?: string; announced?: string }): boolean { + return sessionId !== undefined && sessionId !== "" && announced === sessionId; +} + +/** The mod's mark and the real Claude settings, never read by a test (they would make the message depend on this machine). */ +function installedMod(sessionId: string | undefined): boolean { + // The mark is the proof the mod is loaded here, from any scope or `--plugin-dir`. The settings are read only for + // a marked session, and only an explicit off (a disable this session has not reloaded yet) overrides the mark. + return ( + !isTestProcess() && + modLoadedIn({ sessionId, announced: env.getProcessEnv()[MODS_SESSION_VAR] }) && + !modsPluginDisabled() + ); +} + +/** + * Claude Code with the full-diff mod (`genesis-tools-mods`) loaded in this session and `mods.fullDiff` on: the mod + * draws each call's diff from its note, so the hook's own message would draw it twice. Codex and Grok have no mods. + */ +export function modDrawsDiffs({ + config, + harness, + sessionId, + isEnabled = installedMod, +}: { + config: HooksConfig; + harness: string; + sessionId?: string; + isEnabled?: (sessionId: string | undefined) => boolean; +}): boolean { + return ( + harness === "claude" && + config.mods.fullDiff && + diffFor(config, harness).fullDiff !== "off" && + isEnabled(sessionId) + ); +} + /** * One post phase: background tasks that have ended share half of the message, and this call gets * what remains. The sum, headings included, stays inside `maxMessageChars`. Claude Code replaces @@ -627,7 +774,8 @@ export function composePostDiff({ const flushedChars = flushedParts.reduce((total, part) => total + part.length + 2, 0); const heading = commandLines(payload.command, diffFor(config, payload.harness).commandLines); const room = Math.max(0, maxChars - flushedChars - (heading ? heading.length + 1 : 0)); - const decision = fitDecision(runDiffPost({ payload, config, record, maxChars: room }), room); + const drawnByMod = modDrawsDiffs({ config, harness: payload.harness, sessionId: payload.sessionId }); + const decision = fitDecision(runDiffPost({ payload, config, record, maxChars: room, drawnByMod }), room); const own = decision.message ? [heading ? `${heading}\n${decision.message}` : decision.message] : []; const message = [...own, ...flushedParts].join("\n\n"); @@ -723,6 +871,7 @@ export function flushDeferred({ config, record, maxChars: room, + deferred: true, }), room ); @@ -1129,12 +1278,21 @@ export function runDiffPost({ config, record = recordBashEdits, maxChars, + drawnByMod = false, + deferred = false, }: { payload: HookPayload; config: HooksConfig; record?: EditRecorder; /** The message budget when it must share the hook's one message (`flushDeferred`); else `diff.maxMessageChars`. */ maxChars?: number; + /** A background call's diff taken when its task ended (`flushDeferred`): its note is one the mod does not draw. */ + deferred?: boolean; + /** + * The full-diff mod draws this call's diff (`modDrawsDiffs`): every diff goes to its note, and the message + * keeps only the notes under it, so the diff is not drawn twice. + */ + drawnByMod?: boolean; }): DiffDecision { const diff = diffFor(config, payload.harness); @@ -1266,9 +1424,13 @@ export function runDiffPost({ } const changed = changedFiles({ root, since, config: diff, source: { entries: summary.entries, committed } }); - const renames = renamesIn({ root, base, files: changed, entries: summary.entries }).filter((pair) => - ownedRename(pair, { dir, root, context: attribution }) - ); + const renames = renamesIn({ + root, + base, + files: changed, + entries: summary.entries, + beforeOf: beforeCopier({ dir, root }), + }).filter((pair) => ownedRename(pair, { dir, root, context: attribution })); const renamed = new Set(renames.flatMap((pair) => [pair.from, pair.to])); const carried: string[] = []; const ownCommits = new Set(committed.map((name) => join(root, name))); @@ -1353,7 +1515,7 @@ export function runDiffPost({ const patch = patchFor({ file, before, config: diff, base }); if (patch.trim().length > 0) { - overflow.push({ label: file.path, patch }); + overflow.push(overflowPart(file.path, patch)); } } @@ -1490,8 +1652,7 @@ export function runDiffPost({ // Always `--no-index`, in both directions: a named path may live outside every // repository, so `git diff HEAD` is not available to it. A deletion is the copy // against `/dev/null`, which is what gives the removed lines. - const patch = gitOut(file.root, [ - "diff", + const patch = gitPatch(file.root, [ "--no-index", `-U${diff.contextLines}`, "--", @@ -1500,8 +1661,8 @@ export function runDiffPost({ ]); if (full()) { - if (patch.trim().length > 0 && !overflow.some((part) => part.label === change.path)) { - overflow.push({ label: change.path, patch }); + if (patch.trim().length > 0 && !overflow.some((part) => part.path === change.path)) { + overflow.push(overflowPart(change.path, patch)); } if (!notShown.includes(change.path) && !files.includes(change.path)) { @@ -1584,6 +1745,41 @@ export function runDiffPost({ return { decision: "noted", reason, message: notes.join("\n"), files: [], others }; } + if (drawnByMod) { + const parts = [...blocks.flatMap((block) => (block.source ? [block.source] : [])), ...overflow]; + const unpatched = notShown.filter((path) => !overflow.some((part) => part.path === path)); + const path = writeFullDiff({ + toolUseId: payload.toolUseId, + command: payload.command, + parts, + notShown: unpatched, + }); + + if (path) { + // The mod draws only the patches that fit its budget, and nothing of a call past the file cap: keep + // the link then, or such a diff has no visible trace at all. + const undrawn = + unpatched.length > 0 || + parts.reduce((total, part) => total + part.patch.length, 0) > MOD_MAX_PATCH_CHARS; + // The mod draws a file from its first `@@` hunk (`sectionsOf`); a file with none (an empty file deleted + // or created, a binary, a mode change) keeps its own header line here, or it vanished from the view. + const hunkless = blocks + .filter((block) => !/^@@ /m.test(block.source?.patch ?? "")) + .map((block) => block.head); + const message = [...hunkless, ...notes, ...(undrawn ? [`Full diff: ${path}`] : [])].join("\n"); + + return { + decision: message ? "noted" : "silent", + reason: "the full-diff mod draws the diff", + ...(message ? { message } : {}), + files, + attributed, + others, + stats: { chars: message.length, blocks: blocks.length, elided: 0, dropped: 0, fullDiff: path }, + }; + } + } + const noteChars = notes.reduce((total, line) => total + line.length + 2, 0); const budget = Math.max(0, (maxChars ?? diff.maxMessageChars) - noteChars); let fitted = fitMessage(blocks, { ...diff, maxMessageChars: budget }); @@ -1597,7 +1793,8 @@ export function runDiffPost({ toolUseId: payload.toolUseId, command: payload.command, parts: [...blocks.flatMap((block) => (block.source ? [block.source] : [])), ...overflow], - notShown: notShown.filter((path) => !overflow.some((part) => part.label === path)), + notShown: notShown.filter((path) => !overflow.some((part) => part.path === path)), + deferred, }); const line = path ? fullDiffLine(path, diff.fullDiff) : null; diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 6eb9d2a25a..46a5bd0124 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -54,7 +54,15 @@ import { wiringStatus, } from "./install"; import { logDecision, releaseRotationLock, setDiagLogPath, setMaxLogBytes, takeRotationLock } from "./log"; -import { MODS_PLUGIN, modsPluginState } from "./mods"; +import { + MODS_PLUGIN, + modsPluginDisabled, + modsPluginState, + modsWatched, + modsWatchState, + pluginDirsOf, + withModsWatch, +} from "./mods"; import { nativeDiffState } from "./native-diff"; import { matchesGlob, resolveOutcome } from "./outcome"; import { callDir, hookDataRoot, safeSegment, sessionDir } from "./paths"; @@ -661,6 +669,7 @@ describe("applySetting", () => { writeFileSync(path, SafeJSON.stringify({ mods: { idleCompactMinutes: 75, idleCompactMinTokens: 1000 } })); expect(loadHooksConfig(path).mods).toEqual({ fullDiff: true, + collapseGitSync: true, idleCompact: true, idleCompactMinutes: 56, idleCompactMinTokens: 1000, @@ -684,6 +693,62 @@ describe("applySetting", () => { expect(modsPluginState("not json")).toEqual({ installed: false, enabled: false }); }); + it("lets only the strongest settings file that names the mods plugin turn it off", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-mods-settings-")); + const local = join(dir, "settings.local.json"); + const user = join(dir, "settings.json"); + const named = (on: boolean) => SafeJSON.stringify({ enabledPlugins: { [MODS_PLUGIN]: on } }); + + // A project, local or --plugin-dir load leaves the user settings silent: the session's mark decides. + expect(modsPluginDisabled([local, user])).toBe(false); + writeFileSync(user, named(true)); + writeFileSync(local, named(false)); + expect(modsPluginDisabled([local, user])).toBe(true); + writeFileSync(local, named(true)); + writeFileSync(user, named(false)); + expect(modsPluginDisabled([local, user])).toBe(false); + writeFileSync(local, "not json"); + expect(modsPluginDisabled([local, user])).toBe(true); + }); + + it("mods watch adds the mods folder to CLAUDE_CODE_PLUGIN_DIRS and takes it out again, keeping the rest", () => { + const folder = "/repo/plugins/genesis-tools-mods"; + const settings = { model: "opus", env: { OTHER: "1", CLAUDE_CODE_PLUGIN_DIRS: "/a/one" } }; + const on = withModsWatch(settings, folder, true); + + expect(pluginDirsOf(on)).toEqual(["/a/one", folder]); + expect(modsWatched(on)).toBe(true); + expect(withModsWatch(on, folder, true)).toEqual(on); + expect(on.model).toBe("opus"); + + const off = withModsWatch(on, folder, false); + + expect(off).toEqual(settings); + expect(withModsWatch({ env: { CLAUDE_CODE_PLUGIN_DIRS: `${folder}/` } }, folder, false)).toEqual({}); + expect(modsWatched({})).toBe(false); + + const dir = mkdtempSync(join(tmpdir(), "gt-mods-watch-")); + const path = join(dir, "settings.json"); + + // A watched load names no enabledPlugins entry, so it never turns the mod off. + writeFileSync(path, SafeJSON.stringify(on)); + expect(modsPluginDisabled([path])).toBe(false); + }); + + it("mods status reads hot reload as unknown, not a crash, when the settings file is not JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-mods-state-")); + const path = join(dir, "settings.json"); + + expect(modsWatchState(path)).toBe(false); + writeFileSync( + path, + SafeJSON.stringify({ env: { CLAUDE_CODE_PLUGIN_DIRS: "/repo/plugins/genesis-tools-mods" } }) + ); + expect(modsWatchState(path)).toBe(true); + writeFileSync(path, "{not json"); + expect(modsWatchState(path)).toBeUndefined(); + }); + it("agentsTalk.hint is a boolean switch that ships on and survives a load", () => { expect(DEFAULT_HOOKS_CONFIG.agentsTalk.hint).toBe(true); expect(applySetting(DEFAULT_HOOKS_CONFIG, "agentsTalk.hint", "false").agentsTalk.hint).toBe(false); diff --git a/src/agents/lib/hooks/mods-plugin.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts new file mode 100644 index 0000000000..0684b29b08 --- /dev/null +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { + compactionEndsIdleWait, + IDLE_COMPACT_DEFAULTS, + IDLE_NOTICE, + idleCompactSettings, + idleCompactSkip, + idleNotice, +} from "../../../../plugins/genesis-tools-mods/hooks/idle"; +import { + collapseGitSyncEnabled, + fullDiffEnabled, + gitSyncOf, + isDeletion, + OPEN_FILE_LINES, + openByDefault, + sectionsOf, + sectionsOfCalls, +} from "../../../../plugins/genesis-tools-mods/hooks/sections"; +import { overflowPart } from "./diff/full"; +import { MOD_MAX_PATCH_CHARS, MODS_SESSION_VAR } from "./diff/run"; + +const NOTE = [ + "# Diff of toolu_x", + "", + "```sh", + "sed -i s/a/b/ src/a.ts src/b.ts", + "```", + "", + "## /repo/src/a.ts (+1 -1)", + "", + "```diff", + "diff --git a/a.ts b/a.ts", + "index 111..222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1,2 +1,2 @@", + "-a", + "+b", + " same", + "```", + "", + "## /repo/src/b.ts (+0 -1)", + "", + "```diff", + "@@ -1,1 +0,0 @@", + "-gone", + "```", + "", + "## Changed, past the file cap", + "- /repo/src/c.ts", +].join("\n"); + +describe("the mod's session mark", () => { + it("is the variable the diff hook reads", () => { + const source = readFileSync( + join(import.meta.dir, "../../../../plugins/genesis-tools-mods/hooks/register.tsx"), + "utf8" + ); + expect(source).toContain(`$.env.set("${MODS_SESSION_VAR}", id)`); + }); + + it("shares the hook's patch budget, so a call the mod cannot draw keeps its full-diff link", () => { + const source = readFileSync( + join(import.meta.dir, "../../../../plugins/genesis-tools-mods/hooks/register.tsx"), + "utf8" + ); + const budget = source.match(/const MAX_CHARS = ([\d_]+);/)?.[1]?.replaceAll("_", ""); + expect(Number(budget)).toBe(MOD_MAX_PATCH_CHARS); + }); +}); + +describe("the full-diff mod's note reader", () => { + it("reads a file past the hook's file cap, headed with its counts by overflowPart", () => { + const part = overflowPart("/repo/src/d.ts", "@@ -1,1 +1,2 @@\n-old\n+new\n+more"); + const note = ["# Diff of toolu_z", "", `## ${part.label}`, "", "```diff", part.patch, "```", ""].join("\n"); + + expect(sectionsOf(note).map((section) => [section.path, section.added, section.removed])).toEqual([ + ["/repo/src/d.ts", 2, 1], + ]); + }); + + it("counts an added or removed source line that itself starts with ++ or --", () => { + const patch = ["--- a/src/c.c", "+++ b/src/c.c", "@@ -1,2 +1,2 @@", "---i;", "+++i;", " x"].join("\n"); + + expect(overflowPart("/repo/src/c.c", patch).label).toBe("/repo/src/c.c (+1 -1)"); + }); + + it("reads each file of a note from its first hunk, and nothing else", () => { + const sections = sectionsOf(NOTE); + + expect(sections.map((section) => section.path)).toEqual(["/repo/src/a.ts", "/repo/src/b.ts"]); + expect(sections[0]?.patch).toBe("@@ -1,2 +1,2 @@\n-a\n+b\n same"); + expect(sections[1]?.patch).toBe("@@ -1,1 +0,0 @@\n-gone"); + expect(sections.map((section) => [section.added, section.removed])).toEqual([ + [1, 1], + [0, 1], + ]); + expect(sectionsOf("# Diff of toolu_y\n\n```sh\nls\n```\n")).toEqual([]); + }); + + it("reads a group's notes in call order, skips a call with no note, and stops at the budget", async () => { + const notes: Record = { first: NOTE, third: NOTE }; + const read = async (id: string) => { + const note = notes[id]; + + if (note === undefined) { + throw new Error(`no note for ${id}`); + } + + return note; + }; + + const all = await sectionsOfCalls({ ids: ["first", "second", "third"], read, maxChars: 1_000 }); + + expect(all.sections.map((section) => `${section.call} ${section.path}`)).toEqual([ + "first /repo/src/a.ts", + "first /repo/src/b.ts", + "third /repo/src/a.ts", + "third /repo/src/b.ts", + ]); + expect(all.omitted).toEqual([]); + + const cut = await sectionsOfCalls({ ids: ["first", "third"], read, maxChars: 40 }); + + expect(cut.sections.map((section) => section.path)).toEqual(["/repo/src/a.ts"]); + }); + + it("names every call of a group whose files went past the shared budget", async () => { + // Each call fits alone, so the hook dropped both links; together the second one was not drawn at all. + const read = async () => NOTE; + const one = (await sectionsOfCalls({ ids: ["first"], read, maxChars: 1_000 })).sections; + const each = one.reduce((total, section) => total + section.patch.length, 0); + const group = await sectionsOfCalls({ ids: ["first", "second", "third"], read, maxChars: each + 10 }); + + expect(group.sections.map((section) => section.call)).toEqual(["first", "first"]); + expect(group.omitted).toEqual(["second", "third"]); + }); + + it("draws a file the note holds twice once", () => { + const twice = [NOTE.split("## /repo/src/b.ts")[0], NOTE.split("## /repo/src/b.ts")[0]].join(""); + + expect(sectionsOf(twice).map((section) => section.path)).toEqual(["/repo/src/a.ts"]); + }); + + it("folds a whole deletion, a long file and what is past the row's budget by default", () => { + const file = (path: string, added: number, removed: number, patch = "@@ -1,2 +1,2 @@\n-a\n+b") => ({ + path, + added, + removed, + patch, + }); + const gone = file("gone.ts", 0, 782, "@@ -1,782 +0,0 @@\n-x"); + + expect(isDeletion(gone)).toBe(true); + expect(isDeletion(file("edit.ts", 0, 1, "@@ -3,2 +3,1 @@\n-x\n y"))).toBe(false); + expect( + openByDefault([ + file("small.ts", 1, 1), + gone, + file("long.ts", OPEN_FILE_LINES, 1), + file("mid1.ts", 100, 0), + file("mid2.ts", 100, 0), + file("mid3.ts", 100, 0), + ]) + ).toEqual([true, false, false, true, true, false]); + }); + + it("is on unless the hooks file sets mods.fullDiff to false", () => { + expect(fullDiffEnabled(undefined)).toBe(true); + expect(fullDiffEnabled("not json")).toBe(true); + expect(fullDiffEnabled('{"mods":{"fullDiff":"no"}}')).toBe(true); + expect(fullDiffEnabled('{"mods":{"fullDiff":false}}')).toBe(false); + expect(fullDiffEnabled('{"diff":{}}')).toBe(true); + expect(collapseGitSyncEnabled('{"mods":{"fullDiff":false}}')).toBe(true); + expect(collapseGitSyncEnabled('{"mods":{"collapseGitSync":false}}')).toBe(false); + }); + + it("names the git sync a command runs, and not one it only mentions", () => { + expect(gitSyncOf("cd /r && git pull -q --ff-only 2>&1 | tail -2; git log -5")).toBe("git pull"); + expect(gitSyncOf("git -C /r stash pop")).toBe("git stash pop"); + expect(gitSyncOf("git rebase origin/master")).toBe("git rebase"); + expect(gitSyncOf("git log --oneline; git diff")).toBeUndefined(); + expect(gitSyncOf('git commit -m "after git pull"')).toBeUndefined(); + expect(gitSyncOf("git commit -F - <<'EOF'\ngit merge was clean\nEOF")).toBeUndefined(); + expect(gitSyncOf("tools git pullout")).toBeUndefined(); + }); + + it("reads a quoted git folder as one argument, and no git sync a command only prints", () => { + expect(gitSyncOf('git -C "/repo/a b" pull')).toBe("git pull"); + expect(gitSyncOf("git -c core.pager=cat --no-pager merge topic")).toBe("git merge"); + expect(gitSyncOf("echo git pull; perl -pi -e s/a/b/ a.ts")).toBeUndefined(); + expect(gitSyncOf("printf '%s' x && git status")).toBeUndefined(); + }); +}); + +describe("idle compaction", () => { + it("reads its settings from the mods section and keeps the default for a bad value", () => { + expect(idleCompactSettings(undefined)).toEqual(IDLE_COMPACT_DEFAULTS); + expect(idleCompactSettings("not json")).toEqual(IDLE_COMPACT_DEFAULTS); + expect( + idleCompactSettings( + SafeJSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 50, idleCompactMinTokens: 0 } }) + ) + ).toEqual({ enabled: false, minutes: 50, minTokens: 0 }); + // 60 minutes or more would fire after the cache expired. + expect( + idleCompactSettings(SafeJSON.stringify({ mods: { idleCompactMinutes: 60, idleCompact: "no" } })) + ).toEqual(IDLE_COMPACT_DEFAULTS); + }); + + it("compacts only when on, drawn and over the floor", () => { + const settings = { enabled: true, minutes: 56, minTokens: 200_000 }; + + expect(idleCompactSkip({ settings, surfaces: 1, tokens: 300_000 })).toBeUndefined(); + expect(idleCompactSkip({ settings: { ...settings, enabled: false }, surfaces: 1, tokens: 300_000 })).toBe( + "mods.idleCompact is off" + ); + expect(idleCompactSkip({ settings, surfaces: 0, tokens: 300_000 })).toContain("no surface"); + expect(idleCompactSkip({ settings, surfaces: 1, tokens: 199_999 })).toContain("under the floor"); + expect(idleCompactSkip({ settings, surfaces: 1, tokens: undefined })).toContain("unknown"); + }); + + it("a precompute, a skipped and a subagent compaction leave the armed timer alone", () => { + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: false })).toBe(true); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "manual", skipped: false })).toBe(true); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "precompute", skipped: false })).toBe(false); + expect(compactionEndsIdleWait({ agentId: undefined, trigger: "auto", skipped: true })).toBe(false); + expect(compactionEndsIdleWait({ agentId: "a1", trigger: "auto", skipped: false })).toBe(false); + }); + + it("writes a notice that starts with the text the session scan counts", () => { + const text = idleNotice({ minutes: 56, tokensBefore: 550_000, tokensAfter: 40_000 }); + + expect(text.startsWith(IDLE_NOTICE)).toBe(true); + expect(text).toContain("550,000 to 40,000 tokens"); + expect(idleNotice({ minutes: 56, tokensBefore: undefined, tokensAfter: 1 })).not.toContain("tokens)"); + }); +}); diff --git a/src/agents/lib/hooks/mods.ts b/src/agents/lib/hooks/mods.ts index 243dd9117b..476788cb7a 100644 --- a/src/agents/lib/hooks/mods.ts +++ b/src/agents/lib/hooks/mods.ts @@ -1,3 +1,7 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, delimiter, join } from "node:path"; +import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -58,6 +62,115 @@ export function modsPluginState(listJson: string): ModsPluginState { }; } +/** `/settings.json`: the user settings, where `CLAUDE_CONFIG_DIR` moves the claude dir. */ +export function claudeUserSettingsPath(): string { + return join(env.paths.getClaudeConfigDir() ?? join(homedir(), ".claude"), "settings.json"); +} + +/** The folder plugins name a session's own plugin folders by, in the process env or settings `env`. */ +export const PLUGIN_DIRS_VAR = "CLAUDE_CODE_PLUGIN_DIRS"; + +const MODS_FOLDER_NAME = "genesis-tools-mods"; + +function settingsEnv(settings: unknown): Record { + const block = isRecord(settings) ? settings.env : undefined; + + return isRecord(block) ? block : {}; +} + +/** The folders `CLAUDE_CODE_PLUGIN_DIRS` names in a settings object's `env` block. */ +export function pluginDirsOf(settings: unknown): string[] { + const value = settingsEnv(settings)[PLUGIN_DIRS_VAR]; + + return typeof value === "string" ? value.split(delimiter).filter((dir) => dir.length > 0) : []; +} + +/** Whether the settings load a mods plugin folder (`mods watch`), hot-reloaded on every save. */ +export function modsWatched(settings: unknown): boolean { + return pluginDirsOf(settings).some((dir) => basename(dir.replace(/\/+$/, "")) === MODS_FOLDER_NAME); +} + +/** + * `modsWatched` of the user settings file: `false` when there is no file, `undefined` when it cannot be read or is + * not JSON. `mods status` reports that as unknown instead of stopping at a malformed settings file. + */ +export function modsWatchState(settingsPath = claudeUserSettingsPath()): boolean | undefined { + if (!existsSync(settingsPath)) { + return false; + } + + try { + return modsWatched(SafeJSON.parse(readFileSync(settingsPath, "utf8"))); + } catch (err) { + logger.debug({ err, settingsPath }, "Claude settings unreadable: hot reload is unknown"); + return undefined; + } +} + +/** + * The settings with the mods folder added to (or, `on: false`, every mods folder taken out of) + * `env.CLAUDE_CODE_PLUGIN_DIRS`. Every interactive session loads such a folder as a `--plugin-dir` and + * watches it, so a save reloads the mod; an installed plugin is never watched, only re-read by + * `/reload-plugins`. Other folders in the list and other env keys are kept; an emptied list is removed. + */ +export function withModsWatch(settings: unknown, folder: string, on: boolean): Record { + const base = isRecord(settings) ? { ...settings } : {}; + const env = { ...settingsEnv(settings) }; + const others = pluginDirsOf(settings).filter((dir) => basename(dir.replace(/\/+$/, "")) !== MODS_FOLDER_NAME); + const dirs = on ? [...others, folder] : others; + + if (dirs.length > 0) { + env[PLUGIN_DIRS_VAR] = dirs.join(delimiter); + } else { + delete env[PLUGIN_DIRS_VAR]; + } + + if (Object.keys(env).length > 0) { + base.env = env; + } else { + delete base.env; + } + + return base; +} + +/** + * The settings files that can turn the mods plugin on or off for this session, the strongest first: the project's + * local and shared settings (`CLAUDE_PROJECT_DIR`, which Claude Code gives its hooks), then the user settings. + */ +export function modsSettingsPaths(projectDir = env.getProcessEnv().CLAUDE_PROJECT_DIR): string[] { + const project = projectDir + ? [join(projectDir, ".claude", "settings.local.json"), join(projectDir, ".claude", "settings.json")] + : []; + return [...project, claudeUserSettingsPath()]; +} + +/** + * Whether the strongest settings file that names the mods plugin turns it off. A file that does not name it (a + * `--plugin-dir` or watched load, a project install read from the user side) decides nothing. + */ +export function modsPluginDisabled(paths = modsSettingsPaths()): boolean { + for (const path of paths) { + let settings: unknown; + + try { + settings = SafeJSON.parse(readFileSync(path, "utf8")); + } catch (err) { + logger.debug({ err, path }, "Claude settings unreadable: they decide nothing about the mods plugin"); + continue; + } + + const enabled = isRecord(settings) ? settings.enabledPlugins : undefined; + const value = isRecord(enabled) ? enabled[MODS_PLUGIN] : undefined; + + if (typeof value === "boolean") { + return !value; + } + } + + return false; +} + export interface ClaudePluginRun { ok: boolean; stdout: string; diff --git a/src/agents/lib/hooks/set-config.ts b/src/agents/lib/hooks/set-config.ts index 8e6f735b86..1009f1e1e1 100644 --- a/src/agents/lib/hooks/set-config.ts +++ b/src/agents/lib/hooks/set-config.ts @@ -68,7 +68,7 @@ export const SETTABLE_KEYS = [ "decisions.injectAnswers", "decisions.staleness.", "agentsTalk.hint", - "mods.fullDiff", + "mods.", "mods.", ] as const; @@ -422,7 +422,7 @@ export function applySetting( return next; } - if (key === "mods.fullDiff" || key === "mods.idleCompact") { + if (key === "mods.fullDiff" || key === "mods.collapseGitSync" || key === "mods.idleCompact") { next.mods = { ...config.mods, [key.slice("mods.".length)]: asBoolean(key, value) }; return next; } diff --git a/src/utils/transclude/redact.ts b/src/utils/transclude/redact.ts index b5d42544f2..dbfb9ab10c 100644 --- a/src/utils/transclude/redact.ts +++ b/src/utils/transclude/redact.ts @@ -2,7 +2,7 @@ * Masks secret-shaped values in free text before it is stored. Pattern-based and conservative: it * covers the credential formats that show up in files, logs and command output on this kind of * machine (provider API keys, forge and chat tokens, JWTs, private keys, credentials in URLs, and - * `password: x` / `TOKEN=x` assignments). A miss is possible, which is why a transclusion is also + * `password: x` / `TOKEN=x` assignments, cookie headers, and the values in a browser's saved cookies). A miss is possible, which is why a transclusion is also * size-capped and the original token is kept beside the resolved text. */ @@ -19,25 +19,58 @@ const WHOLE_MATCH: RegExp[] = [ /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/g, /\bxai-[A-Za-z0-9]{32,}\b/g, /\bAIza[0-9A-Za-z_-]{35}\b/g, - /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, + // A JWT header is always `eyJ` (base64 of `{"`); its payload need not be, when it is itself encoded. + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, ]; /** The first group is kept, the rest of the match is masked. */ const KEEP_PREFIX: RegExp[] = [ /(\bBearer\s+)[A-Za-z0-9._~+/-]{12,}=*/gi, /(\b[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s/]+(?=@)/gi, + /(\b(?:Set-)?Cookie:[ \t]*)[^\r\n]+/gi, // A value only counts when it looks like a credential (16+ key characters, a digit among them), so - // code such as `refreshToken: string` or `token = readToken()` in an excerpt stays readable. - /((?:^|[\s"'{,])[\w.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*["']?)(?=[A-Za-z0-9_+/=.-]*\d)[A-Za-z0-9_+/=.-]{16,}/gim, + // code such as `refreshToken: string` or `token = readToken()` in an excerpt stays readable. A name may follow + // a diff row's `+` or `-` (`+TOKEN=…` in a patch of a .env file). + /((?:^[+-]?|[\s"'{,])[\w.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*["']?)(?=[A-Za-z0-9_+/=.-]*\d)[A-Za-z0-9_+/=.-]{16,}/gim, ]; -export function redactSecretsInText(text: string): string { - let result = text; +/** + * A quoted literal assigned to a credential name is a secret whatever its characters: `PASSWORD="A1bc defg hij"` + * has spaces the run-of-key-characters rule above cannot cross. Only a literal (8+ characters, no `$` expansion); + * `token = readToken()` and `password: string` stay readable. + */ +const QUOTED_CREDENTIAL = + /((?:^[+-]?|[\s{,(])["']?[\w.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)["']?\s*[:=]\s*)(["'])(?!\[redacted\])([^"'$\n]{8,})\2/gim; + +/** A browser's saved login (Playwright `storageState`, a cookie jar): every cookie and storage value is a session. */ +const COOKIE_JAR = /"(?:cookies|localStorage|httpOnly|sameSite)"\s*:/; +const JAR_VALUE = /("value"\s*:\s*")(?:[^"\\\n]|\\.)*(?=")/g; + +/** Whether a text is (part of) a saved browser login, whose every `"value"` is a session secret. */ +export function looksLikeCookieJar(text: string): boolean { + return COOKIE_JAR.test(text); +} + +/** + * `cookieJar: true` masks every `"value"` even when `text` alone shows no jar marker: a diff hunk of a storage-state + * file can hold a changed cookie value with none of `cookies`, `httpOnly` or `sameSite` in its context lines. + */ +export function redactSecretsInText(text: string, options: { cookieJar?: boolean } = {}): string { + let result = + options.cookieJar || COOKIE_JAR.test(text) + ? text.replace(JAR_VALUE, (_match, prefix: string) => `${prefix}${MASK}`) + : text; for (const pattern of WHOLE_MATCH) { result = result.replace(pattern, MASK); } + // The whole quoted literal first: the key-character rule below would mask only its first word and leave the rest. + result = result.replace( + QUOTED_CREDENTIAL, + (_match, prefix: string, quote: string) => `${prefix}${quote}${MASK}${quote}` + ); + for (const pattern of KEEP_PREFIX) { result = result.replace(pattern, (_match, prefix: string) => `${prefix}${MASK}`); } diff --git a/src/utils/transclude/transclude.test.ts b/src/utils/transclude/transclude.test.ts index 84d2cd23ef..cc95e180fb 100644 --- a/src/utils/transclude/transclude.test.ts +++ b/src/utils/transclude/transclude.test.ts @@ -305,6 +305,32 @@ describe("helpers", () => { ].join("\n") ); }); + + test("masks a quoted credential literal with spaces, and keeps an expansion or a short literal", () => { + expect(redactSecretsInText('PASSWORD="A1bc defg hij"')).toBe('PASSWORD="[redacted]"'); + expect(redactSecretsInText("client_secret: 'two words here'")).toBe("client_secret: '[redacted]'"); + expect(redactSecretsInText('{"api_key": "abc def ghi jkl"}')).toBe('{"api_key": "[redacted]"}'); + expect(redactSecretsInText('TOKEN="$TOKEN_FROM_ENV"')).toBe('TOKEN="$TOKEN_FROM_ENV"'); + expect(redactSecretsInText('password: "short"')).toBe('password: "short"'); + // A row of a .env patch: the name follows the diff marker. + expect(redactSecretsInText('+PASSWORD="two words here"\n-API_TOKEN=abcd1234efgh5678ijkl')).toBe( + '+PASSWORD="[redacted]"\n-API_TOKEN=[redacted]' + ); + // A quoted password whose first word alone looks like a key: the rest is masked too. + expect(redactSecretsInText('PASSWORD="Abc123456789012345 tail secret"')).toBe('PASSWORD="[redacted]"'); + }); + + test("masks a browser's saved cookies and storage values, a cookie header, and a JWT with an encoded payload", () => { + const jar = + '{"cookies":[{"name":"JSESSIONID","value":"288F69E6","sameSite":"Lax"}],"localStorage":[{"name":"n","value":"v\\"x"}]}'; + + expect(redactSecretsInText(jar)).toBe( + '{"cookies":[{"name":"JSESSIONID","value":"[redacted]","sameSite":"Lax"}],"localStorage":[{"name":"n","value":"[redacted]"}]}' + ); + expect(redactSecretsInText('{"name":"pkg","value":"1.0"}')).toBe('{"name":"pkg","value":"1.0"}'); + expect(redactSecretsInText("Cookie: a=1; b=2")).toBe("Cookie: [redacted]"); + expect(redactSecretsInText("x eyJhbGciOiJIUzUx.ZXlKaGJHY2lP.D5f5cyQknIoM y")).toBe("x [redacted] y"); + }); }); describe("template collisions", () => {