Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a5f53a5
fix(mcp-manager): a global Claude disable denies the server in settin…
genesiscz Oct 13, 2026
0b01fa0
fix(ai, statusline): a narrow pane shortens the branch in the middle …
genesiscz Oct 13, 2026
5e2779a
test(ai-spend): Claude prices are checked against Claude Code's own p…
genesiscz Oct 14, 2026
dd17fb6
fix(codex): tools codex tail --events numbers each turn, counting the…
genesiscz Oct 14, 2026
78f9f57
fix(ai, grok): a malformed Grok auth file is logged by category, neve…
genesiscz Oct 14, 2026
606767d
fix(Clicky): the library integration tests delete their per-test pref…
genesiscz Oct 14, 2026
7fff4e5
fix(tools): a handoff worker commits without asking again when its br…
genesiscz Oct 14, 2026
29cbc21
fix(agents, skills, agents-talk): agents message takes the body as po…
genesiscz Oct 14, 2026
66b4bad
fix(tools): a worker takes a commit grant in its brief as given in ad…
genesiscz Oct 14, 2026
4476e7f
fix(codex): a worker's app-server error shows its nested message and …
genesiscz Oct 14, 2026
ce829f8
perf(GenesisKit, diff): the file tree's git marks are built off the m…
genesiscz Oct 14, 2026
34e8d9a
fix(claude): tools claude info <name> reads account names only, becau…
genesiscz Oct 14, 2026
9a61ad8
feat(ai, macos): with GenesisTools.app installed, a tool gets the vau…
genesiscz Oct 14, 2026
1da3a69
fix(ai, macos): the vault and account approval windows are one SwiftU…
genesiscz Oct 14, 2026
0a926a1
fix(ai, macos): the approval window keeps whole buttons, wraps paths …
genesiscz Oct 14, 2026
5ed9013
fix(ai, macos): a vault approval remembers a relative script by its p…
genesiscz Oct 14, 2026
aceee4f
fix(ai, macos): a vault approval for a bun script holds under every G…
genesiscz Oct 15, 2026
95816a7
fix(grok): a writable worker turn runs with --always-approve, because…
genesiscz Oct 15, 2026
2f5c833
fix(agents, hooks): #520 followup - diff log keeps reading for a back…
genesiscz Oct 15, 2026
24d272d
fix(mcp-manager): a global Claude disable removes the server from mcp…
genesiscz Oct 13, 2026
5e4a50b
fix(ai, macos): the vault window treats the tools wrapper's own prelo…
genesiscz Oct 15, 2026
b653703
fix(agents, hooks): a block fill stops at diff.fillMax (72) columns, …
genesiscz Oct 15, 2026
a0500ae
fix(agents, hooks): a folder moved with one mv is one "old/ -> new/ (…
genesiscz Oct 15, 2026
23b521b
feat(agents, hooks): themed diff rows are coloured by highlight.js in…
genesiscz Oct 15, 2026
d599096
feat(agents, hooks): the diff message names its command in a dim firs…
genesiscz Oct 15, 2026
ee009d2
feat(agents, hooks): diff.commandLines heads a message with the first…
genesiscz Oct 15, 2026
84a8efe
feat(agents, hooks): genesis-tools-mods compacts an idle session 56 m…
genesiscz Oct 16, 2026
a587de1
fix(agents, hooks): a precompute no longer cancels the idle-compactio…
genesiscz Oct 16, 2026
a40c79c
fix(ai): warmup names why an account is refused (organization blocks …
genesiscz Oct 16, 2026
f1ba23e
fix(ai, GenesisHub): the vault gate counts the launcher's orphan-watc…
genesiscz Oct 16, 2026
d6d93c0
feat(GenesisKit, permissions): every permission gate (vault key, AI a…
genesiscz Oct 16, 2026
10b2775
fix(ai, GenesisKit, GenesisHub): address PR review (14 commits)
genesiscz Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "genesis-tools",
"version": "1.0.107",
"version": "1.0.109",
"description": "Plugins for GenesisTools CLI development and management",
"owner": {
"name": "genesiscz",
Expand All @@ -11,7 +11,7 @@
{
"name": "genesis-tools",
"description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.",
"version": "1.0.107",
"version": "1.0.109",
"author": {
"name": "GenesisTools"
},
Expand All @@ -24,6 +24,16 @@
"./skills/"
]
},
{
"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",
"author": {
"name": "GenesisTools"
},
"source": "./plugins/genesis-tools-mods",
"category": "development"
},
{
"name": "genesis-tools-server",
"description": "A single Claude Code command for Linux server security auditing: log analysis, intrusion detection, malware scanning, and rootkit detection. Not an MCP server, despite the name.",
Expand Down
8 changes: 8 additions & 0 deletions plugins/genesis-tools-mods/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "genesis-tools-mods",
"version": "1.1.0",
"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"
}
}
3 changes: 3 additions & 0 deletions plugins/genesis-tools-mods/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"modules": ["./register.tsx"]
}
111 changes: 111 additions & 0 deletions plugins/genesis-tools-mods/hooks/idle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/** The idle-compaction mod's settings, from the `mods` section of the hooks file. */
export interface IdleCompactSettings {
enabled: boolean;
/** Minutes after the last main-loop turn. Under 60, so the compaction still reads the 1-hour cache. */
minutes: number;
/** Below this many context tokens a compaction saves too little to pay for itself. */
minTokens: number;
}

/**
* 56 minutes: Claude Code's own idle compaction fires at 54 (0.9 of the hour) and goes first; the cache
* written by the last request expires at 60. 200,000 tokens is the built-in's floor
* (`CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS` on this machine).
*/
export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minutes: 56, minTokens: 200_000 };

/** The start of the notice the mod appends. GenesisClaude's session-analyzer `idle-compact.ts scan` counts it. */
export const IDLE_NOTICE = "Compacted while idle";

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function wholeIn(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
}

/**
* `mods.idleCompact`, `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` from the hooks file
* (`tools agents hooks config set mods.idleCompact false --write`). A missing or broken file, and a value
* of the wrong kind, leave that value's default.
*/
export function idleCompactSettings(hooksFile: string | undefined): IdleCompactSettings {
let mods: unknown;

try {
const stored: unknown = hooksFile === undefined ? undefined : JSON.parse(hooksFile);
mods = isRecord(stored) ? stored.mods : undefined;
} catch {
mods = undefined;
}

const held = isRecord(mods) ? mods : {};

return {
enabled: typeof held.idleCompact === "boolean" ? held.idleCompact : IDLE_COMPACT_DEFAULTS.enabled,
minutes: wholeIn(held.idleCompactMinutes, 1, 59) ? held.idleCompactMinutes : IDLE_COMPACT_DEFAULTS.minutes,
minTokens: wholeIn(held.idleCompactMinTokens, 0, Number.MAX_SAFE_INTEGER)
? held.idleCompactMinTokens
: IDLE_COMPACT_DEFAULTS.minTokens,
};
}

/**
* Whether a compaction leaves nothing for the timer to save: a main-loop one that ran. A `precompute`
* installs nothing (the engine keeps its summary for a later threshold compaction), and it resolves after
* the turn that started it, so cancelling on it would drop the timer of exactly the largest sessions.
*/
export function compactionEndsIdleWait({
agentId,
trigger,
skipped,
}: {
agentId: string | undefined;
trigger: string;
skipped: boolean;
}): boolean {
return agentId === undefined && trigger !== "precompute" && !skipped;
}

/** Why the timer did not compact, or `undefined` to compact. */
export function idleCompactSkip({
settings,
surfaces,
tokens,
}: {
settings: IdleCompactSettings;
surfaces: number;
tokens: number | undefined;
}): string | undefined {
if (!settings.enabled) {
return "mods.idleCompact is off";
}

if (surfaces === 0) {
return "no surface draws the session (claude -p)";
}

if (tokens === undefined || tokens < settings.minTokens) {
return `context ${tokens ?? "unknown"} tokens, under the floor of ${settings.minTokens}`;
}

return undefined;
}

export function idleNotice({
minutes,
tokensBefore,
tokensAfter,
}: {
minutes: number;
tokensBefore: number | undefined;
tokensAfter: number | undefined;
}): string {
const sizes =
tokensBefore !== undefined && tokensAfter !== undefined
? ` (${tokensBefore.toLocaleString("en-US")} to ${tokensAfter.toLocaleString("en-US")} tokens)`
: "";

return `${IDLE_NOTICE}, before the prompt cache expired: genesis-tools-mods after ${minutes} min${sizes}`;
}
130 changes: 130 additions & 0 deletions plugins/genesis-tools-mods/hooks/mods.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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<string, string> = { 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)");
});
});
Loading
Loading