From 03fd5301f54a5e73c4a9587ffa590c210f75e882 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 21:25:43 +0200 Subject: [PATCH 001/251] feat(agents, hooks): the mods plugin shows each subagent's model on its Agent row and on a folded group line --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- .../genesis-tools-mods/hooks/agent-model.tsx | 84 ++++++++++ plugins/genesis-tools-mods/hooks/register.tsx | 3 + .../tests/agent-model.test.tsx | 146 ++++++++++++++++++ 5 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 plugins/genesis-tools-mods/hooks/agent-model.tsx create mode 100644 plugins/genesis-tools-mods/tests/agent-model.test.tsx diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b2e07a2a35..990e19a268 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "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.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, shows the model each subagent ran on at its Agent row and on a folded group line, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", + "version": "1.6.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 9f3fc1a885..308494e13f 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "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.", + "version": "1.6.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, shows the model each subagent ran on at its Agent row and on a folded group line, 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" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx new file mode 100644 index 0000000000..aa3b45b9aa --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -0,0 +1,84 @@ +// The model each subagent ran on, at the right edge of its Agent row. A folded group draws the models of +// its Agent calls on its line; an expanded group draws its rows, and each Agent row carries its own model. +import type { On } from "claude-code"; + +interface AgentCall { + input: unknown; + output?: unknown; + isRunning: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** `claude-sonnet-5-5` shows as `sonnet-5-5`; a dated id such as `claude-haiku-4-5-20251001` loses its date. */ +function modelLabel(id: string): string { + return id.replace(/^claude-/, "").replace(/-\d{8}$/, ""); +} + +/** The model an Agent result names: the one it runs on, or every model of a mid-run swap, in order. */ +function modelsOf(output: unknown): string[] { + if (!isRecord(output)) { + return []; + } + + const used = Array.isArray(output.modelsUsed) + ? output.modelsUsed.filter((id): id is string => typeof id === "string") + : []; + const current = typeof output.resolvedModel === "string" ? [output.resolvedModel] : []; + + return used.length > 1 ? used : current; +} + +function agentModel(call: AgentCall): string { + const models = modelsOf(call.output).map(modelLabel); + + if (models.length > 0) { + return models.join(" → "); + } + + return call.isRunning ? "pending" : "unknown"; +} + +export function agentModelHooks(on: On): void { + on("ui.render", { component: "ToolUse" }, async ($, e, next) => { + const drawn = await next(e); + + if (e.props.tool !== "Agent") { + return drawn; + } + + const { Box, Text } = $.ui.resolve(e); + + return ( + + {drawn} + {agentModel(e.props)} + + ); + }); + + on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { + const drawn = await next(e); + + if (e.props.isExpanded) { + return drawn; + } + + const agents = e.props.calls.filter((call) => call.tool === "Agent"); + + if (agents.length === 0) { + return drawn; + } + + const { Box, Text } = $.ui.resolve(e); + + return ( + + {drawn} + {agents.map((call) => agentModel(call)).join(" · ")} + + ); + }); +} diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 0a06750801..ce7aa2a883 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,6 +1,7 @@ import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; import { atom, read, update } from "claude-code"; import type { FlippedFiles, ShownCalls } from "../types"; +import { agentModelHooks } from "./agent-model"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; import { type CallSection, @@ -284,6 +285,8 @@ async function announceSession($: EngineInterface): Promise { } export const register: Register = (on) => { + agentModelHooks(on); + on("turn.start", async ($, e, next) => { stopIdleTimer(); await announceSession($); diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx new file mode 100644 index 0000000000..c6b3c01a52 --- /dev/null +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -0,0 +1,146 @@ +import type { On } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; + +// Run with `claude plugin test plugins/genesis-tools-mods`, with the fold tests. + +const SONNET = { + status: "async_launched", + agentId: "agent-1", + description: "Audit pultik installer writes", + resolvedModel: "claude-sonnet-5-5", + modelsUsed: ["claude-sonnet-5-5"], + prompt: "audit", + outputFile: "/tmp/out", +}; + +const HAIKU = { ...SONNET, agentId: "agent-2", resolvedModel: "claude-haiku-5-5", modelsUsed: ["claude-haiku-5-5"] }; + +const AGENT = { + tool_use_id: "toolu_a", + tool: "Agent", + input: { description: "Audit pultik installer writes", prompt: "audit" }, + isRunning: false, + isErrored: false, + isInterrupted: false, + output: SONNET, +}; + +const SHELL = { + tool_use_id: "toolu_b", + tool: "Bash", + input: { command: "git status" }, + isRunning: false, + isErrored: false, + isInterrupted: false, + output: { stdout: "", stderr: "" }, +}; + +/** The engine's own rows, beneath the plugin's. The full-diff hook reads the hooks file under HOME. */ +function engine(on: On): void { + mock.env(on, { HOME: "/home/test" }); + on("ui.render", { component: "ToolUse" }, async ($, e) => { + const { Text } = $.ui.resolve(e); + + return {e.props.tool} row; + }); + on("ui.render", { component: "ToolGroup" }, async ($, e) => { + const { Text } = $.ui.resolve(e); + + return {e.props.calls.length} calls; + }); +} + +test("an Agent row names the model it resolved to", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolUse", + props: AGENT, + }); + + expect(await ui.findAll({ type: "Text", text: /sonnet-5-5/ })).toHaveLength(1); + await ui.unmount(); +}); + +test("a running Agent row says pending until its result names a model", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolUse", + props: { ...AGENT, isRunning: true, output: undefined }, + }); + + expect(await ui.findAll({ type: "Text", text: /pending/ })).toHaveLength(1); + await ui.unmount(); +}); + +test("a mid-run model swap lists each model in order", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolUse", + props: { + ...AGENT, + output: { + ...SONNET, + resolvedModel: "claude-opus-5-5", + modelsUsed: ["claude-haiku-4-5-20251001", "claude-opus-5-5"], + }, + }, + }); + + expect(await ui.findAll({ type: "Text", text: /haiku-4-5 → opus-5-5/ })).toHaveLength(1); + await ui.unmount(); +}); + +test("a folded group lists the model of each Agent call and leaves the other calls out", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { + calls: [AGENT, { ...AGENT, tool_use_id: "toolu_c", output: HAIKU }, SHELL], + isActive: false, + isExpanded: false, + }, + }); + + expect(await ui.findAll({ type: "Text", text: /sonnet-5-5 · haiku-5-5/ })).toHaveLength(1); + await ui.unmount(); +}); + +test("an expanded group draws no model line; each of its Agent rows does", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolGroup", + props: { calls: [AGENT], isActive: false, isExpanded: true }, + }); + + expect(await ui.findAll({ type: "Text", text: /sonnet/ })).toHaveLength(0); + await ui.unmount(); +}); + +test("a Bash row draws unchanged", async ($, on) => { + engine(on); + + const ui = await $.ui.mount({ + plugin: "genesis-tools-mods", + surface: "terminal", + component: "ToolUse", + props: SHELL, + }); + + expect(await ui.findAll({ type: "Text", text: /pending|unknown|sonnet/ })).toHaveLength(0); + await ui.unmount(); +}); From 5ea9e574f24dc8acc6119af07a37c5d8b6bef8a5 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 21:32:10 +0200 Subject: [PATCH 002/251] docs(skills, babysit, handoff-to): handoff-to and babysit set a Codex review's effort from the diff size (high, xhigh above about 50 files), because medium reviews were measured shallow --- .claude-plugin/marketplace.json | 4 ++-- .claude/skills/babysit/SKILL.md | 9 ++++++--- plugins/genesis-tools/.claude-plugin/plugin.json | 2 +- plugins/genesis-tools/skills/handoff-to/SKILL.md | 11 ++++++++++- .../skills/handoff-to/references/codex.md | 2 ++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 990e19a268..91681d837d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.114", + "version": "1.0.115", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -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.114", + "version": "1.0.115", "author": { "name": "GenesisTools" }, diff --git a/.claude/skills/babysit/SKILL.md b/.claude/skills/babysit/SKILL.md index de250c4449..d8c0e8d8d0 100644 --- a/.claude/skills/babysit/SKILL.md +++ b/.claude/skills/babysit/SKILL.md @@ -53,7 +53,8 @@ Write it to `/tmp/cc///babysit/run.json` at the start; the scr | Field | Default | Notes | |---|---|---| | `prs` | asked | PR numbers. Small PRs first; never a PR another session owns (step 3.1). | -| `worker` per PR | `grok` | `grok` (model `grok-4.7-build-fast`, `--auth subscription`) or `codex` (model `gpt-6.1-sol`, effort `medium`, `--write ask`). Mix freely; one worker per PR. | +| `worker` per PR | `grok` | `grok` (model `grok-4.7-build-fast`, `--auth subscription`) or `codex` (model `gpt-6.1-sol`, `--write ask`). Mix freely; one worker per PR. | +| `effort` per PR | from the work | Codex `--effort`: `low`, `medium`, `high`, `xhigh` or `max`. A fix round: `medium`, `high` for a Critical or a cross-file finding. An independent review (step 7a): `high`, `xhigh` above about 50 changed files. Medium reviews were measured shallow (handoff-to, "Effort is the review's depth"). | | `mode` | from `scripts/mode.ts` | `worktree` or `unified`, step 2. | | `maxParallel` | 3 | Workers at once. The master is the bottleneck, not the workers. | | `post` | ask | Post the round summary and replies after the push (review-loop step 4), or hold them in the vault. | @@ -174,7 +175,7 @@ summary go to `.claude/work/babysit/` inside that cwd (ignored by git; Grok's ja - Grok: `tools grok spawn --name bs--grok --cwd -m grok-4.7-build-fast --auth subscription --label "babysit pr" --prompt-file ` with Bash `run_in_background` (it blocks for the whole turn). -- Codex: `tools codex spawn --name bs--sol --model gpt-6.1-sol --effort medium --write ask --cwd --session +- Codex: `tools codex spawn --name bs--sol --model gpt-6.1-sol --effort --write ask --cwd --session --label "babysit pr" --prompt-file `. The `--label` tags every cost-ledger row of the session, so a run's spend is one query later (step 9). @@ -320,7 +321,9 @@ A round counts only when it fixed or answered real findings. When the reviewer b still needs rounds, or a PR merged with fewer than three, run an independent read-only review: `bun .claude/skills/babysit/scripts/review-brief.ts --run --pr --wt [--post-merge] [--swift]`, then `steer.ts` with the printed file to a worker that did NOT fix this PR (Sol; Astra only for a risky cross-system PR, -by the model rule in step 1). Judge its findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --json` (`cost.lastTurn`: `durationMs`, `toolCalls`, `tokens.reasoning`). Judge its findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --dedicated-review`); after a merge they go to a follow-up PR from master (2026-10-14: #518 carried #509's after-merge findings). Never count a reply-only round, or a label, as a fix round: on 2026-10-14 the report said "#500 merged after 3 rounds" when GitHub showed two round summaries. diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index 68d79199bf..9d01c1c762 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.114", + "version": "1.0.115", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/handoff-to/SKILL.md b/plugins/genesis-tools/skills/handoff-to/SKILL.md index a184769f03..dea9c84987 100644 --- a/plugins/genesis-tools/skills/handoff-to/SKILL.md +++ b/plugins/genesis-tools/skills/handoff-to/SKILL.md @@ -83,10 +83,19 @@ Set a model explicitly; an Astra parent should not make every worker Astra. |---|---|---| | Mechanical extraction, formatting, simple lookups, or a fully specified small edit | `gpt-6-luna` | low | | Bounded repository exploration, tracing an established call path, or gathering evidence | `gpt-6-luna` | medium | -| Normal implementation, reproducible debugging, and code review | `gpt-6.1-sol` | medium; high for complex reviews | +| Normal implementation and reproducible debugging | `gpt-6.1-sol` | medium | +| Code review of a PR or branch | `gpt-6.1-sol` | high; xhigh above about 50 changed files | | Difficult cross-system reasoning, ambiguous failures, or an evidence-backed escalation | `gpt-6-astra` | high | `gpt-6.1-sol` accepts `low` to `max` effort only; `none` and `minimal` return an error, as on Astra. + +**Effort is the review's depth: set it from the diff size, never leave a review at medium.** Measured on +2026-10-22 with nine Sol reviews at `medium`: 1 to 4 minutes each, 8 to 26 tool calls, and 358 to 2,399 +reasoning tokens, also for a 125-file, 6,200-line PR. The findings were real, but no worker could have read +every file. Pass `--effort high` for a review, `xhigh` above about 50 changed files, `max` only for a risky +cross-system PR, and tell the worker in the brief to read every changed file and to end with `Files reviewed: + of `. Check the depth afterwards: `tools codex status --name --json` carries the turn's +`durationMs`, `toolCalls` and `tokens.reasoning` under `cost.lastTurn`. Astra is the escalation tier, not the default for exploration. Keep the cheaper worker's findings and failed verification when escalating. If a model is unavailable, disclose the fallback and select the nearest suitable tier instead of silently choosing Astra. diff --git a/plugins/genesis-tools/skills/handoff-to/references/codex.md b/plugins/genesis-tools/skills/handoff-to/references/codex.md index 62852ab47e..4961a0b639 100644 --- a/plugins/genesis-tools/skills/handoff-to/references/codex.md +++ b/plugins/genesis-tools/skills/handoff-to/references/codex.md @@ -15,6 +15,8 @@ The `tools codex spawn` command accepts an explicit model string. Availability s depends on the selected account and backend. Verify the recorded model after dispatch; do not treat a requested model or the parent's config as proof of what ran. Code-review workers should explicitly select Sol; `review_model` does not route arbitrary workers. +Give a review `--effort high`, or `xhigh` above about 50 changed files: at `medium` a review spends about +two thousand reasoning tokens however large the diff is (parent skill, "Effort is the review's depth"). ## Choose the orchestration layer From 1f34936db7df85ffcafacc898dc539540ed9db8a Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 22:14:46 +0200 Subject: [PATCH 003/251] fix(agents, hooks): the mods plugin names a subagent's model in its description, which the agents panel draws; the row hooks only repeated the engine's own model and never reached the launch block --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- .../genesis-tools-mods/hooks/agent-model.tsx | 87 ++-------- .../tests/agent-model.test.tsx | 163 ++++-------------- 4 files changed, 58 insertions(+), 200 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 91681d837d..db789dbc84 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "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, shows the model each subagent ran on at its Agent row and on a folded group line, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.6.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, adds the model a subagent runs on to its description, so the agents panel and its finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", + "version": "1.6.1", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 308494e13f..e584adc324 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.6.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, shows the model each subagent ran on at its Agent row and on a folded group line, 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.", + "version": "1.6.1", + "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, adds the model a subagent runs on to its description, so the agents panel and its finished line name it, 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" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx index aa3b45b9aa..4d1f78b667 100644 --- a/plugins/genesis-tools-mods/hooks/agent-model.tsx +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -1,84 +1,33 @@ -// The model each subagent ran on, at the right edge of its Agent row. A folded group draws the models of -// its Agent calls on its line; an expanded group draws its rows, and each Agent row carries its own model. +// The model a subagent runs on, added to its description, which the agents panel row (`general-purpose Pair one +// · sonnet 4s · ↓ 58k tokens`) and the `Agent "Pair one · sonnet" finished` line draw. No render hook reaches +// the panel (live probe, 2026-10-22). The Agent row needs nothing: the engine prints the model there itself. The +// `N background agents launched` block draws the model's own tool input, which neither this rewrite nor a +// `tool.call` rewrite changes (both tried live), so it stays without the model. +// +// Named only when the spawn knows it: the call's `model` parameter, or the parent's model for a fork. Without +// either, the agent's own definition or the default subagent model decides after this hook, so the description +// stays as it was rather than guess. import type { On } from "claude-code"; -interface AgentCall { - input: unknown; - output?: unknown; - isRunning: boolean; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -/** `claude-sonnet-5-5` shows as `sonnet-5-5`; a dated id such as `claude-haiku-4-5-20251001` loses its date. */ +/** `claude-sonnet-5-5` shows as `sonnet-5-5`; a dated id loses its date; an alias such as `sonnet` stays. */ function modelLabel(id: string): string { return id.replace(/^claude-/, "").replace(/-\d{8}$/, ""); } -/** The model an Agent result names: the one it runs on, or every model of a mid-run swap, in order. */ -function modelsOf(output: unknown): string[] { - if (!isRecord(output)) { - return []; - } - - const used = Array.isArray(output.modelsUsed) - ? output.modelsUsed.filter((id): id is string => typeof id === "string") - : []; - const current = typeof output.resolvedModel === "string" ? [output.resolvedModel] : []; - - return used.length > 1 ? used : current; -} - -function agentModel(call: AgentCall): string { - const models = modelsOf(call.output).map(modelLabel); +function withModel(description: string, label: string): string { + const suffix = ` · ${label}`; - if (models.length > 0) { - return models.join(" → "); - } - - return call.isRunning ? "pending" : "unknown"; + return description.endsWith(suffix) ? description : `${description}${suffix}`; } export function agentModelHooks(on: On): void { - on("ui.render", { component: "ToolUse" }, async ($, e, next) => { - const drawn = await next(e); + on("agent.spawn", (_$, e, next) => { + const model = e.fork ? e.parentModel : e.model; - if (e.props.tool !== "Agent") { - return drawn; + if (!model) { + return next(e); } - const { Box, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {agentModel(e.props)} - - ); - }); - - on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { - const drawn = await next(e); - - if (e.props.isExpanded) { - return drawn; - } - - const agents = e.props.calls.filter((call) => call.tool === "Agent"); - - if (agents.length === 0) { - return drawn; - } - - const { Box, Text } = $.ui.resolve(e); - - return ( - - {drawn} - {agents.map((call) => agentModel(call)).join(" · ")} - - ); + return next({ ...e, description: withModel(e.description, modelLabel(model)) }); }); } diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx index c6b3c01a52..93ef06a2e7 100644 --- a/plugins/genesis-tools-mods/tests/agent-model.test.tsx +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -1,146 +1,55 @@ -import type { On } from "claude-code"; -import { expect, mock, test } from "claude-code/testing"; +import type { AgentSpawnInput, On } from "claude-code"; +import { expect, test } from "claude-code/testing"; // Run with `claude plugin test plugins/genesis-tools-mods`, with the fold tests. -const SONNET = { - status: "async_launched", - agentId: "agent-1", - description: "Audit pultik installer writes", - resolvedModel: "claude-sonnet-5-5", - modelsUsed: ["claude-sonnet-5-5"], - prompt: "audit", - outputFile: "/tmp/out", -}; +/** The engine's own spawn, beneath the plugin: records the description the plugin passed down. */ +function engine(on: On, seen: string[]): void { + on("agent.spawn", async (_$, e) => { + seen.push(e.description); -const HAIKU = { ...SONNET, agentId: "agent-2", resolvedModel: "claude-haiku-5-5", modelsUsed: ["claude-haiku-5-5"] }; - -const AGENT = { - tool_use_id: "toolu_a", - tool: "Agent", - input: { description: "Audit pultik installer writes", prompt: "audit" }, - isRunning: false, - isErrored: false, - isInterrupted: false, - output: SONNET, -}; - -const SHELL = { - tool_use_id: "toolu_b", - tool: "Bash", - input: { command: "git status" }, - isRunning: false, - isErrored: false, - isInterrupted: false, - output: { stdout: "", stderr: "" }, -}; - -/** The engine's own rows, beneath the plugin's. The full-diff hook reads the hooks file under HOME. */ -function engine(on: On): void { - mock.env(on, { HOME: "/home/test" }); - on("ui.render", { component: "ToolUse" }, async ($, e) => { - const { Text } = $.ui.resolve(e); - - return {e.props.tool} row; - }); - on("ui.render", { component: "ToolGroup" }, async ($, e) => { - const { Text } = $.ui.resolve(e); - - return {e.props.calls.length} calls; + return { model: "claude-sonnet-5-5", agentId: `agent-${seen.length}` }; }); } -test("an Agent row names the model it resolved to", async ($, on) => { - engine(on); - - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolUse", - props: AGENT, - }); - - expect(await ui.findAll({ type: "Text", text: /sonnet-5-5/ })).toHaveLength(1); - await ui.unmount(); -}); - -test("a running Agent row says pending until its result names a model", async ($, on) => { - engine(on); - - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolUse", - props: { ...AGENT, isRunning: true, output: undefined }, - }); - - expect(await ui.findAll({ type: "Text", text: /pending/ })).toHaveLength(1); - await ui.unmount(); -}); - -test("a mid-run model swap lists each model in order", async ($, on) => { - engine(on); - - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolUse", - props: { - ...AGENT, - output: { - ...SONNET, - resolvedModel: "claude-opus-5-5", - modelsUsed: ["claude-haiku-4-5-20251001", "claude-opus-5-5"], - }, - }, - }); - - expect(await ui.findAll({ type: "Text", text: /haiku-4-5 → opus-5-5/ })).toHaveLength(1); - await ui.unmount(); -}); +function spawn(description: string, extra: Partial = {}): AgentSpawnInput { + return { + tool_use_id: `toolu_${description.replace(/\W/g, "")}`, + prompt: "Reply with the word ok.", + description, + subagentType: "general-purpose", + provider: { plugin: "engine", tier: "core" }, + parentModel: "claude-opus-5-5[1m]", + background: true, + fork: false, + ...extra, + }; +} -test("a folded group lists the model of each Agent call and leaves the other calls out", async ($, on) => { - engine(on); +test("a spawn that names its model shows it in the description", async ($, on) => { + const seen: string[] = []; + engine(on, seen); - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolGroup", - props: { - calls: [AGENT, { ...AGENT, tool_use_id: "toolu_c", output: HAIKU }, SHELL], - isActive: false, - isExpanded: false, - }, - }); + await $.agent.spawn(spawn("Pair one", { model: "sonnet" })); + await $.agent.spawn(spawn("Pair two", { model: "claude-haiku-4-5-20251001" })); - expect(await ui.findAll({ type: "Text", text: /sonnet-5-5 · haiku-5-5/ })).toHaveLength(1); - await ui.unmount(); + expect(seen).toEqual(["Pair one · sonnet", "Pair two · haiku-4-5"]); }); -test("an expanded group draws no model line; each of its Agent rows does", async ($, on) => { - engine(on); +test("a fork shows the parent's model, which it always inherits", async ($, on) => { + const seen: string[] = []; + engine(on, seen); - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolGroup", - props: { calls: [AGENT], isActive: false, isExpanded: true }, - }); + await $.agent.spawn(spawn("Fork it", { fork: true, model: "haiku" })); - expect(await ui.findAll({ type: "Text", text: /sonnet/ })).toHaveLength(0); - await ui.unmount(); + expect(seen).toEqual(["Fork it · opus-5-5[1m]"]); }); -test("a Bash row draws unchanged", async ($, on) => { - engine(on); +test("a spawn that names no model keeps its description", async ($, on) => { + const seen: string[] = []; + engine(on, seen); - const ui = await $.ui.mount({ - plugin: "genesis-tools-mods", - surface: "terminal", - component: "ToolUse", - props: SHELL, - }); + await $.agent.spawn(spawn("Explore the repo")); - expect(await ui.findAll({ type: "Text", text: /pending|unknown|sonnet/ })).toHaveLength(0); - await ui.unmount(); + expect(seen).toEqual(["Explore the repo"]); }); From ecc7238a627ee0394d0c7a4151a7c0719f7a6510 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 22:43:31 +0200 Subject: [PATCH 004/251] feat(agents, hooks): the mods plugin writes a subagent's model into the Agent call's streamed arguments, so the background agents launched block names it too --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- .../genesis-tools-mods/hooks/agent-model.tsx | 98 +++++++++++++++++-- .../tests/agent-model.test.tsx | 54 +++++++++- 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db789dbc84..5c4adcf1dd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "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, adds the model a subagent runs on to its description, so the agents panel and its finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.6.1", + "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, adds the model a subagent runs on to its description, so the launch block, the agents panel and the finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", + "version": "1.6.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 e584adc324..6c8762c63b 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.6.1", - "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, adds the model a subagent runs on to its description, so the agents panel and its finished line name it, 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.", + "version": "1.6.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, adds the model a subagent runs on to its description, so the launch block, the agents panel and the finished line name it, 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" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx index 4d1f78b667..6d2f576bcc 100644 --- a/plugins/genesis-tools-mods/hooks/agent-model.tsx +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -1,13 +1,15 @@ -// The model a subagent runs on, added to its description, which the agents panel row (`general-purpose Pair one -// · sonnet 4s · ↓ 58k tokens`) and the `Agent "Pair one · sonnet" finished` line draw. No render hook reaches -// the panel (live probe, 2026-10-22). The Agent row needs nothing: the engine prints the model there itself. The -// `N background agents launched` block draws the model's own tool input, which neither this rewrite nor a -// `tool.call` rewrite changes (both tried live), so it stays without the model. +// The model a subagent runs on, added to its description. Two hooks, because the places that draw a description +// read it at two different moments (live probes, 2026-10-22): +// - The `N background agents launched` block (`├ Pair one · sonnet`) and the Agent row draw the arguments the +// model's response recorded. A `tool.call` or `agent.spawn` rewrite comes too late for them, so `turn.step` +// rewrites the Agent call's argument JSON as it streams: what leaves the outermost hook is shown and recorded. +// - The agents panel row and the `Agent "…" finished` line draw the description `agent.spawn` passes on, which +// also covers a fork (its model is the parent's, known only at the spawn) and a call a plugin made. // -// Named only when the spawn knows it: the call's `model` parameter, or the parent's model for a fork. Without -// either, the agent's own definition or the default subagent model decides after this hook, so the description -// stays as it was rather than guess. -import type { On } from "claude-code"; +// Named only when the model is known before the spawn: the call's `model` parameter, or the parent's model for a +// fork. Without either, the agent's own definition or the default subagent model decides later, so the +// description stays as it was rather than guess. +import type { On, TurnStepChunk, TurnStepResult } from "claude-code"; /** `claude-sonnet-5-5` shows as `sonnet-5-5`; a dated id loses its date; an alias such as `sonnet` stays. */ function modelLabel(id: string): string { @@ -20,7 +22,85 @@ function withModel(description: string, label: string): string { return description.endsWith(suffix) ? description : `${description}${suffix}`; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Agent arguments with the model in the description, or `undefined` when they stay as the model wrote them. */ +function labelled(input: unknown): Record | undefined { + if (!isRecord(input) || typeof input.description !== "string" || typeof input.model !== "string") { + return undefined; + } + + if (input.subagent_type === "fork") { + return undefined; + } + + return { ...input, description: withModel(input.description, modelLabel(input.model)) }; +} + +/** The argument JSON of one Agent block, rewritten when it parses and names a model; else as streamed. */ +function rewrittenJson(json: string): string { + try { + const next = labelled(JSON.parse(json)); + + return next ? JSON.stringify(next) : json; + } catch { + // Broken JSON stays broken: the engine reports it as the model wrote it. + return json; + } +} + export function agentModelHooks(on: On): void { + on("turn.step", async function* (_$, e, next) { + const stream = next(e); + const agentBlocks = new Set(); + const held = new Map(); + + function* release(): Generator { + for (const [index, json] of held) { + yield { kind: "input", index, json: rewrittenJson(json) }; + } + + held.clear(); + } + + for await (const chunk of stream) { + if (chunk.kind === "tool" && chunk.name === "Agent") { + yield* release(); + agentBlocks.add(chunk.index); + held.set(chunk.index, ""); + yield chunk; + continue; + } + + if (chunk.kind === "input" && agentBlocks.has(chunk.index)) { + held.set(chunk.index, (held.get(chunk.index) ?? "") + chunk.json); + continue; + } + + // The first chunk after an Agent block's arguments (its block end, the next block, the stop) releases them. + yield* release(); + yield chunk; + } + + yield* release(); + + // Patched too, so a hook above reads the arguments as recorded. A test stub at the bottom returns none. + const result: TurnStepResult | undefined = await stream.result; + + if (!result) { + return result; + } + + return { + ...result, + toolUses: result.toolUses.map((use) => + use.name === "Agent" ? { ...use, input: labelled(use.input) ?? use.input } : use + ), + }; + }); + on("agent.spawn", (_$, e, next) => { const model = e.fork ? e.parentModel : e.model; diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx index 93ef06a2e7..7ddf92917c 100644 --- a/plugins/genesis-tools-mods/tests/agent-model.test.tsx +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -1,4 +1,4 @@ -import type { AgentSpawnInput, On } from "claude-code"; +import type { AgentSpawnInput, On, TurnStepChunk } from "claude-code"; import { expect, test } from "claude-code/testing"; // Run with `claude plugin test plugins/genesis-tools-mods`, with the fold tests. @@ -53,3 +53,55 @@ test("a spawn that names no model keeps its description", async ($, on) => { expect(seen).toEqual(["Explore the repo"]); }); + +/** The model's response beneath the plugin: an Agent call that names a model, an Agent call that does not, a Bash call. */ +function response(on: On): void { + on("turn.step", async function* () { + yield { kind: "tool", index: 0, id: "toolu_a", name: "Agent" }; + yield { kind: "input", index: 0, json: '{"description":"Pair one",' }; + yield { kind: "input", index: 0, json: '"prompt":"ok","model":"sonnet"}' }; + yield { kind: "tool", index: 1, id: "toolu_b", name: "Agent" }; + yield { kind: "input", index: 1, json: '{"description":"Explore","prompt":"ok"}' }; + yield { kind: "tool", index: 2, id: "toolu_c", name: "Bash" }; + yield { kind: "input", index: 2, json: '{"command":"ls"}' }; + yield { kind: "stop", stopReason: "tool_use", usage: null }; + + return { + turnId: "t1", + index: 0, + answer: "", + toolUses: [ + { name: "Agent", input: { description: "Pair one", prompt: "ok", model: "sonnet" } }, + { name: "Agent", input: { description: "Explore", prompt: "ok" } }, + { name: "Bash", input: { command: "ls" } }, + ], + stopReason: "tool_use", + usage: null, + }; + }); +} + +test("an Agent call's streamed arguments carry its model in the description, so the launch block shows it", async ($, on) => { + response(on); + + const stream = $.turn.step({ turnId: "t1", index: 0, model: "claude-haiku-5-5", messageCount: 1 }); + const chunks: TurnStepChunk[] = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + // The stream is what the screen draws and the transcript records. The harness gives a stub at the bottom no + // step result, so the hook's patch of `toolUses` is checked live only. + expect( + chunks.map((chunk) => (chunk.kind === "input" ? `input ${chunk.index}: ${chunk.json}` : chunk.kind)) + ).toEqual([ + "tool", + 'input 0: {"description":"Pair one · sonnet","prompt":"ok","model":"sonnet"}', + "tool", + 'input 1: {"description":"Explore","prompt":"ok"}', + "tool", + 'input 2: {"command":"ls"}', + "stop", + ]); +}); From ac2463b6f45187d80e34c4930e92bbb5f357295c Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 22:55:23 +0200 Subject: [PATCH 005/251] =?UTF-8?q?feat(agents,=20hooks):=20the=20mods=20p?= =?UTF-8?q?lugin=20puts=20a=20subagent's=20model=20in=20front=20of=20its?= =?UTF-8?q?=20description,=20so=20the=20agents=20panel=20reads=20general-p?= =?UTF-8?q?urpose=20=20sonnet=20=C2=B7=20Pair=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 4 ++-- plugins/genesis-tools-mods/.claude-plugin/plugin.json | 4 ++-- plugins/genesis-tools-mods/hooks/agent-model.tsx | 10 ++++++---- plugins/genesis-tools-mods/tests/agent-model.test.tsx | 6 +++--- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5c4adcf1dd..0c140ed8df 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "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, adds the model a subagent runs on to its description, so the launch block, the agents panel and the finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.6.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, puts the model a subagent runs on in front of its description, after the agent type, so the launch block, the agents panel and the finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", + "version": "1.6.3", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 6c8762c63b..5abb1cee5b 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.6.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, adds the model a subagent runs on to its description, so the launch block, the agents panel and the finished line name it, 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.", + "version": "1.6.3", + "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, puts the model a subagent runs on in front of its description, after the agent type, so the launch block, the agents panel and the finished line name it, 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" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx index 6d2f576bcc..a6078863b6 100644 --- a/plugins/genesis-tools-mods/hooks/agent-model.tsx +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -1,6 +1,8 @@ -// The model a subagent runs on, added to its description. Two hooks, because the places that draw a description +// The model a subagent runs on, put in front of its description, so the agents panel row reads +// `general-purpose sonnet · Pair one`: the model right after the agent's type, which no hook can rename (a +// `subagentType` rewrite starts another agent) and the panel draws itself. Two hooks, because the places that draw a description // read it at two different moments (live probes, 2026-10-22): -// - The `N background agents launched` block (`├ Pair one · sonnet`) and the Agent row draw the arguments the +// - The `N background agents launched` block (`├ sonnet · Pair one`) and the Agent row draw the arguments the // model's response recorded. A `tool.call` or `agent.spawn` rewrite comes too late for them, so `turn.step` // rewrites the Agent call's argument JSON as it streams: what leaves the outermost hook is shown and recorded. // - The agents panel row and the `Agent "…" finished` line draw the description `agent.spawn` passes on, which @@ -17,9 +19,9 @@ function modelLabel(id: string): string { } function withModel(description: string, label: string): string { - const suffix = ` · ${label}`; + const prefix = `${label} · `; - return description.endsWith(suffix) ? description : `${description}${suffix}`; + return description.startsWith(prefix) ? description : `${prefix}${description}`; } function isRecord(value: unknown): value is Record { diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx index 7ddf92917c..ebb3964034 100644 --- a/plugins/genesis-tools-mods/tests/agent-model.test.tsx +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -33,7 +33,7 @@ test("a spawn that names its model shows it in the description", async ($, on) = await $.agent.spawn(spawn("Pair one", { model: "sonnet" })); await $.agent.spawn(spawn("Pair two", { model: "claude-haiku-4-5-20251001" })); - expect(seen).toEqual(["Pair one · sonnet", "Pair two · haiku-4-5"]); + expect(seen).toEqual(["sonnet · Pair one", "haiku-4-5 · Pair two"]); }); test("a fork shows the parent's model, which it always inherits", async ($, on) => { @@ -42,7 +42,7 @@ test("a fork shows the parent's model, which it always inherits", async ($, on) await $.agent.spawn(spawn("Fork it", { fork: true, model: "haiku" })); - expect(seen).toEqual(["Fork it · opus-5-5[1m]"]); + expect(seen).toEqual(["opus-5-5[1m] · Fork it"]); }); test("a spawn that names no model keeps its description", async ($, on) => { @@ -97,7 +97,7 @@ test("an Agent call's streamed arguments carry its model in the description, so chunks.map((chunk) => (chunk.kind === "input" ? `input ${chunk.index}: ${chunk.json}` : chunk.kind)) ).toEqual([ "tool", - 'input 0: {"description":"Pair one · sonnet","prompt":"ok","model":"sonnet"}', + 'input 0: {"description":"sonnet · Pair one","prompt":"ok","model":"sonnet"}', "tool", 'input 1: {"description":"Explore","prompt":"ok"}', "tool", From 24c10f1768c588bd3ec5a07dee8a60aed0f9228d Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:02:58 +0200 Subject: [PATCH 006/251] feat(worker): the shared worker contract carries a per-tier review block and the diff manifest, so a reviewer on any backend gets the same depth rules and output shape --- src/utils/worker/contract.test.ts | 63 +++++++++++++++++- src/utils/worker/contract.ts | 104 ++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/utils/worker/contract.test.ts b/src/utils/worker/contract.test.ts index a444cb10a0..824921372e 100644 --- a/src/utils/worker/contract.test.ts +++ b/src/utils/worker/contract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildWorkerContract, WORKER_REPORT_KEYS } from "./contract"; +import { buildWorkerContract, type ReviewManifest, reviewLines, WORKER_REPORT_KEYS } from "./contract"; const bus = { agentName: "codex_task", leadName: "lead", rendezvousSession: "s-1" }; @@ -78,4 +78,65 @@ describe("buildWorkerContract", () => { "loaded for reference" ); }); + + test("review: each tier stacks the rules below it, and low is triage without them", () => { + const low = reviewLines("low").join("\n"); + const medium = reviewLines("medium").join("\n"); + const high = reviewLines("high").join("\n"); + const xhigh = reviewLines("xhigh").join("\n"); + const max = reviewLines("max").join("\n"); + + expect(low).toContain("Triage only: name the three riskiest files"); + expect(low).not.toContain("Read every hunk"); + expect(medium).toContain("Read every hunk of every file in the manifest."); + expect(medium).not.toContain("Open the callers"); + expect(high).toContain("Read every hunk"); + expect(high).toContain("Open the callers and the tests of every changed function."); + expect(high).toContain("Label each finding observed"); + expect(high).not.toContain("per-file pass"); + expect(xhigh).toContain("Label each finding observed"); + expect(xhigh).toContain("Probe each finding (`bun -e`, a test) before you report it, or mark it inferred."); + expect(xhigh).toContain("second pass over every file that touches secrets, processes, concurrency or I/O"); + expect(xhigh).not.toContain("adversarial pass"); + expect(max).toContain("Probe each finding"); + expect(max).toContain("Read the area rules of every touched folder."); + expect(max).toContain("Make an adversarial pass"); + + for (const text of [low, medium, high, xhigh, max]) { + expect(text).toContain( + "`1. [Severity] path:line - defect. Scenario: . Fix: .`" + ); + expect(text).toContain("`Files reviewed: of `"); + expect(text).toContain("`Not reviewed: none`"); + } + }); + + test("review: the manifest and tier ride after the report shape; without review the text is unchanged", () => { + const manifest: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 40, deleted: 2 }, + { path: "assets/logo.png", added: 0, deleted: 0, binary: true }, + ], + added: 40, + deleted: 2, + }; + + for (const backend of ["codex", "grok", "claude"] as const) { + const plain = buildWorkerContract({ backend, sandbox: "read-only" }); + const reviewer = buildWorkerContract({ + backend, + sandbox: "read-only", + review: { effort: "high", manifest }, + }); + + expect(buildWorkerContract({ backend, sandbox: "read-only", review: null })).toBe(plain); + // Without surfaces the review block is the only addition, appended after the report shape. + expect(reviewer.startsWith(`${plain}\n`)).toBe(true); + expect(reviewer).toContain("You are a code REVIEWER at the high tier."); + expect(reviewer).toContain("Files to review (2, +40 -2):\n- src/a.ts +40 -2\n- assets/logo.png (binary)"); + expect(reviewer).toContain("`git diff origin/master...HEAD -- `"); + expect(reviewer.indexOf("OPEN: ")).toBeLessThan(reviewer.indexOf("code REVIEWER")); + } + }); }); diff --git a/src/utils/worker/contract.ts b/src/utils/worker/contract.ts index 1adcbe82e5..2550af5df4 100644 --- a/src/utils/worker/contract.ts +++ b/src/utils/worker/contract.ts @@ -17,6 +17,109 @@ export interface WorkerContractInput { bus?: WorkerBus | null; /** Which personal surfaces the worker loaded; on by default for every backend that can honour it. */ surfaces?: { skills: boolean; rules: boolean }; + /** Present when the worker is a code reviewer: the tier rules, the diff manifest and the review output shape. */ + review?: WorkerReviewInput | null; +} + +/** Review depth tiers, shallowest first. They reuse the reasoning-effort names the workers already take. */ +export const REVIEW_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +export type ReviewEffort = (typeof REVIEW_EFFORTS)[number]; + +export function isReviewEffort(value: unknown): value is ReviewEffort { + return typeof value === "string" && (REVIEW_EFFORTS as readonly string[]).includes(value); +} + +/** One changed file of `git diff --numstat ...HEAD`. */ +export interface ReviewManifestFile { + path: string; + added: number; + deleted: number; + /** git printed `-` for both counts: there is no text to read. */ + binary?: boolean; +} + +/** The files a review must cover, computed by the harness before the worker starts. */ +export interface ReviewManifest { + base: string; + files: ReviewManifestFile[]; + added: number; + deleted: number; +} + +export interface WorkerReviewInput { + effort: ReviewEffort; + manifest: ReviewManifest; +} + +/** Added plus deleted lines: the size the reasoning floor is measured against. */ +export function manifestChangedLines(manifest: Pick): number { + return manifest.added + manifest.deleted; +} + +/** `Files to review (3, +40 -2):` and one `path +a -d` line per file. */ +export function manifestLines(manifest: ReviewManifest): string[] { + return [ + `Base: ${manifest.base}. Read each change with \`git diff ${manifest.base}...HEAD -- \`.`, + `Files to review (${manifest.files.length}, +${manifest.added} -${manifest.deleted}):`, + ...manifest.files.map((file) => + file.binary ? `- ${file.path} (binary)` : `- ${file.path} +${file.added} -${file.deleted}` + ), + ]; +} + +/** What each tier adds over the one below it; `reviewLines` stacks them up to the chosen tier. */ +const TIER_RULES: Record = { + low: ["Triage only: name the three riskiest files of the manifest and why each one is risky."], + medium: [ + "Read every hunk of every file in the manifest.", + "Every finding names a concrete scenario that triggers it.", + ], + high: [ + "Open the callers and the tests of every changed function.", + "Run the focused tests your sandbox allows.", + "Label each finding observed (name the command that showed it) or inferred.", + ], + xhigh: [ + "Make a per-file pass over correctness, error paths, security, platform differences and tests.", + "Probe each finding (`bun -e`, a test) before you report it, or mark it inferred.", + "Make a second pass over every file that touches secrets, processes, concurrency or I/O.", + "Check the change's own follow-up fixes for new defects.", + ], + max: [ + "Read the area rules of every touched folder.", + "Trace interactions across files: process lifecycle, stacked branches, shared state.", + "Make an adversarial pass: how does this change break in production?", + ], +}; + +/** + * The review contract of one tier: the rules of every tier up to it (high includes medium's), then the + * output shape the depth gate parses. `low` is triage and has no medium rules under it. The shape lines + * come before the five report lines of {@link reportShapeLines}, which still end the message. + */ +export function reviewLines(effort: ReviewEffort): string[] { + const upTo = REVIEW_EFFORTS.indexOf(effort); + const rules = + effort === "low" ? TIER_RULES.low : REVIEW_EFFORTS.slice(1, upTo + 1).flatMap((tier) => TIER_RULES[tier]); + + return [ + `You are a code REVIEWER at the ${effort} tier. Do not change any file; report defects.`, + ...rules, + "Before the five report lines, your final message has this shape:", + "Numbered findings, one per item: `1. [Severity] path:line - defect. Scenario: . Fix: .`", + "Severity is Critical, High, Medium or Low. With no findings, write the line `Findings: none`.", + "Then the line `Ran: `.", + "Then the line `Files reviewed: of `, where N counts every file of the manifest.", + "Then the line `Not reviewed: (), ...` naming every file you did not read, or `Not reviewed: none`.", + ]; +} + +function reviewBlock(input: WorkerContractInput): string[] { + if (!input.review) { + return []; + } + + return [...reviewLines(input.review.effort), ...manifestLines(input.review.manifest)]; } /** The keys every worker's final message carries, in this order. `tools …` printers read RESULT. */ @@ -154,6 +257,7 @@ export function buildWorkerContract(input: WorkerContractInput): string { ...checkpointLines(), ...sandboxLines(input), ...reportShapeLines(), + ...reviewBlock(input), ...surfaceLines(input), ].join("\n"); } From 31808fad9c6957ec483ab469123fa10820c0f5bb Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:03:12 +0200 Subject: [PATCH 007/251] feat(codex): spawn --mode review takes a base, injects the diff manifest and the tier contract, picks the tier from the diff size, and gates the depth after each turn (one steer, one rerun a tier higher, then shallow), logging every turn and decision to the review ledger --- src/codex/daemon.ts | 6 + src/codex/lib/driver.ts | 48 ++- src/codex/lib/review/controller.ts | 198 ++++++++++ src/codex/lib/review/escalate.ts | 67 ++++ src/codex/lib/review/gate.ts | 330 ++++++++++++++++ src/codex/lib/review/ledger.ts | 129 +++++++ src/codex/lib/review/prepare.ts | 309 +++++++++++++++ src/codex/lib/review/report.ts | 194 ++++++++++ src/codex/lib/review/review.test.ts | 568 ++++++++++++++++++++++++++++ src/codex/lib/review/types.ts | 44 +++ src/codex/lib/seed-instructions.ts | 4 +- src/codex/lib/session.test.ts | 123 ++++++ src/codex/lib/session.ts | 139 ++++++- src/codex/lib/spawn.ts | 23 +- src/codex/lib/store.ts | 3 + 15 files changed, 2172 insertions(+), 13 deletions(-) create mode 100644 src/codex/lib/review/controller.ts create mode 100644 src/codex/lib/review/escalate.ts create mode 100644 src/codex/lib/review/gate.ts create mode 100644 src/codex/lib/review/ledger.ts create mode 100644 src/codex/lib/review/prepare.ts create mode 100644 src/codex/lib/review/report.ts create mode 100644 src/codex/lib/review/review.test.ts create mode 100644 src/codex/lib/review/types.ts diff --git a/src/codex/daemon.ts b/src/codex/daemon.ts index 6f238a0d49..8200b3a1cd 100644 --- a/src/codex/daemon.ts +++ b/src/codex/daemon.ts @@ -14,6 +14,7 @@ import { computerUseOverrides } from "./lib/computer-use"; import { ControlLogCursor, respondToControl } from "./lib/control-channel"; import { buildAccountLaunchOptions } from "./lib/launch-options"; import { sessionControlPath, sessionDaemonLogPath, sessionLaunchPath } from "./lib/paths"; +import { escalateCodexReview } from "./lib/review/escalate"; import { CodexSessionRuntime } from "./lib/session"; import type { LaunchConfig } from "./lib/spawn"; import { CodexSessionStore } from "./lib/store"; @@ -131,6 +132,11 @@ async function run(): Promise { store, meta: (await store.readMeta(name)) ?? meta, ...(launch.project ? { project: launch.project } : {}), + escalateReview: (request) => escalateCodexReview(request, launch), + onReviewGate: async (event) => { + store.appendEvent(name, { source: "daemon", method: "review_gate", params: event }); + await bridge?.publishEvent(event); + }, onApprovalRequest: async (notice) => { store.appendEvent(name, { source: "daemon", method: "approval_request", params: notice }); if (!meta.agentsEnabled) { diff --git a/src/codex/lib/driver.ts b/src/codex/lib/driver.ts index 95dba8be46..759aa82692 100644 --- a/src/codex/lib/driver.ts +++ b/src/codex/lib/driver.ts @@ -8,6 +8,7 @@ import { WorkerDeliveryRejectedError, workerSourceHome } from "@genesiscz/utils/ import type { WorkerDriver, WorkerVerbOutcome } from "@genesiscz/utils/worker/driver"; import { type CodexControl, parseControlTarget } from "./control"; import { sendControlRequest } from "./control-channel"; +import { prepareReview } from "./review/prepare"; import { isCodexDaemonPid, parseWritePolicy, schemaDriftWarning, spawnCodexSession } from "./spawn"; import { type CodexSessionMeta, CodexSessionStore, deriveSessionStatus } from "./store"; import { followSessionEvents } from "./tail-events"; @@ -52,12 +53,28 @@ export const codexDriver: WorkerDriver = { command .option("--computer-use", "Configure installed official Mac Computer Use for this worker") .option("--home ", "CODEX_HOME override") - .option("--effort ", "Reasoning effort") + .option( + "--effort ", + "Reasoning effort; for --mode review a tier (high | xhigh | max, or low | medium with --allow-shallow), default from the diff size" + ) .option( "--write ", "ask | allow | deny | full (full = no sandbox, refused in a main checkout: use a throwaway worktree)" ) - .option("--mode ", "review | task", "task") + .option( + "--mode ", + "review | task. review: injects the diff manifest and the tier's review contract, then gates the depth after each turn", + "task" + ) + .option( + "--base ", + "--mode review: the ref to diff against (git diff ...HEAD); default the branch's PR target, else its upstream" + ) + .option("--allow-shallow", "--mode review: accept --effort low or medium, which the gate never tunes up") + .option( + "--paths ", + "--mode review: limit the manifest to these paths (one slice of a split review)" + ) .option("--no-agents", `Disable ${toolCommand("agents")} integration`) .option("--session ", `Parent ${toolCommand("agents")} session id`) .option("--writable-root ", "Additional writable roots") @@ -95,12 +112,19 @@ export const codexDriver: WorkerDriver = { session?: string; writableRoot?: string[]; project?: string; + base?: string; + allowShallow?: boolean; + paths?: string[]; }; if (extras.mode !== "review" && extras.mode !== "task") { throw new Error("--mode must be review or task"); } + if (extras.mode === "task" && (extras.base || extras.allowShallow || extras.paths)) { + throw new Error("--base, --allow-shallow and --paths apply to --mode review only"); + } + // A silent no-op here reads as isolation that was applied. It never was, so this // refuses instead of pretending: a caller who asked for isolation and did not get it // must find out now, not from a worker that turned out to load every skill. @@ -116,6 +140,25 @@ export const codexDriver: WorkerDriver = { ); } + const prepared = + extras.mode === "review" + ? await prepareReview({ + cwd: input.cwd, + ...(extras.base ? { base: extras.base } : {}), + ...(extras.effort === undefined ? {} : { effort: extras.effort }), + allowShallow: extras.allowShallow === true, + ...(extras.paths ? { paths: extras.paths } : {}), + }) + : null; + + for (const note of prepared?.notes ?? []) { + out.log.info(note); + } + + for (const warning of prepared?.warnings ?? []) { + out.log.warn(warning); + } + const meta = await spawnCodexSession({ name: input.name, cwd: input.cwd, @@ -125,6 +168,7 @@ export const codexDriver: WorkerDriver = { ...(extras.computerUse === undefined ? {} : { computerUse: extras.computerUse }), ...(extras.home === undefined ? {} : { home: extras.home }), ...(extras.effort === undefined ? {} : { effort: extras.effort }), + ...(prepared ? { review: { ...prepared.config, step: 0, chain: input.name } } : {}), write: parseWritePolicy(extras.write), mode: extras.mode, agents: extras.agents, diff --git a/src/codex/lib/review/controller.ts b/src/codex/lib/review/controller.ts new file mode 100644 index 0000000000..5f77f3022a --- /dev/null +++ b/src/codex/lib/review/controller.ts @@ -0,0 +1,198 @@ +import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; +import { logger } from "@genesiscz/utils/logger"; +import type { ReviewEffort } from "@genesiscz/utils/worker/contract"; +import { sumPartTokens } from "@genesiscz/utils/worker/cost"; +import { checkReview, decideGate, type GateDecision, parseReviewReport, type ReviewGateChecks } from "./gate"; +import { appendReviewRecord, type ReviewGateRecord, reviewLedgerBase } from "./ledger"; +import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-gate" }); + +const UNREAD_RECORDED = 40; + +export type ReviewGateAction = + | { kind: "none" } + | { kind: "steer"; text: string } + | { kind: "escalate"; effort: ReviewEffort; name: string }; + +export interface ReviewTurnOutcome { + /** The session's review record after this turn: spent, step and verdict updated. */ + review: CodexReviewConfig; + action: ReviewGateAction; + /** Absent when the gate did not run: a failed or interrupted turn, or a review whose verdict is final. */ + decision?: GateDecision; + checks?: ReviewGateChecks; +} + +export interface ReviewTurnInput { + name: string; + label?: string; + review: CodexReviewConfig; + finished: CodexFinishedTurn; + /** The final agent message of the turn. */ + report: string; + costUsd: number | null; + ledgerPath?: string; +} + +/** The name an escalated review runs under: the chain's first name and the new tier. */ +export function escalatedReviewName(review: Pick, effort: ReviewEffort): string { + return `${review.chain}-${effort}`; +} + +function addSpent(spent: CodexReviewSpent | undefined, finished: CodexFinishedTurn, costUsd: number | null) { + const tokens = sumPartTokens(finished.parts); + const before = spent ?? { turns: 0, durationMs: 0, toolCalls: 0, reasoningTokens: 0, outputTokens: 0, costUsd: 0 }; + + return { + turns: before.turns + 1, + durationMs: before.durationMs + (finished.durationMs ?? 0), + toolCalls: before.toolCalls + finished.toolCalls, + reasoningTokens: before.reasoningTokens + tokens.reasoning, + outputTokens: before.outputTokens + tokens.output, + costUsd: before.costUsd === null || costUsd === null ? null : before.costUsd + costUsd, + } satisfies CodexReviewSpent; +} + +/** A review whose verdict is final takes follow-up turns without the gate: the lead asked something else. */ +function gateIsOpen(review: CodexReviewConfig): boolean { + return review.verdict === undefined || review.verdict === "steered"; +} + +/** + * Books one finished review turn and decides the gate step. Writes one `turn` line, and one `gate` line when the + * gate ran, to the review ledger. The caller performs the action: a steer into the same session, or an escalated + * spawn under {@link escalatedReviewName}. Nothing here spawns or steers, so the whole decision is testable. + */ +export function recordReviewTurn(input: ReviewTurnInput): ReviewTurnOutcome { + const { finished, report } = input; + const tokens = sumPartTokens(finished.parts); + const knownTokens = finished.parts.length > 0; + const spent = addSpent(input.review.spent, finished, input.costUsd); + const parsed = parseReviewReport(report); + const base = reviewLedgerBase({ name: input.name, review: input.review, label: input.label, at: finished.endedAt }); + + appendReviewRecord( + base, + { + kind: "turn", + turn: finished.turn, + outcome: finished.outcome, + model: finished.model, + durationMs: finished.durationMs, + toolCalls: finished.toolCalls, + reasoningTokens: knownTokens ? tokens.reasoning : null, + outputTokens: knownTokens ? tokens.output : null, + costUsd: input.costUsd, + coverage: parsed.coverage, + }, + input.ledgerPath + ); + + const counted: CodexReviewConfig = { ...input.review, spent }; + + if (finished.outcome !== "completed" || !gateIsOpen(input.review)) { + return { review: counted, action: { kind: "none" } }; + } + + const checks = checkReview({ + report, + manifest: input.review.manifest, + effort: input.review.effort, + reasoningTokens: knownTokens || spent.reasoningTokens > 0 ? spent.reasoningTokens : null, + }); + const decision = decideGate(checks, input.review); + const escalatedTo = + decision.verdict === "escalated" && decision.nextEffort + ? escalatedReviewName(input.review, decision.nextEffort) + : undefined; + const review: CodexReviewConfig = { + ...counted, + verdict: decision.verdict, + reason: decision.reason, + step: + decision.verdict === "steered" || decision.verdict === "escalated" + ? input.review.step + 1 + : input.review.step, + ...(escalatedTo ? { escalatedTo } : {}), + }; + + writeGateRecord({ input, review, checks, decision, escalatedTo }); + log.debug( + { name: input.name, verdict: decision.verdict, reason: decision.reason, step: input.review.step }, + "review gate decided" + ); + + if (decision.verdict === "steered" && decision.steer) { + return { review, action: { kind: "steer", text: decision.steer }, decision, checks }; + } + + if (decision.verdict === "escalated" && decision.nextEffort && escalatedTo) { + return { + review, + action: { kind: "escalate", effort: decision.nextEffort, name: escalatedTo }, + decision, + checks, + }; + } + + return { review, action: { kind: "none" }, decision, checks }; +} + +function writeGateRecord(options: { + input: Pick; + review: CodexReviewConfig; + checks: ReviewGateChecks | null; + decision: Pick; + escalatedTo?: string; +}): ReviewGateRecord | null { + const { input, review, checks, decision } = options; + const record = appendReviewRecord( + reviewLedgerBase({ name: input.name, review, label: input.label }), + { + kind: "gate", + step: review.step, + verdict: decision.verdict, + reason: decision.reason, + coverage: { + reviewed: checks?.coverage.reviewed ?? null, + total: checks?.coverage.total ?? review.manifest.files.length, + }, + reasoningPer100Lines: checks?.reasoning.per100Lines ?? null, + floor: checks?.reasoning.floor ?? 0, + findings: checks?.shape.findings ?? 0, + malformedFindings: checks?.shape.malformed.length ?? 0, + unread: checks?.coverage.unread.slice(0, UNREAD_RECORDED) ?? [], + ...(options.escalatedTo ? { escalatedTo: options.escalatedTo } : {}), + spent: review.spent ?? { + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + outputTokens: 0, + costUsd: null, + }, + }, + input.ledgerPath + ); + + return record?.kind === "gate" ? record : null; +} + +/** + * The gate wanted a step the runtime could not take (the steer was refused, the escalated spawn failed): the review + * ends `shallow` with that reason, in the session record and as one more gate line. + */ +export function markReviewShallow(input: { + name: string; + label?: string; + review: CodexReviewConfig; + reason: string; + ledgerPath?: string; +}): CodexReviewConfig { + const verdict: ReviewVerdict = "shallow"; + const review: CodexReviewConfig = { ...input.review, verdict, reason: input.reason, escalatedTo: undefined }; + writeGateRecord({ input, review, checks: null, decision: { verdict, reason: input.reason } }); + log.warn({ name: input.name, reason: input.reason }, "review marked shallow"); + return review; +} diff --git a/src/codex/lib/review/escalate.ts b/src/codex/lib/review/escalate.ts new file mode 100644 index 0000000000..3170698324 --- /dev/null +++ b/src/codex/lib/review/escalate.ts @@ -0,0 +1,67 @@ +import { logger } from "@genesiscz/utils/logger"; +import type { EscalateReviewRequest } from "../session"; +import { type LaunchConfig, spawnCodexSession } from "../spawn"; +import type { CodexReviewConfig } from "./types"; + +const log = logger.child({ component: "codex:review-escalate" }); + +const FALLBACK_PROMPT = "Review the change between the base and HEAD that the manifest lists."; + +/** + * The review record of the escalated session: the same base, manifest and prompt one tier higher, at step 2 so + * its own miss ends the chain `shallow`. Its verdict, reason and spend start empty. + */ +export function escalatedReview( + review: CodexReviewConfig, + request: Pick +): CodexReviewConfig { + return { + base: review.base, + baseSource: review.baseSource, + baseDetail: review.baseDetail, + ...(review.branch ? { branch: review.branch } : {}), + ...(review.pr === undefined ? {} : { pr: review.pr }), + manifest: review.manifest, + effort: request.effort, + effortSource: "flag", + allowShallow: review.allowShallow, + step: 2, + chain: review.chain, + parent: request.meta.name, + ...(review.prompt ? { prompt: review.prompt } : {}), + }; +} + +/** + * Starts the escalated review from inside the daemon: the same cwd, home, account, write policy, swarm and + * writable roots as the session that missed, under the name the gate recorded. A steer cannot change a running + * thread's effort, which is why the higher tier needs a new session. + */ +export async function escalateCodexReview(request: EscalateReviewRequest, launch: LaunchConfig): Promise { + const { meta } = request; + + if (!meta.review) { + throw new Error(`${meta.name} is not a review session`); + } + + const review = escalatedReview(meta.review, request); + log.info({ from: meta.name, to: request.name, effort: request.effort }, "starting the escalated review"); + + await spawnCodexSession({ + name: request.name, + cwd: meta.cwd, + ...(meta.home ? { home: meta.home } : {}), + ...(meta.model ? { model: meta.model } : {}), + ...(launch.accountId ? { account: launch.accountId } : {}), + ...(launch.computerUse ? { computerUse: true } : {}), + ...(launch.project ? { project: launch.project } : {}), + ...(meta.label ? { label: meta.label } : {}), + write: meta.writePolicy, + mode: "review", + agents: meta.agentsEnabled, + rendezvousSession: meta.rendezvousSession, + writableRoots: launch.writableRoots, + prompt: review.prompt ?? FALLBACK_PROMPT, + review, + }); +} diff --git a/src/codex/lib/review/gate.ts b/src/codex/lib/review/gate.ts new file mode 100644 index 0000000000..ca6b2fcdd4 --- /dev/null +++ b/src/codex/lib/review/gate.ts @@ -0,0 +1,330 @@ +import { + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, + type ReviewManifest, +} from "@genesiscz/utils/worker/contract"; +import type { CodexReviewConfig, ReviewVerdict } from "./types"; + +/** + * Reasoning tokens per 100 changed lines a tier must reach. Measured 2026-10-22 on a 6,200-line diff: medium + * spent about 40, xhigh about 390. First guesses from one day of data; tune them from `tools codex reviews`. + */ +export const REASONING_FLOOR_PER_100_LINES: Record = { + low: 0, + medium: 0, + high: 100, + xhigh: 250, + max: 250, +}; + +/** The most unread files one steer names; the rest are counted. */ +const STEER_FILE_LIMIT = 40; + +export interface ParsedFinding { + index: number; + text: string; + ok: boolean; +} + +export interface ParsedReviewReport { + coverage: { reviewed: number; total: number } | null; + notReviewed: Array<{ path: string; reason: string | null }>; + findings: ParsedFinding[]; + findingsNone: boolean; + ran: boolean; +} + +/** Markdown emphasis and code ticks a model wraps around the fixed lines; they carry no meaning here. */ +function plain(line: string): string { + return line.replace(/[*_`]/g, "").trim(); +} + +const FINDING_START = /^(\d+)[.)]\s+(.*)$/; +const FINDING_SHAPE = /^\[(critical|high|medium|low)\]\s+\S+:\d+/i; +const SECTION_END = /^(ran|files reviewed|not reviewed|result|at|changed|verify|open|findings):/i; + +function parseNotReviewed(value: string): ParsedReviewReport["notReviewed"] { + if (/^none\.?$/i.test(value.trim())) { + return []; + } + + return value + .split(/,\s+(?![^()]*\))/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const match = /^(.*?)\s*\((.*)\)\s*$/.exec(entry); + return match ? { path: match[1].trim(), reason: match[2].trim() || null } : { path: entry, reason: null }; + }); +} + +/** Reads the review shape the contract asks for (`reviewLines`): numbered findings, `Ran:`, `Files reviewed:`, `Not reviewed:`. */ +export function parseReviewReport(report: string): ParsedReviewReport { + const parsed: ParsedReviewReport = { + coverage: null, + notReviewed: [], + findings: [], + findingsNone: false, + ran: false, + }; + let current: ParsedFinding | null = null; + + for (const raw of report.split("\n")) { + const line = plain(raw); + const coverage = /^files reviewed:\s*(\d+)\s*(?:of|\/)\s*(\d+)/i.exec(line); + + if (coverage) { + parsed.coverage = { reviewed: Number(coverage[1]), total: Number(coverage[2]) }; + } + + const notReviewed = /^not reviewed:\s*(.*)$/i.exec(line); + + if (notReviewed) { + parsed.notReviewed = parseNotReviewed(notReviewed[1]); + } + + if (/^ran:/i.test(line)) { + parsed.ran = true; + } + + if (/^findings:\s*none\b/i.test(line)) { + parsed.findingsNone = true; + } + + if (SECTION_END.test(line)) { + current = null; + continue; + } + + const start = FINDING_START.exec(line); + + if (start && !parsed.ran && !parsed.coverage) { + current = { index: Number(start[1]), text: start[2], ok: false }; + parsed.findings.push(current); + continue; + } + + if (current && line) { + current.text = `${current.text} ${line}`; + } + } + + for (const finding of parsed.findings) { + finding.ok = FINDING_SHAPE.test(finding.text) && /scenario:/i.test(finding.text) && /fix:/i.test(finding.text); + } + + return parsed; +} + +export interface GateCheck { + ok: boolean; + detail: string; +} + +export interface ReviewGateChecks { + coverage: GateCheck & { reviewed: number | null; total: number; unread: string[] }; + reasoning: GateCheck & { per100Lines: number | null; floor: number }; + shape: GateCheck & { findings: number; malformed: number[] }; +} + +/** Reasoning tokens per 100 changed lines, rounded; null when the tokens are unknown. */ +export function reasoningPer100Lines(reasoningTokens: number | null, manifest: ReviewManifest): number | null { + if (reasoningTokens === null) { + return null; + } + + return Math.round((reasoningTokens * 100) / Math.max(1, manifestChangedLines(manifest))); +} + +function checkCoverage( + parsed: ParsedReviewReport, + manifest: ReviewManifest, + report: string +): ReviewGateChecks["coverage"] { + const total = manifest.files.length; + + if (!parsed.coverage) { + return { + ok: false, + detail: "no `Files reviewed: n of N` line", + reviewed: null, + total, + unread: manifest.files.map((file) => file.path), + }; + } + + const { reviewed } = parsed.coverage; + const binary = new Set(manifest.files.filter((file) => file.binary).map((file) => file.path)); + // A binary file has no text to read: named with a reason, it does not count as unread. + const excused = parsed.notReviewed.filter((entry) => entry.reason && binary.has(entry.path)).length; + const ok = parsed.coverage.total === total && reviewed + excused >= total; + + if (ok) { + return { ok, detail: `${reviewed} of ${total} files reviewed`, reviewed, total, unread: [] }; + } + + const named = parsed.notReviewed.map((entry) => entry.path).filter((path) => !binary.has(path)); + const missing = total - reviewed - excused; + // The worker named fewer files than it skipped: the manifest files its report never mentions are the best guess. + const unmentioned = + named.length >= missing + ? [] + : manifest.files.map((file) => file.path).filter((path) => !named.includes(path) && !report.includes(path)); + const detail = + parsed.coverage.total !== total + ? `reported ${reviewed} of ${parsed.coverage.total}, but the manifest has ${total} files` + : `${reviewed} of ${total} files reviewed`; + + return { ok, detail, reviewed, total, unread: [...new Set([...named, ...unmentioned])] }; +} + +function checkShape(parsed: ParsedReviewReport): ReviewGateChecks["shape"] { + const malformed = parsed.findings.filter((finding) => !finding.ok).map((finding) => finding.index); + const problems: string[] = []; + + if (parsed.findings.length === 0 && !parsed.findingsNone) { + problems.push("no numbered findings and no `Findings: none` line"); + } + + if (malformed.length > 0) { + problems.push( + `findings ${malformed.map((index) => `#${index}`).join(", ")} lack severity, path:line, Scenario or Fix` + ); + } + + if (!parsed.ran) { + problems.push("no `Ran:` line"); + } + + return { + ok: problems.length === 0, + detail: problems.length === 0 ? `${parsed.findings.length} findings parse` : problems.join("; "), + findings: parsed.findings.length, + malformed, + }; +} + +/** The three depth checks of one review turn: coverage against the manifest, the reasoning floor, and finding shape. */ +export function checkReview(input: { + report: string; + manifest: ReviewManifest; + effort: ReviewEffort; + /** Reasoning tokens of every review turn of the session so far; null when the backend did not report them. */ + reasoningTokens: number | null; +}): ReviewGateChecks { + const parsed = parseReviewReport(input.report); + const floor = REASONING_FLOOR_PER_100_LINES[input.effort]; + const per100Lines = reasoningPer100Lines(input.reasoningTokens, input.manifest); + const reasoningOk = per100Lines === null || per100Lines >= floor; + + return { + coverage: checkCoverage(parsed, input.manifest, input.report), + reasoning: { + ok: reasoningOk, + detail: + per100Lines === null + ? "reasoning tokens unknown" + : `${per100Lines} reasoning tokens per 100 changed lines, ${input.effort} floor ${floor}`, + per100Lines, + floor, + }, + shape: checkShape(parsed), + }; +} + +export function nextReviewEffort(effort: ReviewEffort): ReviewEffort | null { + const index = REVIEW_EFFORTS.indexOf(effort); + return index >= 0 && index < REVIEW_EFFORTS.length - 1 ? REVIEW_EFFORTS[index + 1] : null; +} + +function failedDetails(checks: ReviewGateChecks): string[] { + return (["coverage", "reasoning", "shape"] as const) + .filter((key) => !checks[key].ok) + .map((key) => `${key}: ${checks[key].detail}`); +} + +/** The one steer the gate sends: the unread files by name, the reasoning gap, the findings that do not parse. */ +export function steerText(checks: ReviewGateChecks, effort: ReviewEffort): string { + const lines = ["The review gate found this review incomplete. Continue the same review now:"]; + const unread = checks.coverage.unread; + + if (!checks.coverage.ok && unread.length > 0) { + const named = unread.slice(0, STEER_FILE_LIMIT).join(", "); + const more = + unread.length > STEER_FILE_LIMIT ? `, and ${unread.length - STEER_FILE_LIMIT} more manifest files` : ""; + lines.push(`You did not read: ${named}${more}. Review them now with the same output shape.`); + } else if (!checks.coverage.ok) { + lines.push(`Coverage: ${checks.coverage.detail}. Account for every file of the manifest.`); + } + + if (!checks.reasoning.ok) { + lines.push( + `Depth: ${checks.reasoning.detail}. Go deeper at the ${effort} tier: open the callers and tests of the riskiest changes and probe each finding.` + ); + } + + if (!checks.shape.ok) { + lines.push( + `Shape: ${checks.shape.detail}. Write each finding as \`N. [Severity] path:line - defect. Scenario: ... Fix: ...\`.` + ); + } + + lines.push( + `End with the full findings list (earlier ones included), the Ran line, \`Files reviewed: of ${checks.coverage.total}\`, the Not reviewed line and the five report lines.` + ); + return lines.join("\n"); +} + +export interface GateDecision { + verdict: ReviewVerdict; + reason: string; + /** For `steered`: the text sent to the same session. */ + steer?: string; + /** For `escalated`: the tier of the new session. */ + nextEffort?: ReviewEffort; +} + +/** + * What the gate does after a review turn. At most two automatic steps per review chain: the first miss steers the + * same session (step 0), the second reruns one tier higher in a new session (step 1), and a miss after that, or a + * miss with no tier left, is `shallow`. A shallow tier the caller asked for (`--allow-shallow`) is never tuned up. + */ +export function decideGate( + checks: ReviewGateChecks, + config: Pick +): GateDecision { + const misses = failedDetails(checks); + + if (config.effort === "low") { + return { verdict: "shallow", reason: "low is triage only (--allow-shallow); the depth gate does not apply" }; + } + + if (misses.length === 0) { + return { + verdict: "pass", + reason: `${checks.coverage.detail}; ${checks.reasoning.detail}; ${checks.shape.detail}`, + }; + } + + const missed = misses.join("; "); + + if (config.effort === "medium" && config.allowShallow) { + return { verdict: "shallow", reason: `${missed} (medium with --allow-shallow: no automatic step)` }; + } + + if (config.step === 0) { + return { verdict: "steered", reason: missed, steer: steerText(checks, config.effort) }; + } + + const next = nextReviewEffort(config.effort); + + if (config.step === 1 && next) { + return { verdict: "escalated", reason: `${missed}; rerun at ${next}`, nextEffort: next }; + } + + return { + verdict: "shallow", + reason: `${missed}; ${config.step >= 2 ? "both automatic steps are spent" : `no tier above ${config.effort}`}`, + }; +} diff --git a/src/codex/lib/review/ledger.ts b/src/codex/lib/review/ledger.ts new file mode 100644 index 0000000000..8d5b5e30f9 --- /dev/null +++ b/src/codex/lib/review/ledger.ts @@ -0,0 +1,129 @@ +import { join } from "node:path"; +import { codexRoot } from "@genesiscz/utils/codex/worker-paths"; +import { readJsonlRows } from "@genesiscz/utils/jsonl"; +import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import { manifestChangedLines, type ReviewEffort } from "@genesiscz/utils/worker/contract"; +import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-ledger" }); + +/** `~/.genesis-tools/codex/reviews.jsonl`: one line per review start, review turn and gate decision. */ +export function reviewLedgerPath(): string { + return join(codexRoot(), "reviews.jsonl"); +} + +interface ReviewLedgerBase { + v: 1; + ts: string; + name: string; + chain: string; + label?: string; + pr?: number; + branch?: string; + base: string; + files: number; + lines: number; + effort: ReviewEffort; +} + +export interface ReviewStartRecord extends ReviewLedgerBase { + kind: "start"; + cwd: string; + effortSource: CodexReviewConfig["effortSource"]; + allowShallow: boolean; + parent?: string; +} + +export interface ReviewTurnRecord extends ReviewLedgerBase { + kind: "turn"; + turn: number; + outcome: "completed" | "failed" | "interrupted"; + model: string | null; + durationMs: number | null; + toolCalls: number; + reasoningTokens: number | null; + outputTokens: number | null; + costUsd: number | null; + /** The `Files reviewed: n of N` line of the turn's final message; null when it had none. */ + coverage: { reviewed: number; total: number } | null; +} + +export interface ReviewGateRecord extends ReviewLedgerBase { + kind: "gate"; + step: number; + verdict: ReviewVerdict; + reason: string; + coverage: { reviewed: number | null; total: number }; + reasoningPer100Lines: number | null; + floor: number; + findings: number; + malformedFindings: number; + /** The unread files the gate named, first 40. */ + unread: string[]; + escalatedTo?: string; + spent: CodexReviewSpent; +} + +export type ReviewLedgerRecord = ReviewStartRecord | ReviewTurnRecord | ReviewGateRecord; + +type Distribute = T extends ReviewLedgerRecord ? Omit : never; +export type ReviewLedgerEntry = Distribute; + +/** The fields every record of one review session repeats, so a single line answers the reviews filters. */ +export function reviewLedgerBase(input: { name: string; review: CodexReviewConfig; label?: string; at?: Date }) { + const { review } = input; + return { + v: 1, + ts: (input.at ?? new Date()).toISOString(), + name: input.name, + chain: review.chain, + ...(input.label ? { label: input.label } : {}), + ...(review.pr === undefined ? {} : { pr: review.pr }), + ...(review.branch ? { branch: review.branch } : {}), + base: review.base, + files: review.manifest.files.length, + lines: manifestChangedLines(review.manifest), + effort: review.effort, + } satisfies ReviewLedgerBase; +} + +/** + * Appends one record. Never throws: the ledger is a record of the review, and a failed write must not fail the + * review turn it describes. The failure is logged with the record's kind and session. + */ +export function appendReviewRecord( + base: ReturnType, + entry: ReviewLedgerEntry, + path = reviewLedgerPath() +): ReviewLedgerRecord | null { + const record: ReviewLedgerRecord = { ...base, ...entry }; + + try { + new JsonlWriter(path).append({ ...record }); + log.debug({ path, kind: record.kind, name: record.name }, "review ledger record written"); + return record; + } catch (err) { + log.warn({ err, path, kind: record.kind, name: record.name }, "review ledger write failed"); + return null; + } +} + +function isLedgerRecord(value: unknown): value is ReviewLedgerRecord { + return ( + isObject(value) && + value.v === 1 && + (value.kind === "start" || value.kind === "turn" || value.kind === "gate") && + typeof value.name === "string" && + typeof value.ts === "string" && + typeof value.base === "string" + ); +} + +/** Every well-formed record, oldest first; torn or foreign lines are counted in `skipped`. */ +export function readReviewRecords(path = reviewLedgerPath()): { records: ReviewLedgerRecord[]; skipped: number } { + const { rows, skipped } = readJsonlRows(path); + const records = rows.filter(isLedgerRecord); + return { records, skipped: skipped + rows.length - records.length }; +} diff --git a/src/codex/lib/review/prepare.ts b/src/codex/lib/review/prepare.ts new file mode 100644 index 0000000000..4c070f3974 --- /dev/null +++ b/src/codex/lib/review/prepare.ts @@ -0,0 +1,309 @@ +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { createGit, getCurrentBranch, type NumstatEntry, originDriver, type PrInfo } from "@genesiscz/utils/git"; +import { logger } from "@genesiscz/utils/logger"; +import { + isReviewEffort, + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, + type ReviewManifest, +} from "@genesiscz/utils/worker/contract"; +import type { CodexReviewConfig, ReviewBaseSource } from "./types"; + +const log = logger.child({ component: "codex:review" }); + +/** A diff up to this size gets `high` by default; anything larger gets `xhigh`. */ +export const HIGH_TIER_MAX = { files: 15, lines: 800 } as const; +/** Above either bound one reviewer is too thin, and the spawn proposes a split by folder. */ +export const SPLIT_THRESHOLD = { files: 60, lines: 4000 } as const; + +/** Everything the review preparation reads from git and the hosting service; injected so tests never spawn. */ +export interface ReviewGitReaders { + currentBranch(cwd: string): Promise; + /** `origin/` when it exists, else the local branch, else null. */ + resolveBranch(cwd: string, name: string): Promise; + refExists(cwd: string, ref: string): Promise; + /** The newest PR of the branch; null when there is none or the host cannot answer. */ + pullRequest(cwd: string, branch: string): Promise; + upstream(cwd: string, branch: string): Promise; + /** `git diff --numstat ...HEAD`, limited to `paths` when given. */ + numstat(cwd: string, base: string, paths?: string[]): Promise; +} + +export function defaultReviewGitReaders(): ReviewGitReaders { + return { + currentBranch: (cwd) => getCurrentBranch(cwd), + async resolveBranch(cwd, name) { + const git = createGit({ cwd }); + const short = name.replace(/^origin\//, ""); + + if (await git.refExists(`refs/remotes/origin/${short}`)) { + return `origin/${short}`; + } + + return (await git.refExists(`refs/heads/${short}`)) ? short : null; + }, + refExists: (cwd, ref) => createGit({ cwd }).refExists(`${ref}^{commit}`), + async pullRequest(cwd, branch) { + const driver = await originDriver(cwd); + + if (!driver) { + return null; + } + + const lookup = await driver.prForHead(branch); + + if (lookup.error) { + log.debug({ branch, error: lookup.error }, "review: PR lookup failed"); + } + + return lookup.pr; + }, + upstream: (cwd, branch) => createGit({ cwd }).getTrackingBranch(branch), + async numstat(cwd, base, paths) { + const git = createGit({ cwd }); + const mergeBase = await git.mergeBase(base, "HEAD"); + return git.numstat({ from: mergeBase, to: "HEAD", ...(paths?.length ? { paths } : {}) }); + }, + }; +} + +export interface PrepareReviewInput { + cwd: string; + base?: string; + effort?: string; + allowShallow?: boolean; + /** Limit the manifest to these paths: one slice of a split review. */ + paths?: string[]; + readers?: ReviewGitReaders; +} + +export interface PreparedReview { + /** The session's review record, without the chain fields the spawn adds. */ + config: Omit; + /** Lines the spawn prints as information: which base and why, which tier and why. */ + notes: string[]; + /** Lines the spawn prints as warnings: the split suggestion. */ + warnings: string[]; +} + +interface ChosenBase { + ref: string; + source: ReviewBaseSource; + detail: string; + pr?: PrInfo; +} + +async function chooseBase(input: PrepareReviewInput & { readers: ReviewGitReaders; branch: string | null }) { + const { readers, cwd, branch } = input; + + if (input.base) { + if (!(await readers.refExists(cwd, input.base))) { + throw new Error( + `--base ${input.base} does not resolve in ${cwd}; run git fetch first or pick another ref.` + ); + } + + const pr = branch ? await readers.pullRequest(cwd, branch) : null; + return { ref: input.base, source: "flag", detail: "--base", ...(pr ? { pr } : {}) } satisfies ChosenBase; + } + + if (!branch) { + throw new Error("--mode review needs --base : HEAD is detached, so there is no PR or upstream to read."); + } + + const pr = await readers.pullRequest(cwd, branch); + + if (pr) { + const ref = await readers.resolveBranch(cwd, pr.target); + + if (ref) { + return { + ref, + source: "pr", + detail: `${pr.state} PR #${pr.number} targets ${pr.target}`, + pr, + } satisfies ChosenBase; + } + + log.debug({ branch, pr }, "review: PR target does not resolve locally"); + } + + const upstream = await readers.upstream(cwd, branch); + + // A branch tracking its own remote copy has no base there: the diff against it is the unpushed commits only. + if (upstream && upstream !== `origin/${branch}` && upstream !== branch) { + return { ref: upstream, source: "upstream", detail: `upstream of ${branch}` } satisfies ChosenBase; + } + + throw new Error( + `--mode review needs --base : ${branch} has no PR whose target resolves and no upstream other than itself. \`${toolCommand("git base")}\` names a likely base.` + ); +} + +export function buildManifest(base: string, entries: readonly NumstatEntry[]): ReviewManifest { + const files = entries.map((entry) => ({ + path: entry.path, + added: entry.insertions, + deleted: entry.deletions, + ...(entry.binary ? { binary: true } : {}), + })); + + return { + base, + files, + added: files.reduce((sum, file) => sum + file.added, 0), + deleted: files.reduce((sum, file) => sum + file.deleted, 0), + }; +} + +/** `high` for a diff of up to 15 files and 800 changed lines, `xhigh` above that. */ +export function defaultReviewEffort(manifest: ReviewManifest): ReviewEffort { + const small = manifest.files.length <= HIGH_TIER_MAX.files && manifestChangedLines(manifest) <= HIGH_TIER_MAX.lines; + return small ? "high" : "xhigh"; +} + +function chooseEffort( + flag: string | undefined, + allowShallow: boolean, + manifest: ReviewManifest +): { effort: ReviewEffort; source: "flag" | "diff-size" } { + if (flag === undefined) { + return { effort: defaultReviewEffort(manifest), source: "diff-size" }; + } + + if (!isReviewEffort(flag)) { + throw new Error(`--effort for a review must be one of ${REVIEW_EFFORTS.join(", ")}; got ${flag}.`); + } + + if ((flag === "low" || flag === "medium") && !allowShallow) { + throw new Error( + `--effort ${flag} is refused for a review: medium reviews spent 358 to 2,399 reasoning tokens whatever the diff size and missed defects the xhigh pass found. Omit --effort to pick high or xhigh from the diff size, or pass --allow-shallow to accept a shallow review.` + ); + } + + return { effort: flag, source: "flag" }; +} + +export interface ManifestSlice { + folder: string; + files: number; + added: number; + deleted: number; +} + +function folderOf(path: string, depth: number): string { + const parts = path.split("/"); + return parts.length > depth ? parts.slice(0, depth).join("/") : parts.slice(0, -1).join("/") || "."; +} + +/** + * One slice per folder, split deeper until each slice is at or under the file threshold or cannot split + * further. A repo with everything under `src/` would otherwise get one useless `src` slice. + */ +export function splitManifest(manifest: ReviewManifest, depth = 1): ManifestSlice[] { + const groups = new Map(); + + for (const file of manifest.files) { + const folder = folderOf(file.path, depth); + groups.set(folder, [...(groups.get(folder) ?? []), file]); + } + + return [...groups.entries()] + .flatMap(([folder, files]) => { + const deeper = files.some((file) => file.path.split("/").length > depth + 1); + + if (files.length > SPLIT_THRESHOLD.files && deeper) { + const sub = splitManifest({ ...manifest, files }, depth + 1); + + if (sub.length > 1) { + return sub; + } + } + + return [ + { + folder, + files: files.length, + added: files.reduce((sum, file) => sum + file.added, 0), + deleted: files.reduce((sum, file) => sum + file.deleted, 0), + }, + ]; + }) + .sort((a, b) => b.files - a.files || a.folder.localeCompare(b.folder)); +} + +export function splitSuggestion(manifest: ReviewManifest): string | null { + const lines = manifestChangedLines(manifest); + + if (manifest.files.length <= SPLIT_THRESHOLD.files && lines <= SPLIT_THRESHOLD.lines) { + return null; + } + + // A diff under one folder (`src/`) only splits a level or two down. + let depth = 1; + let slices = splitManifest(manifest, depth); + + while (slices.length < 2 && depth < 4) { + depth += 1; + slices = splitManifest(manifest, depth); + } + + if (slices.length < 2) { + return null; + } + + const listed = slices + .slice(0, 12) + .map((slice) => `${slice.folder} (${slice.files} files, +${slice.added} -${slice.deleted})`) + .join(", "); + const more = slices.length > 12 ? `, +${slices.length - 12} more` : ""; + + return `${manifest.files.length} files, ${lines} changed lines: above about ${SPLIT_THRESHOLD.files} files or ${SPLIT_THRESHOLD.lines} lines one reviewer covers the diff thinly. Split suggestion, one reviewer per folder at xhigh, in parallel: ${listed}${more}. Each one: ${toolCommand("codex spawn")} --mode review --base ${manifest.base} --paths --name -.`; +} + +/** + * Everything `spawn --mode review` decides before a worker starts: the base (the flag, else the branch's PR + * target, else its upstream, and the notes say which), the manifest from `git diff --numstat ...HEAD`, + * the tier (the flag, else the diff size) and the split suggestion for a large diff. + */ +export async function prepareReview(input: PrepareReviewInput): Promise { + const readers = input.readers ?? defaultReviewGitReaders(); + const allowShallow = input.allowShallow === true; + const branch = await readers.currentBranch(input.cwd); + const base = await chooseBase({ ...input, readers, branch }); + const manifest = buildManifest(base.ref, await readers.numstat(input.cwd, base.ref, input.paths)); + + if (manifest.files.length === 0) { + throw new Error( + `Nothing to review: git diff --numstat ${base.ref}...HEAD is empty in ${input.cwd}${input.paths?.length ? ` for ${input.paths.join(", ")}` : ""}.` + ); + } + + const { effort, source } = chooseEffort(input.effort, allowShallow, manifest); + const warnings = [splitSuggestion(manifest)].filter((line): line is string => line !== null); + const notes = [ + `review base ${base.ref} (${base.source}: ${base.detail})`, + `review manifest ${manifest.files.length} files, +${manifest.added} -${manifest.deleted}; effort ${effort} (${source === "flag" ? "--effort" : "from the diff size"})`, + ]; + log.debug( + { cwd: input.cwd, base: base.ref, source: base.source, files: manifest.files.length, effort }, + "review prepared" + ); + + return { + config: { + base: base.ref, + baseSource: base.source, + baseDetail: base.detail, + ...(branch ? { branch } : {}), + ...(base.pr ? { pr: base.pr.number } : {}), + manifest, + effort, + effortSource: source, + allowShallow, + }, + notes, + warnings, + }; +} diff --git a/src/codex/lib/review/report.ts b/src/codex/lib/review/report.ts new file mode 100644 index 0000000000..4ed8837a57 --- /dev/null +++ b/src/codex/lib/review/report.ts @@ -0,0 +1,194 @@ +import { parseSince } from "@genesiscz/utils/date"; +import { parseDuration } from "@genesiscz/utils/format"; +import type { ReviewEffort } from "@genesiscz/utils/worker/contract"; +import type { ReviewLedgerRecord } from "./ledger"; +import type { ReviewVerdict } from "./types"; + +/** One review session, folded from its ledger lines: what `tools codex reviews` prints as a row. */ +export interface ReviewSummary { + name: string; + chain: string; + label?: string; + pr?: number; + branch?: string; + base: string; + files: number; + lines: number; + effort: ReviewEffort; + startedAt: string; + lastAt: string; + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + outputTokens: number; + /** API-equivalent dollars; null when any turn was unpriced. */ + costUsd: number | null; + reasoningPer100Lines: number | null; + /** The last `Files reviewed` line: `12/14`. */ + coverage: string | null; + /** The last gate verdict; `open` while no gate line exists yet (running, or a turn that failed). */ + verdict: ReviewVerdict | "open"; + reason?: string; + escalatedTo?: string; + parent?: string; +} + +export interface ReviewFilters { + since?: Date; + pr?: number; + label?: string; + effort?: string; + verdict?: string; + name?: string; +} + +export interface ReviewTotals { + reviews: number; + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + costUsd: number; + unpricedReviews: number; + verdicts: Record; +} + +function emptySummary(record: ReviewLedgerRecord): ReviewSummary { + return { + name: record.name, + chain: record.chain, + ...(record.label ? { label: record.label } : {}), + ...(record.pr === undefined ? {} : { pr: record.pr }), + ...(record.branch ? { branch: record.branch } : {}), + base: record.base, + files: record.files, + lines: record.lines, + effort: record.effort, + startedAt: record.ts, + lastAt: record.ts, + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + outputTokens: 0, + costUsd: 0, + reasoningPer100Lines: null, + coverage: null, + verdict: "open", + }; +} + +/** + * Folds the ledger into one summary per review session. A `start` line opens a new review, so a name that was + * reused later shows twice; turn and gate lines without a start (a ledger cut by hand) open one too. + */ +export function summarizeReviews(records: readonly ReviewLedgerRecord[]): ReviewSummary[] { + const open = new Map(); + const all: ReviewSummary[] = []; + + for (const record of records) { + let summary = open.get(record.name); + + if (!summary || record.kind === "start") { + summary = emptySummary(record); + open.set(record.name, summary); + all.push(summary); + } + + summary.lastAt = record.ts; + + if (record.kind === "start") { + if (record.parent) { + summary.parent = record.parent; + } + + continue; + } + + if (record.kind === "turn") { + summary.turns += 1; + summary.durationMs += record.durationMs ?? 0; + summary.toolCalls += record.toolCalls; + summary.reasoningTokens += record.reasoningTokens ?? 0; + summary.outputTokens += record.outputTokens ?? 0; + summary.costUsd = + summary.costUsd === null || record.costUsd === null ? null : summary.costUsd + record.costUsd; + summary.effort = record.effort; + + if (record.coverage) { + summary.coverage = `${record.coverage.reviewed}/${record.coverage.total}`; + } + + summary.reasoningPer100Lines = Math.round((summary.reasoningTokens * 100) / Math.max(1, summary.lines)); + continue; + } + + summary.verdict = record.verdict; + summary.reason = record.reason; + + if (record.escalatedTo) { + summary.escalatedTo = record.escalatedTo; + } else { + delete summary.escalatedTo; + } + } + + return all; +} + +/** `24h`, `90m`, `7d` back from now; otherwise the `--since` grammar of the other reports (`HH:MM`, a date). */ +export function parseReviewSince(value: string, now = new Date()): Date | null { + const text = value.trim(); + + if (/^\d+\s*[a-z]/i.test(text) && !/^\d{4}-/.test(text)) { + const ms = parseDuration(text); + return ms > 0 ? new Date(now.getTime() - ms) : null; + } + + return parseSince(text, now); +} + +export function filterReviews(summaries: readonly ReviewSummary[], filters: ReviewFilters): ReviewSummary[] { + const label = filters.label?.toLowerCase(); + const name = filters.name?.toLowerCase(); + + return summaries.filter( + (summary) => + (!filters.since || Date.parse(summary.lastAt) >= filters.since.getTime()) && + (filters.pr === undefined || summary.pr === filters.pr) && + (!label || (summary.label ?? "").toLowerCase().includes(label)) && + (!filters.effort || summary.effort === filters.effort) && + (!filters.verdict || summary.verdict === filters.verdict) && + (!name || summary.name.toLowerCase().includes(name) || summary.chain.toLowerCase().includes(name)) + ); +} + +export function reviewTotals(summaries: readonly ReviewSummary[]): ReviewTotals { + const totals: ReviewTotals = { + reviews: summaries.length, + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + costUsd: 0, + unpricedReviews: 0, + verdicts: {}, + }; + + for (const summary of summaries) { + totals.turns += summary.turns; + totals.durationMs += summary.durationMs; + totals.toolCalls += summary.toolCalls; + totals.reasoningTokens += summary.reasoningTokens; + totals.verdicts[summary.verdict] = (totals.verdicts[summary.verdict] ?? 0) + 1; + + if (summary.costUsd === null) { + totals.unpricedReviews += 1; + } else { + totals.costUsd += summary.costUsd; + } + } + + return totals; +} diff --git a/src/codex/lib/review/review.test.ts b/src/codex/lib/review/review.test.ts new file mode 100644 index 0000000000..c30a91404a --- /dev/null +++ b/src/codex/lib/review/review.test.ts @@ -0,0 +1,568 @@ +import { describe, expect, test } from "bun:test"; +import { appendFileSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; +import type { NumstatEntry, PrInfo } from "@genesiscz/utils/git"; +import { SafeJSON } from "@genesiscz/utils/json"; +import type { ReviewManifest } from "@genesiscz/utils/worker/contract"; +import { codexDriver } from "../driver"; +import { spawnCodexSession } from "../spawn"; +import { escalatedReviewName, markReviewShallow, recordReviewTurn } from "./controller"; +import { checkReview, decideGate, parseReviewReport } from "./gate"; +import { type ReviewLedgerRecord, readReviewRecords } from "./ledger"; +import { prepareReview, type ReviewGitReaders } from "./prepare"; +import { filterReviews, parseReviewSince, reviewTotals, summarizeReviews } from "./report"; +import type { CodexReviewConfig } from "./types"; + +function entry(path: string, insertions: number, deletions = 0, binary = false): NumstatEntry { + return { path, insertions, deletions, binary }; +} + +function readers(options: { + branch?: string | null; + pr?: PrInfo | null; + upstream?: string | null; + numstat?: NumstatEntry[]; + refs?: string[]; +}): ReviewGitReaders & { calls: string[] } { + const calls: string[] = []; + return { + calls, + currentBranch: async () => (options.branch === undefined ? "feat/x" : options.branch), + resolveBranch: async (_cwd, name) => + (options.refs ?? ["origin/master"]).includes(`origin/${name}`) ? `origin/${name}` : null, + refExists: async (_cwd, ref) => (options.refs ?? ["origin/master"]).includes(ref), + pullRequest: async () => options.pr ?? null, + upstream: async () => options.upstream ?? null, + numstat: async (_cwd, base, paths) => { + calls.push(`numstat ${base}${paths ? ` ${paths.join(",")}` : ""}`); + return options.numstat ?? [entry("src/a.ts", 40, 2)]; + }, + }; +} + +const PR: PrInfo = { number: 12, state: "OPEN", target: "master", url: "https://example.com/pr/12" }; + +describe("prepareReview", () => { + test("--base is verified and used; the PR number is still recorded", async () => { + const fake = readers({ pr: PR }); + const prepared = await prepareReview({ cwd: "/repo", base: "origin/master", readers: fake }); + + expect(prepared.config).toMatchObject({ base: "origin/master", baseSource: "flag", pr: 12, branch: "feat/x" }); + expect(prepared.notes[0]).toBe("review base origin/master (flag: --base)"); + expect(fake.calls).toEqual(["numstat origin/master"]); + await expect(prepareReview({ cwd: "/repo", base: "origin/nope", readers: fake })).rejects.toThrow( + "--base origin/nope does not resolve" + ); + }); + + test("without --base: the PR target first, then an upstream that is not the branch itself", async () => { + const fromPr = await prepareReview({ cwd: "/repo", readers: readers({ pr: PR }) }); + expect(fromPr.config).toMatchObject({ base: "origin/master", baseSource: "pr", pr: 12 }); + expect(fromPr.notes[0]).toBe("review base origin/master (pr: OPEN PR #12 targets master)"); + + const fromUpstream = await prepareReview({ cwd: "/repo", readers: readers({ upstream: "origin/develop" }) }); + expect(fromUpstream.config).toMatchObject({ base: "origin/develop", baseSource: "upstream" }); + + await expect(prepareReview({ cwd: "/repo", readers: readers({ upstream: "origin/feat/x" }) })).rejects.toThrow( + "--mode review needs --base : feat/x has no PR" + ); + await expect(prepareReview({ cwd: "/repo", readers: readers({ branch: null }) })).rejects.toThrow( + "HEAD is detached" + ); + }); + + test("an empty diff is refused; --paths reaches the numstat", async () => { + await expect( + prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({ numstat: [] }) }) + ).rejects.toThrow("Nothing to review"); + + const fake = readers({}); + await prepareReview({ cwd: "/repo", base: "origin/master", paths: ["src/codex"], readers: fake }); + expect(fake.calls).toEqual(["numstat origin/master src/codex"]); + }); + + test("the tier comes from the diff size; low and medium need --allow-shallow", async () => { + const small = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({}) }); + expect(small.config).toMatchObject({ effort: "high", effortSource: "diff-size" }); + + const many = Array.from({ length: 16 }, (_, i) => entry(`src/f${i}.ts`, 10)); + const large = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({ numstat: many }) }); + expect(large.config.effort).toBe("xhigh"); + + const long = await prepareReview({ + cwd: "/repo", + base: "origin/master", + readers: readers({ numstat: [entry("src/a.ts", 801)] }), + }); + expect(long.config.effort).toBe("xhigh"); + + const base = { cwd: "/repo", base: "origin/master", readers: readers({}) }; + await expect(prepareReview({ ...base, effort: "medium" })).rejects.toThrow("--effort medium is refused"); + await expect(prepareReview({ ...base, effort: "low" })).rejects.toThrow("--allow-shallow"); + await expect(prepareReview({ ...base, effort: "huge" })).rejects.toThrow("must be one of low, medium"); + expect((await prepareReview({ ...base, effort: "medium", allowShallow: true })).config).toMatchObject({ + effort: "medium", + effortSource: "flag", + allowShallow: true, + }); + expect((await prepareReview({ ...base, effort: "max" })).config.effort).toBe("max"); + }); + + test("above 60 files the spawn proposes one reviewer per folder, split below a shared src/", async () => { + const files = [ + ...Array.from({ length: 40 }, (_, i) => entry(`src/codex/f${i}.ts`, 5)), + ...Array.from({ length: 30 }, (_, i) => entry(`src/utils/g${i}.ts`, 5)), + ]; + const prepared = await prepareReview({ + cwd: "/repo", + base: "origin/master", + readers: readers({ numstat: files }), + }); + + expect(prepared.warnings).toHaveLength(1); + expect(prepared.warnings[0]).toContain("src/codex (40 files, +200 -0), src/utils (30 files, +150 -0)"); + expect(prepared.warnings[0]).toContain("--paths "); + + const smallDiff = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({}) }); + expect(smallDiff.warnings).toEqual([]); + }); +}); + +const MANIFEST: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 60, deleted: 10 }, + { path: "src/b.ts", added: 20, deleted: 0 }, + { path: "src/c.ts", added: 10, deleted: 0 }, + { path: "assets/logo.png", added: 0, deleted: 0, binary: true }, + ], + added: 90, + deleted: 10, +}; + +const GOOD_REPORT = [ + "1. **[High]** src/a.ts:12 - the lock is never released. Scenario: a throw inside the callback. Fix: release in finally.", + "2. [Low] src/b.ts:3 - stale comment.", + " Scenario: a reader trusts it. Fix: delete it.", + "Ran: bun test src/a.test.ts", + "Files reviewed: 3 of 4", + "Not reviewed: assets/logo.png (binary image)", + "RESULT: done", +].join("\n"); + +describe("the depth gate", () => { + test("parses the review shape, markdown and wrapped findings included", () => { + const parsed = parseReviewReport(GOOD_REPORT); + + expect(parsed.coverage).toEqual({ reviewed: 3, total: 4 }); + expect(parsed.notReviewed).toEqual([{ path: "assets/logo.png", reason: "binary image" }]); + expect(parsed.findings.map((finding) => [finding.index, finding.ok])).toEqual([ + [1, true], + [2, true], + ]); + expect(parsed.ran).toBe(true); + }); + + test("passes a full review: a named binary file with a reason is excused", () => { + const checks = checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: 400 }); + + expect(checks.coverage).toMatchObject({ ok: true, reviewed: 3, total: 4, unread: [] }); + expect(checks.reasoning).toMatchObject({ ok: true, per100Lines: 400, floor: 100 }); + expect(checks.shape.ok).toBe(true); + expect(decideGate(checks, { effort: "high", step: 0, allowShallow: false }).verdict).toBe("pass"); + }); + + test("names the unread files: the Not reviewed line, else the manifest files the report never mentions", () => { + const named = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 2 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: src/c.ts (ran out of time), assets/logo.png (binary image)" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(named.coverage).toMatchObject({ ok: false, unread: ["src/c.ts"] }); + + const silent = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 2 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: none" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(silent.coverage.unread).toEqual(["src/c.ts", "assets/logo.png"]); + + const missing = checkReview({ report: "1. nothing", manifest: MANIFEST, effort: "high", reasoningTokens: 400 }); + expect(missing.coverage).toMatchObject({ + ok: false, + reviewed: null, + detail: "no `Files reviewed: n of N` line", + }); + expect(missing.shape).toMatchObject({ ok: false, malformed: [1] }); + }); + + test("holds each tier to its reasoning floor per 100 changed lines", () => { + const thin = checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "xhigh", reasoningTokens: 200 }); + expect(thin.reasoning).toMatchObject({ ok: false, per100Lines: 200, floor: 250 }); + expect( + checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: 99 }).reasoning.ok + ).toBe(false); + expect( + checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: null }).reasoning + ).toMatchObject({ ok: true, detail: "reasoning tokens unknown" }); + }); + + test("steers once, escalates once, then ends shallow; --allow-shallow tiers are never tuned up", () => { + const miss = checkReview({ + report: "Ran: nothing\nFindings: none", + manifest: MANIFEST, + effort: "high", + reasoningTokens: 10, + }); + + const first = decideGate(miss, { effort: "high", step: 0, allowShallow: false }); + expect(first.verdict).toBe("steered"); + expect(first.steer).toContain("You did not read: src/a.ts, src/b.ts, src/c.ts, assets/logo.png."); + expect(first.steer).toContain("Depth: 10 reasoning tokens per 100 changed lines, high floor 100."); + expect(first.steer).toContain("`Files reviewed: of 4`"); + + expect(decideGate(miss, { effort: "high", step: 1, allowShallow: false })).toMatchObject({ + verdict: "escalated", + nextEffort: "xhigh", + }); + expect(decideGate(miss, { effort: "max", step: 1, allowShallow: false }).reason).toContain("no tier above max"); + expect(decideGate(miss, { effort: "xhigh", step: 2, allowShallow: false })).toMatchObject({ + verdict: "shallow", + }); + expect(decideGate(miss, { effort: "medium", step: 0, allowShallow: true }).verdict).toBe("shallow"); + expect(decideGate(miss, { effort: "low", step: 0, allowShallow: true }).verdict).toBe("shallow"); + }); +}); + +function finishedTurn(over: Partial = {}): CodexFinishedTurn { + return { + turn: 1, + outcome: "completed", + startedAt: new Date("2026-10-22T10:00:00Z"), + endedAt: new Date("2026-10-22T10:05:00Z"), + durationMs: 300_000, + parts: [{ model: "gpt-x", tokens: { input: 900, cacheRead: 0, cacheWrite: 0, output: 600, reasoning: 50 } }], + toolCalls: 12, + model: "gpt-x", + auth: "subscription", + ...over, + }; +} + +function reviewConfig(over: Partial = {}): CodexReviewConfig { + return { + base: "origin/master", + baseSource: "pr", + baseDetail: "OPEN PR #12 targets master", + branch: "feat/x", + pr: 12, + manifest: MANIFEST, + effort: "high", + effortSource: "diff-size", + allowShallow: false, + step: 0, + chain: "rv", + ...over, + }; +} + +describe("recordReviewTurn", () => { + test("a miss steers the same session and logs a turn line and a gate line", () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const outcome = recordReviewTurn({ + name: "rv", + label: "babysit pr12", + review: reviewConfig(), + finished: finishedTurn(), + report: "Ran: nothing\nFindings: none\nFiles reviewed: 1 of 4\nNot reviewed: none", + costUsd: 0.4, + ledgerPath, + }); + + expect(outcome.action.kind).toBe("steer"); + expect(outcome.review).toMatchObject({ verdict: "steered", step: 1 }); + expect(outcome.review.spent).toEqual({ + turns: 1, + durationMs: 300_000, + toolCalls: 12, + reasoningTokens: 50, + outputTokens: 600, + costUsd: 0.4, + }); + + const { records } = readReviewRecords(ledgerPath); + expect(records.map((record) => record.kind)).toEqual(["turn", "gate"]); + expect(records[0]).toMatchObject({ + name: "rv", + label: "babysit pr12", + pr: 12, + base: "origin/master", + files: 4, + lines: 100, + effort: "high", + coverage: { reviewed: 1, total: 4 }, + reasoningTokens: 50, + }); + expect(records[1]).toMatchObject({ verdict: "steered", step: 1, reasoningPer100Lines: 50, floor: 100 }); + + const second = recordReviewTurn({ + name: "rv", + review: outcome.review, + finished: finishedTurn({ turn: 2 }), + report: "Ran: nothing\nFindings: none\nFiles reviewed: 2 of 4\nNot reviewed: none", + costUsd: 0.2, + ledgerPath, + }); + expect(second.action).toEqual({ + kind: "escalate", + effort: "xhigh", + name: escalatedReviewName({ chain: "rv" }, "xhigh"), + }); + expect(second.review).toMatchObject({ verdict: "escalated", step: 2, escalatedTo: "rv-xhigh" }); + expect(second.review.spent?.reasoningTokens).toBe(100); + }); + + test("a failed turn and a follow-up after a final verdict are booked without the gate", () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const failed = recordReviewTurn({ + name: "rv", + review: reviewConfig(), + finished: finishedTurn({ outcome: "failed" }), + report: "", + costUsd: null, + ledgerPath, + }); + expect(failed).toMatchObject({ action: { kind: "none" } }); + expect(failed.decision).toBeUndefined(); + expect(failed.review.spent?.costUsd).toBeNull(); + + const followUp = recordReviewTurn({ + name: "rv", + review: reviewConfig({ verdict: "pass" }), + finished: finishedTurn(), + report: "", + costUsd: 0.1, + ledgerPath, + }); + expect(followUp.decision).toBeUndefined(); + expect(readReviewRecords(ledgerPath).records.map((record) => record.kind)).toEqual(["turn", "turn"]); + + const shallow = markReviewShallow({ + name: "rv", + review: reviewConfig({ verdict: "steered" }), + reason: "steer failed", + ledgerPath, + }); + expect(shallow.verdict).toBe("shallow"); + expect(readReviewRecords(ledgerPath).records.at(-1)).toMatchObject({ + kind: "gate", + verdict: "shallow", + reason: "steer failed", + }); + }); +}); + +function ledgerFixture(): ReviewLedgerRecord[] { + const base = { + v: 1 as const, + chain: "rv", + base: "origin/master", + files: 4, + lines: 100, + pr: 12, + label: "babysit pr12", + }; + const spent = { + turns: 2, + durationMs: 600_000, + toolCalls: 30, + reasoningTokens: 600, + outputTokens: 900, + costUsd: 0.9, + }; + return [ + { + ...base, + kind: "start", + ts: "2026-10-22T10:00:00Z", + name: "rv", + effort: "high", + cwd: "/repo", + effortSource: "diff-size", + allowShallow: false, + }, + { + ...base, + kind: "turn", + ts: "2026-10-22T10:05:00Z", + name: "rv", + effort: "high", + turn: 1, + outcome: "completed", + model: "gpt-x", + durationMs: 300_000, + toolCalls: 10, + reasoningTokens: 50, + outputTokens: 300, + costUsd: 0.4, + coverage: { reviewed: 1, total: 4 }, + }, + { + ...base, + kind: "gate", + ts: "2026-10-22T10:05:01Z", + name: "rv", + effort: "high", + step: 1, + verdict: "escalated", + reason: "coverage: 1 of 4", + coverage: { reviewed: 1, total: 4 }, + reasoningPer100Lines: 50, + floor: 100, + findings: 0, + malformedFindings: 0, + unread: ["src/b.ts"], + escalatedTo: "rv-xhigh", + spent, + }, + { + ...base, + kind: "start", + ts: "2026-10-22T10:06:00Z", + name: "rv-xhigh", + effort: "xhigh", + cwd: "/repo", + effortSource: "flag", + allowShallow: false, + parent: "rv", + }, + { + ...base, + kind: "turn", + ts: "2026-10-22T10:20:00Z", + name: "rv-xhigh", + effort: "xhigh", + turn: 1, + outcome: "completed", + model: "gpt-x", + durationMs: 840_000, + toolCalls: 60, + reasoningTokens: 3000, + outputTokens: 900, + costUsd: 1.5, + coverage: { reviewed: 4, total: 4 }, + }, + { + ...base, + kind: "gate", + ts: "2026-10-22T10:20:01Z", + name: "rv-xhigh", + effort: "xhigh", + step: 2, + verdict: "pass", + reason: "4 of 4 files reviewed", + coverage: { reviewed: 4, total: 4 }, + reasoningPer100Lines: 3000, + floor: 250, + findings: 3, + malformedFindings: 0, + unread: [], + spent, + }, + ]; +} + +describe("the review ledger report", () => { + test("folds one row per review session, with the escalation linked both ways", () => { + const reviews = summarizeReviews(ledgerFixture()); + + expect(reviews.map((review) => [review.name, review.verdict, review.coverage, review.turns])).toEqual([ + ["rv", "escalated", "1/4", 1], + ["rv-xhigh", "pass", "4/4", 1], + ]); + expect(reviews[0]).toMatchObject({ escalatedTo: "rv-xhigh", reasoningPer100Lines: 50, costUsd: 0.4, pr: 12 }); + expect(reviews[1]).toMatchObject({ parent: "rv", effort: "xhigh", reasoningPer100Lines: 3000, toolCalls: 60 }); + expect(reviewTotals(reviews)).toMatchObject({ + reviews: 2, + turns: 2, + toolCalls: 70, + costUsd: 1.9, + verdicts: { escalated: 1, pass: 1 }, + }); + }); + + test("a reused session name starts a new review row at its start line", () => { + const [start, turn] = ledgerFixture(); + const again = { ...start, ts: "2026-10-23T09:00:00Z" }; + const reviews = summarizeReviews([start, turn, again]); + + expect(reviews.map((review) => [review.name, review.turns, review.startedAt])).toEqual([ + ["rv", 1, "2026-10-22T10:00:00Z"], + ["rv", 0, "2026-10-23T09:00:00Z"], + ]); + }); + + test("filters by PR, label, tier, verdict, name and time", () => { + const reviews = summarizeReviews(ledgerFixture()); + const names = (filters: Parameters[1]) => + filterReviews(reviews, filters).map((r) => r.name); + + expect(names({ pr: 12 })).toEqual(["rv", "rv-xhigh"]); + expect(names({ pr: 13 })).toEqual([]); + expect(names({ label: "PR12" })).toEqual(["rv", "rv-xhigh"]); + expect(names({ effort: "xhigh" })).toEqual(["rv-xhigh"]); + expect(names({ verdict: "escalated" })).toEqual(["rv"]); + expect(names({ name: "xhigh" })).toEqual(["rv-xhigh"]); + expect(names({ since: new Date("2026-10-22T10:10:00Z") })).toEqual(["rv-xhigh"]); + }); + + test("--since takes a duration back, a time today or a date", () => { + const now = new Date("2026-10-22T12:00:00Z"); + + expect(parseReviewSince("24h", now)?.toISOString()).toBe("2026-10-21T12:00:00.000Z"); + expect(parseReviewSince("90m", now)?.toISOString()).toBe("2026-10-22T10:30:00.000Z"); + expect(parseReviewSince("2026-10-01", now)?.getDate()).toBe(1); + expect(parseReviewSince("bogus", now)).toBeNull(); + }); + + test("a torn ledger line is skipped and counted, never fatal", () => { + const path = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const [start] = ledgerFixture(); + appendFileSync(path, `${SafeJSON.stringify(start, { strict: true })}\n{"v":1,"kind":"tu\n{"other":true}\n`); + + expect(readReviewRecords(path)).toMatchObject({ records: [{ name: "rv", kind: "start" }], skipped: 2 }); + expect(readFileSync(path, "utf8").split("\n")).toHaveLength(4); + }); +}); + +describe("spawn guards", () => { + test("--base, --allow-shallow and --paths are refused outside --mode review", async () => { + const spawn = (extras: Record) => + codexDriver.spawn({ + name: "x", + cwd: "/repo", + surfaces: { skills: true, rules: true }, + extras: { mode: "task", ...extras }, + }); + + await expect(spawn({ base: "origin/master" })).rejects.toThrow("apply to --mode review only"); + await expect(spawn({ allowShallow: true })).rejects.toThrow("apply to --mode review only"); + }); + + test("a review session cannot start without its review record, and a task session cannot carry one", async () => { + await expect(spawnCodexSession({ name: "x", mode: "review" })).rejects.toThrow("needs its review record"); + await expect(spawnCodexSession({ name: "x", mode: "task", review: reviewConfig() })).rejects.toThrow( + "needs its review record" + ); + }); +}); diff --git a/src/codex/lib/review/types.ts b/src/codex/lib/review/types.ts new file mode 100644 index 0000000000..bf630babc7 --- /dev/null +++ b/src/codex/lib/review/types.ts @@ -0,0 +1,44 @@ +import type { ReviewEffort, ReviewManifest } from "@genesiscz/utils/worker/contract"; + +/** What the depth gate decided about one review turn. */ +export const REVIEW_VERDICTS = ["pass", "steered", "escalated", "shallow"] as const; +export type ReviewVerdict = (typeof REVIEW_VERDICTS)[number]; + +/** Where the base ref came from; printed at spawn, because a derived base is a guess the caller must see. */ +export type ReviewBaseSource = "flag" | "pr" | "upstream"; + +/** What the review turns of one session spent, summed; the gate's reasoning floor reads it. */ +export interface CodexReviewSpent { + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + outputTokens: number; + /** API-equivalent dollars; null while any turn was unpriced. */ + costUsd: number | null; +} + +/** The `--mode review` part of a codex session record. */ +export interface CodexReviewConfig { + base: string; + baseSource: ReviewBaseSource; + /** One human sentence on how the base was chosen. */ + baseDetail: string; + branch?: string; + pr?: number; + manifest: ReviewManifest; + effort: ReviewEffort; + effortSource: "flag" | "diff-size"; + allowShallow: boolean; + /** Automatic gate steps already taken in this review chain: 0 fresh, 1 after the steer, 2 in the escalated session. */ + step: number; + /** The first session of the chain; an escalated session keeps its parent's chain. */ + chain: string; + parent?: string; + /** The first prompt of the review, kept so an escalation can rerun it. */ + prompt?: string; + verdict?: ReviewVerdict; + reason?: string; + escalatedTo?: string; + spent?: CodexReviewSpent; +} diff --git a/src/codex/lib/seed-instructions.ts b/src/codex/lib/seed-instructions.ts index e6873f597f..73de67f703 100644 --- a/src/codex/lib/seed-instructions.ts +++ b/src/codex/lib/seed-instructions.ts @@ -1,4 +1,4 @@ -import { buildWorkerContract, type WorkerSandbox } from "@genesiscz/utils/worker/contract"; +import { buildWorkerContract, type WorkerReviewInput, type WorkerSandbox } from "@genesiscz/utils/worker/contract"; /** The contract's name for a codex sandbox mode: `danger-full-access` (`--write full`) has no sandbox at all. */ export function contractSandbox(sandbox: string | undefined): WorkerSandbox { @@ -19,10 +19,12 @@ export function buildAgentInstructions(options: { rendezvousSession: string; leadName: string; sandbox?: string; + review?: WorkerReviewInput | null; }): string { return buildWorkerContract({ backend: "codex", sandbox: contractSandbox(options.sandbox), bus: { agentName: options.agentName, leadName: options.leadName, rendezvousSession: options.rendezvousSession }, + ...(options.review ? { review: options.review } : {}), }); } diff --git a/src/codex/lib/session.test.ts b/src/codex/lib/session.test.ts index b28d66cb78..a0b15c9aac 100644 --- a/src/codex/lib/session.test.ts +++ b/src/codex/lib/session.test.ts @@ -10,6 +10,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import type { ThreadTurnsListParams, Turn } from "./_generated/protocol"; import { CodexAccountBinding } from "./account"; import { isOwnBusCommand, isReadOnlyCommand } from "./bus-approval"; +import { readReviewRecords } from "./review/ledger"; import type { RpcClient } from "./session"; import { CodexSessionRuntime } from "./session"; import { type CodexSessionMeta, CodexSessionStore } from "./store"; @@ -1168,3 +1169,125 @@ describe("an ask worker's read-only commands", () => { }); }); }); + +describe("a --mode review session", () => { + const report = (reviewed: number) => + `Findings: none\nRan: nothing\nFiles reviewed: ${reviewed} of 2\nNot reviewed: none`; + + async function finishTurn(runtime: CodexSessionRuntime, turn: string, text: string, reasoning = 5): Promise { + const params = { threadId: "thread-1" }; + await runtime.handleNotification({ method: "turn/started", params: { ...params, turn: { id: turn } } }); + await runtime.handleNotification({ + method: "thread/tokenUsage/updated", + params: { + ...params, + tokenUsage: { last: { inputTokens: 100, outputTokens: 50, reasoningOutputTokens: reasoning } }, + }, + }); + await runtime.handleNotification({ + method: "item/completed", + params: { ...params, item: { type: "agentMessage", id: `${turn}-msg`, text } }, + }); + await runtime.handleNotification({ + method: "turn/completed", + params: { ...params, turn: { id: turn, status: "completed" } }, + }); + } + + test("gets its tier and manifest, steers once on a miss, escalates on the second, and ends shallow when that fails", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-codex-runtime-review-")); + + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + const store = new CodexSessionStore(); + const meta: CodexSessionMeta = { + ...makeMeta(home), + name: "rv", + agentsEnabled: false, + writePolicy: "deny", + sandbox: "read-only", + effort: "high", + review: { + base: "origin/master", + baseSource: "flag", + baseDetail: "--base", + pr: 12, + manifest: { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 40, deleted: 2 }, + { path: "src/b.ts", added: 8, deleted: 0 }, + ], + added: 48, + deleted: 2, + }, + effort: "high", + effortSource: "diff-size", + allowShallow: false, + step: 0, + chain: "rv", + }, + }; + store.writeMeta(meta); + const client = new FakeRpcClient(); + const escalations: string[] = []; + const gateEvents: unknown[] = []; + const runtime = new CodexSessionRuntime({ + client, + store, + meta, + escalateReview: async (request) => { + escalations.push(`${request.name} at ${request.effort}`); + throw new Error('Codex session "rv-xhigh" is already active'); + }, + onReviewGate: (event) => { + gateEvents.push(event.verdict); + }, + }); + + await runtime.start({ prompt: "Review PR 12." }); + const threadStart = client.requests.find((request) => request.method === "thread/start"); + const startParams = threadStart?.params as { developerInstructions?: string } | undefined; + const instructions = String(startParams?.developerInstructions); + expect(instructions).toContain("You are a code REVIEWER at the high tier."); + expect(instructions).toContain("Files to review (2, +48 -2):\n- src/a.ts +40 -2\n- src/b.ts +8 -0"); + expect((await store.readMeta("rv"))?.review?.prompt).toBe("Review PR 12."); + + await finishTurn(runtime, "turn-1", report(1)); + const steers = client.requests.filter((request) => request.method === "turn/start"); + expect(steers).toHaveLength(2); + const steerText = (steers[1].params as { input: Array<{ text: string }> }).input[0].text; + expect(steerText).toContain("You did not read: src/a.ts, src/b.ts."); + // The session never reads as idle between the missed turn and its steer. + expect((await store.readMeta("rv"))?.status).toBe("running"); + expect((await store.readMeta("rv"))?.review).toMatchObject({ verdict: "steered", step: 1 }); + + await finishTurn(runtime, "turn-2", report(1)); + expect(client.requests.filter((request) => request.method === "turn/start")).toHaveLength(2); + expect(escalations).toEqual(["rv-xhigh at xhigh"]); + + const after = await store.readMeta("rv"); + expect(after?.status).toBe("ready"); + expect(after?.review?.verdict).toBe("shallow"); + expect(after?.review?.reason).toContain('the escalate failed: Codex session "rv-xhigh" is already active'); + expect(after?.review?.spent).toMatchObject({ turns: 2, reasoningTokens: 10 }); + expect(gateEvents).toEqual(["steered", "escalated", "shallow"]); + expect( + readReviewRecords().records.map( + (record) => `${record.kind}:${"verdict" in record ? record.verdict : ""}` + ) + ).toEqual(["turn:", "gate:steered", "turn:", "gate:escalated", "gate:shallow"]); + + // A passing review needs no step: a full report with enough reasoning ends the gate at once. + const passing = new CodexSessionRuntime({ + client: new FakeRpcClient(), + store, + meta: { ...meta, name: "rv-ok", review: { ...meta.review!, chain: "rv-ok", prompt: "x" } }, + }); + store.writeMeta({ ...meta, name: "rv-ok" }); + await passing.start({}); + await finishTurn(passing, "turn-1", report(2), 500); + expect((await store.readMeta("rv-ok"))?.review).toMatchObject({ verdict: "pass", step: 0 }); + expect((await store.readMeta("rv-ok"))?.status).toBe("ready"); + }); + }); +}); diff --git a/src/codex/lib/session.ts b/src/codex/lib/session.ts index fe3755f849..9b923aecbb 100644 --- a/src/codex/lib/session.ts +++ b/src/codex/lib/session.ts @@ -1,11 +1,11 @@ import { relative } from "node:path"; import { formatCostLine } from "@app/ai-spend/lib/workers/cost"; -import { recordWorkerTurnCost } from "@app/ai-spend/lib/workers/ledger"; +import { recordWorkerTurnCost, type WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; import { withTimeout } from "@genesiscz/utils/async"; import { type CodexFinishedTurn, CodexTurnCostTracker } from "@genesiscz/utils/codex/turn-cost"; import { codexTurnOutcome } from "@genesiscz/utils/codex/turn-outcome"; import { logger } from "@genesiscz/utils/logger"; -import { buildWorkerContract } from "@genesiscz/utils/worker/contract"; +import { buildWorkerContract, type ReviewEffort } from "@genesiscz/utils/worker/contract"; import { WorkerDeliveryRejectedError, workerSourceHome } from "@genesiscz/utils/worker/delivery"; import type { InitializeParams, @@ -26,6 +26,7 @@ import type { RpcNotification, RpcServerRequest } from "./app-server-client"; import { isOwnBusCommand, isReadOnlyCommand } from "./bus-approval"; import type { CodexControl } from "./control"; import { assignThreadToProject, type CodexProject, resolveProjectSelector } from "./desktop-project"; +import { markReviewShallow, recordReviewTurn } from "./review/controller"; import { buildAgentInstructions, contractSandbox } from "./seed-instructions"; import type { CodexSessionMeta, CodexSessionStore } from "./store"; @@ -46,6 +47,16 @@ interface RuntimeOptions { unsubscribeTimeoutMs?: number; /** `tools codex spawn --project`: a Desktop project id, name or root. Without it the cwd decides. */ project?: string; + /** Starts the escalated review session the depth gate asked for; the daemon wires it to `spawnCodexSession`. */ + escalateReview?: (request: EscalateReviewRequest) => Promise; + /** Told about every gate decision, so the daemon can put it on the agents bus. */ + onReviewGate?: (event: Record) => void | Promise; +} + +export interface EscalateReviewRequest { + meta: CodexSessionMeta; + effort: ReviewEffort; + name: string; } interface ThreadStartResult { @@ -119,6 +130,10 @@ export class CodexSessionRuntime { private readonly costTracker: CodexTurnCostTracker; /** The cost line of the last booked turn, for the lead's `turn_completed` bus event. */ private lastTurnCost: { turnId?: string; line: string } | null = null; + /** The current turn's last agent message: a review's report, which the depth gate reads. */ + private turnMessage = ""; + private readonly escalateReview: RuntimeOptions["escalateReview"]; + private readonly onReviewGate: RuntimeOptions["onReviewGate"]; constructor(options: RuntimeOptions) { this.account = options.account; @@ -128,6 +143,8 @@ export class CodexSessionRuntime { this.onApprovalRequest = options.onApprovalRequest; this.unsubscribeTimeoutMs = options.unsubscribeTimeoutMs ?? 1000; this.projectSelector = options.project; + this.escalateReview = options.escalateReview; + this.onReviewGate = options.onReviewGate; // A pinned account is a ChatGPT login; `account/updated` confirms or corrects it. this.costTracker = new CodexTurnCostTracker({ model: options.meta.model ?? null, @@ -159,23 +176,28 @@ export class CodexSessionRuntime { this.chosenProject = resolveProjectSelector(projects.data ?? [], this.projectSelector); } + const review = this.meta.review + ? { effort: this.meta.review.effort, manifest: this.meta.review.manifest } + : null; const threadParams: ThreadStartParams = { cwd: this.meta.cwd, sandbox: this.meta.sandbox, approvalPolicy: this.meta.approvalPolicy, ...(this.meta.model ? { model: this.meta.model } : {}), - // Every worker gets the shared contract (checkpoints, report shape); - // the bus identity and commands only when the swarm is enabled. + // Every worker gets the shared contract (checkpoints, report shape); the bus identity and + // commands only when the swarm is enabled; a reviewer its tier rules and the diff manifest. developerInstructions: this.meta.agentsEnabled ? buildAgentInstructions({ agentName: this.meta.agentName, rendezvousSession: this.meta.rendezvousSession, leadName: "lead", sandbox: this.meta.sandbox, + review, }) : buildWorkerContract({ backend: "codex", sandbox: contractSandbox(this.meta.sandbox), + review, }), }; const thread = await this.client.request("thread/start", threadParams); @@ -198,7 +220,9 @@ export class CodexSessionRuntime { ...(this.meta.effort ? { effort: this.meta.effort } : {}), }; const result = await this.client.request("turn/start", params); - await this.updateMeta({ activeTurnId: result.turn.id, status: "running" }); + // A review keeps its first prompt, so an escalated session can rerun the same review one tier higher. + const review = this.meta.review && !this.meta.review.prompt ? { ...this.meta.review, prompt: body } : undefined; + await this.updateMeta({ activeTurnId: result.turn.id, status: "running", ...(review ? { review } : {}) }); return result.turn.id; } @@ -222,10 +246,9 @@ export class CodexSessionRuntime { await this.fileUnderProject(notification); this.trackFileChange(notification); + this.trackAgentMessage(notification); const finished = this.costTracker.observe(notification, new Date()); - if (finished) { - await this.bookTurnCost(finished); - } + const booked = finished ? await this.bookTurnCost(finished) : null; if (notification.method === "turn/started") { await this.updateMeta({ @@ -238,6 +261,12 @@ export class CodexSessionRuntime { const terminal = codexTurnOutcome(notification); if (terminal) { + // A steered review keeps running: the session never reads as ready between the turn and its steer. + if (finished && this.meta.review && (await this.gateReviewTurn(finished, booked))) { + await this.updateMeta({ lastEventAt }); + return; + } + await this.updateMeta({ activeTurnId: undefined, status: "ready", lastEventAt }); if (terminal.outcome !== "interrupted") { await this.deliverQueuedSteer(); @@ -505,8 +534,99 @@ export class CodexSessionRuntime { } } + /** The last agent message of the current turn, which a review's gate parses. */ + private trackAgentMessage(notification: RpcNotification): void { + if (notification.method === "turn/started") { + this.turnMessage = ""; + return; + } + + const item = nestedRecord(notification.params, "item"); + + if ( + notification.method === "item/completed" && + item?.type === "agentMessage" && + typeof item.text === "string" + ) { + this.turnMessage = item.text; + } + } + + /** + * Runs the depth gate on a finished review turn and takes its step. True when the review continues in this + * session (the steer started a turn). An escalation starts a new session and this one ends; a step the runtime + * cannot take ends the review `shallow` with the reason. + */ + private async gateReviewTurn(finished: CodexFinishedTurn, booked: WorkerCostRow | null): Promise { + const current = this.meta.review; + + if (!current) { + return false; + } + + const outcome = recordReviewTurn({ + name: this.meta.name, + ...(this.meta.label ? { label: this.meta.label } : {}), + review: current, + finished, + report: this.turnMessage, + costUsd: booked?.cost.apiEquivalentUsd ?? null, + }); + this.turnMessage = ""; + await this.updateMeta({ review: outcome.review }); + + if (outcome.decision) { + await this.publishReviewGate(outcome.review); + } + + const action = outcome.action; + + try { + if (action.kind === "steer") { + await this.startTurn(action.text); + return true; + } + + if (action.kind === "escalate") { + if (!this.escalateReview) { + throw new Error("this runtime has no way to start an escalated session"); + } + + await this.escalateReview({ meta: this.meta, effort: action.effort, name: action.name }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn({ err, name: this.meta.name, action: action.kind }, "review gate step failed"); + const review = markReviewShallow({ + name: this.meta.name, + ...(this.meta.label ? { label: this.meta.label } : {}), + review: outcome.review, + reason: `${outcome.review.reason}; the ${action.kind} failed: ${message}`, + }); + await this.updateMeta({ review }); + await this.publishReviewGate(review); + } + + return false; + } + + private async publishReviewGate(review: NonNullable): Promise { + try { + await this.onReviewGate?.({ + event: "review_gate", + name: this.meta.name, + verdict: review.verdict, + reason: review.reason, + step: review.step, + ...(review.escalatedTo ? { escalatedTo: review.escalatedTo } : {}), + }); + } catch (err) { + log.warn({ err, name: this.meta.name }, "publishing the review gate decision failed"); + } + } + /** Never throws: `recordWorkerTurnCost` logs its own failure and the turn goes on without a cost line. */ - private async bookTurnCost(finished: CodexFinishedTurn): Promise { + private async bookTurnCost(finished: CodexFinishedTurn): Promise { const row = await recordWorkerTurnCost({ backend: "codex", name: this.meta.name, @@ -529,6 +649,7 @@ export class CodexSessionRuntime { this.lastTurnCost = row ? { ...(finished.turnId ? { turnId: finished.turnId } : {}), line: formatCostLine(row.cost, "codex") } : null; + return row; } /** `{ cost }` for the bus event of the turn just booked; undefined for any other notification. */ diff --git a/src/codex/lib/spawn.ts b/src/codex/lib/spawn.ts index 162c8e0f23..77383f802e 100644 --- a/src/codex/lib/spawn.ts +++ b/src/codex/lib/spawn.ts @@ -16,6 +16,8 @@ import { CODEX_SCHEMA_VERSION } from "./_generated/protocol"; import { CodexAccountBinding } from "./account"; import { computerUseOverrides } from "./computer-use"; import { sessionDaemonLogPath, sessionLaunchPath } from "./paths"; +import { appendReviewRecord, reviewLedgerBase } from "./review/ledger"; +import type { CodexReviewConfig } from "./review/types"; import { type CodexSessionMeta, CodexSessionStore, type CodexWritePolicy } from "./store"; import { detectCodexVersion } from "./version"; @@ -37,6 +39,8 @@ export interface SpawnOptions { label?: string; /** A Codex Desktop project id, name or root to show the thread under; the cwd decides without it. */ project?: string; + /** Required with `mode: "review"`: what `prepareReview` decided, plus the chain fields. */ + review?: CodexReviewConfig; } export interface LaunchConfig { @@ -269,6 +273,10 @@ export interface CodexSpawnResult extends CodexSessionMeta { } export async function spawnCodexSession(options: SpawnOptions): Promise { + if ((options.mode === "review") !== (options.review !== undefined)) { + throw new Error("A review session needs its review record, and only a review session takes one"); + } + const cwd = resolve(options.cwd ?? process.cwd()); const checkout = checkoutKindSync(cwd); assertWritePolicyAllowed({ write: options.write, checkout }); @@ -335,6 +343,8 @@ export async function spawnCodexSession(options: SpawnOptions): Promise { try { return Bun.spawn({ diff --git a/src/codex/lib/store.ts b/src/codex/lib/store.ts index c0f05cb0ff..4828ed578c 100644 --- a/src/codex/lib/store.ts +++ b/src/codex/lib/store.ts @@ -7,6 +7,7 @@ import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; import { logger } from "@genesiscz/utils/logger"; import { WorkerMetaStore } from "@genesiscz/utils/worker/meta-store"; import { sessionEventsPath, sessionMetaPath, sessionsDir } from "./paths"; +import type { CodexReviewConfig } from "./review/types"; const log = logger.child({ component: "codex:store" }); @@ -81,6 +82,8 @@ export interface CodexSessionMeta { desktopProject?: { id: string; name: string }; /** The `spawn --label` tag every cost-ledger row of the session carries. */ label?: string; + /** `spawn --mode review`: the base, the diff manifest, the tier and the depth gate's state. */ + review?: CodexReviewConfig; } export interface CodexEventRecord { From f2b19a3d3ba17dda2785d19f6c68b31bb43ea481 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:03:24 +0200 Subject: [PATCH 008/251] feat(codex): tools codex reviews lists every review run from the ledger with filters and totals, and review-depth shows one review's coverage, reasoning per 100 lines and gate history --- src/codex/README.md | 31 ++- src/codex/commands/reviews.ts | 384 ++++++++++++++++++++++++++++++++++ src/codex/index.ts | 2 + 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/codex/commands/reviews.ts diff --git a/src/codex/README.md b/src/codex/README.md index 4f89b2ac03..d8c9990537 100644 --- a/src/codex/README.md +++ b/src/codex/README.md @@ -240,7 +240,8 @@ Sources: [app-server protocol](https://learn.chatgpt.com/docs/app-server), tools codex spawn \ --name reviewer \ --mode review \ - --prompt "Review the current working tree" + --base origin/master \ + --prompt "Review this branch" tools codex status --name reviewer tools codex tail --name reviewer --follow @@ -266,6 +267,34 @@ worker verbs (`src/ai/commands/agent/worker.ts`); what makes them codex is `code (`src/codex/lib/driver.ts`). `steer` still answers to its older `--body` / `--body-file` spelling, hidden from help. +### Review mode (`--mode review`) + +`--mode review` needs a base: `--base `, else the branch's PR target, else its upstream (never the branch's own +remote copy); the spawn prints which. It runs `git diff --numstat ...HEAD` in the worker's cwd (limited to +`--paths ` when given) and refuses an empty diff. The worker's developer instructions carry the shared worker +contract plus `reviewLines(effort)` from `src/utils/worker/contract.ts` and the manifest (`Files to review (N, +A -D)` +and one line per file). + +The tier is `--effort`, else the diff size: `high` up to 15 files and 800 changed lines, `xhigh` above. `max` only when +named. `low` (triage) and `medium` are refused unless `--allow-shallow`. Above 60 files or 4,000 lines the spawn prints +a split suggestion, one reviewer per folder with `--paths`. + +After each review turn the daemon runs the depth gate (`src/codex/lib/review/gate.ts`): the `Files reviewed: n of N` +line against the manifest (a binary file named with a reason is excused), reasoning tokens per 100 changed lines +against the tier floor (high 100, xhigh and max 250), and the finding shape (`N. [Severity] path:line - defect. +Scenario: ... Fix: ...`, then `Ran:`). The first miss steers the same session once with the unread files; the second +starts `-` as a new session (a steer cannot change a thread's effort), at step 2; a miss after that, +or a miss at `max`, ends the review `shallow`. A medium review with `--allow-shallow` is never tuned up. Each decision +also goes to the agents bus as a `review_gate` event. + +Every review start, review turn and gate decision is one line of `~/.genesis-tools/codex/reviews.jsonl`: + +```bash +tools codex reviews [--since 24h] [--pr ] [--label ] [--effort ] [--verdict pass|steered|escalated|shallow|open] [--name ] [--json] +tools codex review-depth [--json] # one review: manifest, spend, coverage, reasoning per 100 lines, verdict, gate history +tools codex review-depth --base # the same checks for a session spawned without --mode review +``` + `login` and `usage` are doors onto the provider-neutral account core: the same code runs behind `tools ai accounts login --provider codex` and `tools ai usage --provider codex`. `tools ai accounts discover --provider codex` lists every `~/.codex*` profile on the machine and `--bind` turns the unbound ones into accounts. Sessions are read-only by default. For implementation work, use `--write ask` for supervised approvals or diff --git a/src/codex/commands/reviews.ts b/src/codex/commands/reviews.ts new file mode 100644 index 0000000000..cfa94646ec --- /dev/null +++ b/src/codex/commands/reviews.ts @@ -0,0 +1,384 @@ +import { sessionCostSummary } from "@app/ai-spend/lib/workers/ledger"; +import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { formatDuration, formatTokens } from "@genesiscz/utils/format"; +import { logger, out } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import * as p from "@genesiscz/utils/prompts/p"; +import { createBoxTable, formatDotStatus, renderCliHeader, truncateDisplay } from "@genesiscz/utils/table"; +import { + isReviewEffort, + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, +} from "@genesiscz/utils/worker/contract"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { checkReview, type ReviewGateChecks } from "../lib/review/gate"; +import { + type ReviewGateRecord, + type ReviewLedgerRecord, + readReviewRecords, + reviewLedgerPath, +} from "../lib/review/ledger"; +import { buildManifest, defaultReviewGitReaders } from "../lib/review/prepare"; +import { + filterReviews, + parseReviewSince, + type ReviewSummary, + reviewTotals, + summarizeReviews, +} from "../lib/review/report"; +import { REVIEW_VERDICTS } from "../lib/review/types"; +import { type CodexSessionMeta, CodexSessionStore } from "../lib/store"; + +const { log } = logger.scoped("codex-reviews"); + +const VERDICT_FILTERS = [...REVIEW_VERDICTS, "open"] as const; + +interface ReviewsOptions { + since?: string; + pr?: string; + label?: string; + effort?: string | true; + verdict?: string | true; + name?: string; + json?: boolean; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function money(usd: number | null): string { + return usd === null ? "unpriced" : `$${usd.toFixed(2)}`; +} + +function verdictCell(verdict: ReviewSummary["verdict"]): string { + const tone = verdict === "pass" ? "ok" : verdict === "shallow" ? "err" : verdict === "open" ? "dim" : "warn"; + return formatDotStatus(tone, verdict); +} + +/** + * A closed-set flag given without a value or with an unknown one: a TTY picks from the values, anything else gets + * the values and a filled command line, and the run stops with exit 1. + */ +async function enumFilter(flag: string, raw: string | true | undefined, values: readonly string[]) { + if (raw === undefined) { + return { ok: true as const, value: undefined }; + } + + if (raw !== true && values.includes(raw)) { + return { ok: true as const, value: raw }; + } + + if (isInteractive()) { + const picked = await p.select({ + message: `${flag}`, + options: values.map((value) => ({ value, label: value })), + }); + + if (!p.isCancel(picked) && typeof picked === "string") { + return { ok: true as const, value: picked }; + } + } + + out.log.error(suggestEnumFlag("tools codex", flag, values, raw === true ? {} : { given: raw })); + process.exitCode = 1; + return { ok: false as const }; +} + +async function runReviews(options: ReviewsOptions): Promise { + const effort = await enumFilter("--effort", options.effort, REVIEW_EFFORTS); + const verdict = await enumFilter("--verdict", options.verdict, VERDICT_FILTERS); + + if (!effort.ok || !verdict.ok) { + return; + } + + const since = options.since === undefined ? undefined : parseReviewSince(options.since); + + if (since === null) { + out.log.error(`--since ${options.since} is not a duration (24h, 90m, 7d), a time (HH:MM) or a date.`); + process.exitCode = 1; + return; + } + + const pr = options.pr === undefined ? undefined : Number(options.pr.replace(/^#/, "")); + + if (pr !== undefined && !Number.isInteger(pr)) { + out.log.error(`--pr ${options.pr} is not a PR number.`); + process.exitCode = 1; + return; + } + + const path = reviewLedgerPath(); + const { records, skipped } = readReviewRecords(path); + const reviews = filterReviews(summarizeReviews(records), { + ...(since ? { since } : {}), + ...(pr === undefined ? {} : { pr }), + ...(options.label ? { label: options.label } : {}), + ...(effort.value ? { effort: effort.value } : {}), + ...(verdict.value ? { verdict: verdict.value } : {}), + ...(options.name ? { name: options.name } : {}), + }); + const totals = reviewTotals(reviews); + log.debug({ path, records: records.length, skipped, shown: reviews.length }, "codex reviews read"); + + if (options.json) { + out.result({ ledger: path, skipped, reviews, totals }); + return; + } + + if (skipped > 0) { + out.log.warn(`${skipped} ledger line(s) in ${path} did not parse and were skipped.`); + } + + if (reviews.length === 0) { + out.log.info(`No review runs match${records.length === 0 ? `: ${path} has none yet` : ""}.`); + return; + } + + renderCliHeader("Codex reviews", `${reviews.length} review(s) · ledger ${path}`); + const table = createBoxTable([ + "NAME", + "PR", + "FILES", + "EFFORT", + "TIME", + "TOOLS", + "RSN/100L", + "COVERAGE", + "VERDICT", + "COST", + ]); + + for (const review of reviews) { + table.push([ + truncateDisplay(review.label ? `${review.name} ${pc.dim(review.label)}` : review.name, 36), + review.pr === undefined ? (review.branch ? truncateDisplay(review.branch, 18) : "—") : `#${review.pr}`, + `${review.files} (${review.lines}L)`, + review.effort, + formatDuration(review.durationMs), + String(review.toolCalls), + review.reasoningPer100Lines === null ? "—" : String(review.reasoningPer100Lines), + review.coverage ?? "—", + review.escalatedTo ? `${verdictCell(review.verdict)} → ${review.escalatedTo}` : verdictCell(review.verdict), + money(review.costUsd), + ]); + } + + out.println(table.toString()); + const verdicts = Object.entries(totals.verdicts) + .map(([name, count]) => `${count} ${name}`) + .join(", "); + out.println( + `Totals: ${plural(totals.reviews, "review")} (${verdicts}) · ${plural(totals.turns, "turn")} · ${formatDuration(totals.durationMs)} · ${totals.toolCalls} tool calls · ${formatTokens(totals.reasoningTokens)} reasoning · $${totals.costUsd.toFixed(2)} API-equivalent${totals.unpricedReviews > 0 ? ` (+${totals.unpricedReviews} unpriced)` : ""}` + ); + out.println(pc.dim(`One review in depth: ${toolCommand("codex review-depth")} `)); +} + +interface DepthOptions { + base?: string; + json?: boolean; +} + +function checkLine(label: string, ok: boolean, detail: string): string { + return `${ok ? pc.green("✓") : pc.red("✗")} ${label.padEnd(9)} ${detail}`; +} + +function gateLines(gate: ReviewGateRecord): string[] { + const coverageOk = gate.coverage.reviewed !== null && gate.coverage.reviewed >= gate.coverage.total; + const reasoningOk = gate.reasoningPer100Lines === null || gate.reasoningPer100Lines >= gate.floor; + + return [ + checkLine( + "coverage", + coverageOk, + `${gate.coverage.reviewed ?? "no line"} of ${gate.coverage.total} files${gate.unread.length > 0 ? `; unread: ${gate.unread.slice(0, 8).join(", ")}${gate.unread.length > 8 ? ", …" : ""}` : ""}` + ), + checkLine( + "reasoning", + reasoningOk, + `${gate.reasoningPer100Lines ?? "unknown"} tokens per 100 changed lines (floor ${gate.floor})` + ), + checkLine( + "shape", + gate.malformedFindings === 0, + `${gate.findings} findings, ${gate.malformedFindings} malformed` + ), + ]; +} + +function checksLines(checks: ReviewGateChecks): string[] { + return [ + checkLine("coverage", checks.coverage.ok, checks.coverage.detail), + checkLine("reasoning", checks.reasoning.ok, checks.reasoning.detail), + checkLine("shape", checks.shape.ok, checks.shape.detail), + ]; +} + +/** The last agent message of the session's own thread, from its event log. */ +async function lastAgentMessage(store: CodexSessionStore, meta: CodexSessionMeta): Promise { + const events = await store.readEvents(meta.name); + + for (const event of events.toReversed()) { + const params = event.params; + + if (event.method !== "item/completed" || !isObject(params) || !isObject(params.item)) { + continue; + } + + const { item, threadId } = params; + + if ( + item.type === "agentMessage" && + typeof item.text === "string" && + (threadId === undefined || threadId === meta.threadId) + ) { + return item.text; + } + } + + return ""; +} + +/** A session spawned without `--mode review`: the gate's checks against a manifest computed now from `--base`. */ +async function adHocDepth(store: CodexSessionStore, meta: CodexSessionMeta, options: DepthOptions): Promise { + if (!options.base) { + out.log.error( + `${meta.name} was not spawned with --mode review, so it has no manifest. Pass --base to measure it against git diff ...HEAD in ${meta.cwd}.` + ); + process.exitCode = 1; + return; + } + + const readers = defaultReviewGitReaders(); + const manifest = buildManifest(options.base, await readers.numstat(meta.cwd, options.base)); + const effort: ReviewEffort = isReviewEffort(meta.effort) ? meta.effort : "high"; + const cost = sessionCostSummary({ backend: "codex", name: meta.name, sessionId: meta.threadId }); + const reasoning = cost ? cost.session.tokens.reasoning : null; + const checks = checkReview({ + report: await lastAgentMessage(store, meta), + manifest, + effort, + reasoningTokens: reasoning, + }); + + if (options.json) { + out.result({ name: meta.name, review: null, base: options.base, effort, manifest, checks, cost }); + return; + } + + out.println(`${pc.bold(meta.name)} ${pc.dim("not a --mode review session; measured now")} · effort ${effort}`); + out.println( + `manifest ${manifest.files.length} files, +${manifest.added} -${manifest.deleted} against ${options.base}` + ); + + if (cost) { + out.println( + `spent ${cost.session.turns} turns · ${formatDuration(cost.session.durationMs)} · ${cost.session.toolCalls} tool calls · ${formatTokens(cost.session.tokens.reasoning)} reasoning · $${cost.session.apiEquivalentUsd.toFixed(2)}` + ); + } + + for (const line of checksLines(checks)) { + out.println(line); + } +} + +async function runReviewDepth(name: string, options: DepthOptions): Promise { + const store = new CodexSessionStore(); + const meta = await store.readMeta(name); + + if (!meta) { + out.log.error(`No codex session named ${name}. ${toolCommand("codex sessions")} lists them.`); + process.exitCode = 1; + return; + } + + const review = meta.review; + + if (!review) { + await adHocDepth(store, meta, options); + return; + } + + const records = readReviewRecords().records.filter( + (record: ReviewLedgerRecord) => record.name === name && record.chain === review.chain + ); + const gates = records.filter((record): record is ReviewGateRecord => record.kind === "gate"); + const lastGate = gates.at(-1); + + if (options.json) { + out.result({ name, review, gates, turns: records.filter((record) => record.kind === "turn") }); + return; + } + + const spent = review.spent; + const lines = manifestChangedLines(review.manifest); + out.println( + `${pc.bold(name)} review · effort ${review.effort} (${review.effortSource === "flag" ? "--effort" : "diff size"}) · ${verdictCell(review.verdict ?? "open")}${review.escalatedTo ? ` → ${review.escalatedTo}` : ""}` + ); + out.println( + `base ${review.base} (${review.baseSource}: ${review.baseDetail})${review.branch ? ` · branch ${review.branch}` : ""}${review.pr === undefined ? "" : ` · PR #${review.pr}`}${review.parent ? ` · escalated from ${review.parent}` : ""}` + ); + out.println( + `manifest ${review.manifest.files.length} files, +${review.manifest.added} -${review.manifest.deleted} (${lines} lines)` + ); + + if (spent) { + out.println( + `spent ${spent.turns} turns · ${formatDuration(spent.durationMs)} · ${spent.toolCalls} tool calls · ${formatTokens(spent.reasoningTokens)} reasoning · ${money(spent.costUsd)}` + ); + } + + if (lastGate) { + for (const line of gateLines(lastGate)) { + out.println(line); + } + } + + if (review.reason) { + out.println(`reason: ${review.reason}`); + } + + if (gates.length > 1) { + out.println(pc.dim("gate history:")); + + for (const gate of gates) { + // Local time, like every time this tool prints; the ledger keeps UTC. + out.println( + pc.dim( + ` ${new Date(gate.ts).toLocaleString("sv-SE")} step ${gate.step} ${gate.verdict}: ${gate.reason}` + ) + ); + } + } +} + +export function registerReviewsCommands(program: Command): void { + program + .command("reviews") + .description("Every --mode review run from the review ledger: one row per review, with totals") + .option("--since ", "Only reviews active since: a duration back (24h, 7d), HH:MM today, or a date") + .option("--pr ", "Only reviews of this PR number") + .option("--label ", "Only reviews whose spawn --label contains this text") + .option("--effort [tier]", `Only reviews at this tier: ${REVIEW_EFFORTS.join(" | ")}`) + .option("--verdict [verdict]", `Only reviews with this gate verdict: ${VERDICT_FILTERS.join(" | ")}`) + .option("--name ", "Only reviews whose session or chain name contains this text") + .option("--json", "Emit the reviews and totals as JSON") + .action(async (options: ReviewsOptions) => { + await runReviews(options); + }); + + program + .command("review-depth ") + .description( + "One review's depth: manifest, effort, spend, coverage, reasoning per 100 lines and the gate verdict" + ) + .option("--base ", "For a session spawned without --mode review: the base to measure it against") + .option("--json", "Emit as JSON") + .action(async (name: string, options: DepthOptions) => { + await runReviewDepth(name, options); + }); +} diff --git a/src/codex/index.ts b/src/codex/index.ts index 75bff2694a..f59e455e27 100644 --- a/src/codex/index.ts +++ b/src/codex/index.ts @@ -9,6 +9,7 @@ import { registerLogsCommand } from "./commands/logs"; import { registerMigrateHomeCommand } from "./commands/migrate-home"; import { registerCodexMessageCommands } from "./commands/queue"; import { registerReviewCommand } from "./commands/review"; +import { registerReviewsCommands } from "./commands/reviews"; import { registerRollbackCommand } from "./commands/rollback"; import { codexSpec } from "./lib/spec"; @@ -24,6 +25,7 @@ registerMigrateHomeCommand(program); registerDesktopCommand(program); registerRollbackCommand(program); registerReviewCommand(program); +registerReviewsCommands(program); registerApprovalCommands(program); registerLogsCommand(program); From 4d13c8dbf42266d13b7c4a1ee365d890dedc80f2 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:03:26 +0200 Subject: [PATCH 009/251] docs(skills, babysit, handoff-to): reviews spawn with --mode review --base and leave depth to its contract and gate, and both skills name tools codex reviews; plugin 1.0.116 --- .claude-plugin/marketplace.json | 4 +- .claude/skills/babysit/SKILL.md | 16 +++-- .../skills/babysit/scripts/review-brief.ts | 72 ++++++++++++++++--- .../skills/babysit/scripts/scripts.test.ts | 31 +++++++- .../genesis-tools/.claude-plugin/plugin.json | 2 +- .../genesis-tools/skills/handoff-to/SKILL.md | 19 +++-- .../skills/handoff-to/references/codex.md | 10 ++- 7 files changed, 125 insertions(+), 29 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0c140ed8df..199c3b2c86 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.115", + "version": "1.0.116", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -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.115", + "version": "1.0.116", "author": { "name": "GenesisTools" }, diff --git a/.claude/skills/babysit/SKILL.md b/.claude/skills/babysit/SKILL.md index d8c0e8d8d0..951782304a 100644 --- a/.claude/skills/babysit/SKILL.md +++ b/.claude/skills/babysit/SKILL.md @@ -54,7 +54,7 @@ Write it to `/tmp/cc///babysit/run.json` at the start; the scr |---|---|---| | `prs` | asked | PR numbers. Small PRs first; never a PR another session owns (step 3.1). | | `worker` per PR | `grok` | `grok` (model `grok-4.7-build-fast`, `--auth subscription`) or `codex` (model `gpt-6.1-sol`, `--write ask`). Mix freely; one worker per PR. | -| `effort` per PR | from the work | Codex `--effort`: `low`, `medium`, `high`, `xhigh` or `max`. A fix round: `medium`, `high` for a Critical or a cross-file finding. An independent review (step 7a): `high`, `xhigh` above about 50 changed files. Medium reviews were measured shallow (handoff-to, "Effort is the review's depth"). | +| `effort` per PR | from the work | Codex `--effort`: `low`, `medium`, `high`, `xhigh` or `max`. A fix round: `medium`, `high` for a Critical or a cross-file finding. An independent review (step 7a): no effort; `--mode review` picks high or xhigh from the diff size and gates the depth (handoff-to, "Effort is the review's depth"). | | `mode` | from `scripts/mode.ts` | `worktree` or `unified`, step 2. | | `maxParallel` | 3 | Workers at once. The master is the bottleneck, not the workers. | | `post` | ask | Post the round summary and replies after the push (review-loop step 4), or hold them in the vault. | @@ -319,11 +319,15 @@ step 4 only when `post` is true. A round counts only when it fixed or answered real findings. When the reviewer bot has nothing left on a PR that still needs rounds, or a PR merged with fewer than three, run an independent read-only review: `bun -.claude/skills/babysit/scripts/review-brief.ts --run --pr --wt [--post-merge] [--swift]`, then -`steer.ts` with the printed file to a worker that did NOT fix this PR (Sol; Astra only for a risky cross-system PR, -by the model rule in step 1), at the run card's review `effort` (`high`, `xhigh` above about 50 changed files). A steer -keeps the session's effort, so spawn the reviewer at that effort; check its depth afterwards in `tools codex status ---name --json` (`cost.lastTurn`: `durationMs`, `toolCalls`, `tokens.reasoning`). Judge its findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --pr --wt [--post-merge] [--swift]`. It +prints the brief path and then the spawn line: `tools codex spawn --mode review --base origin/` (after a +merge `--base ^ --paths `) with the brief as `--prompt-file`, for a worker that did NOT +fix this PR (Sol; Astra only for a risky cross-system PR, by the model rule in step 1). Leave out `--effort`: the +harness picks high or xhigh from the diff size, gives the worker the manifest and the tier's contract, and gates the +depth after the turn (one steer with the unread files, then one rerun a tier higher as `bs--review-`, then +`shallow`). Read the result with `tools codex review-depth bs--review`; a `shallow` verdict is not a finished +review. Every run of the round is one command away: `tools codex reviews --pr ` (or `--label babysit`). Judge its +findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --dedicated-review`); after a merge they go to a follow-up PR from master (2026-10-14: #518 carried #509's after-merge findings). Never count a reply-only round, or a label, as a fix round: on 2026-10-14 the report said "#500 merged after 3 rounds" when GitHub showed two round summaries. diff --git a/.claude/skills/babysit/scripts/review-brief.ts b/.claude/skills/babysit/scripts/review-brief.ts index f0469ac96a..a8173ad733 100644 --- a/.claude/skills/babysit/scripts/review-brief.ts +++ b/.claude/skills/babysit/scripts/review-brief.ts @@ -2,10 +2,15 @@ /** * The brief for an independent read-only review round: when the reviewer bot has nothing left on a PR that still * needs rounds, or a PR merged with fewer than three. Its findings become the next fix round (`fill-brief --judge`) - * or, after a merge, a follow-up PR. Writes `/steer-review-.md` and prints the path for steer.ts. + * or, after a merge, a follow-up PR. Writes `/steer-review-.md`, prints its path, then the + * `tools codex spawn --mode review` line that starts the reviewer with it. * * bun review-brief.ts --run --pr 517 --wt [--base master] [--post-merge] [--swift] [--fresh] * + * The depth rules (read every file, probe each finding, the coverage line) are not in this brief: `--mode review` + * gives the worker its tier's contract and the diff manifest, and gates the depth after the turn + * (`tools codex reviews --pr ` lists every run). + * * `--fresh`: a PR opened minutes ago, reviewed next to the bots (the first round of a newly cut PR). * * Hand-edited copies of this text sent a reviewer to another PR's description (2026-10-14: a sed pass left @@ -46,13 +51,13 @@ export function reviewBrief({ pr, title, wt, base, scratch, postMerge, swift, fr fresh ? `- The PR has no answered threads yet. Do not wait for the bots and do not read their threads: review the code on your own.` : `- Earlier review threads were already answered; read them with \`tools github review ${pr} --llm\` so you do not report a finding that was fixed or deliberately declined (a declined one is fair game only with new evidence).`, - `- Check every claim: run the relevant tests (\`timeout 600 bun scripts/test.ts \`) or a small read-only probe under \`${scratch}\`, and paste the real output. A finding without evidence is a guess; say so.`, + `- Tests in this repository run as \`timeout 600 bun scripts/test.ts \`; put any read-only probe under \`${scratch}\`.`, ...(swift ? [ "- Swift: do not run `swift build` or `swift test` (the lead runs them); a Swift claim needs a code quote as evidence.", ] : []), - `- Write \`${join(wt, ".claude/work/babysit", `${pr}-review.md`)}\` (create the folder): one section per finding with severity (Critical, High, Medium, Low), file:line, the defect, the evidence, why it matters, and the smallest fix. End with a one-line verdict.`, + `- Write \`${join(wt, ".claude/work/babysit", `${pr}-review.md`)}\` (create the folder): the same numbered findings, \`Ran\`, \`Files reviewed\` and \`Not reviewed\` lines as your final message, with the evidence under each finding, then a one-line verdict.`, "- Change NO source file, commit nothing, post nothing to GitHub. Do NOT run `git checkout`, `git switch`, `bun install` or the hook installer. No app builds, installs, launches or relaunches. Another reviewer may read this same folder, so write only your own review file.", "", "Then stop with a report: the finding count by severity and the review file path, then RESULT / AT / CHANGED / VERIFY / OPEN.", @@ -60,6 +65,32 @@ export function reviewBrief({ pr, title, wt, base, scratch, postMerge, swift, fr return `${lines.join("\n")}\n`; } +export interface ReviewSpawnInput { + pr: number; + wt: string; + /** The PR's base branch, without `origin/`. */ + base: string; + briefPath: string; + /** After a merge the worktree sits at master: the manifest is the PR's files since its merge commit's parent. */ + postMerge?: { mergeCommit: string; files: string[] }; +} + +/** The reviewer's spawn line: `--mode review` with the base, so the harness computes the manifest and gates the depth. */ +export function reviewSpawnCommand({ pr, wt, base, briefPath, postMerge }: ReviewSpawnInput): string { + const target = postMerge + ? `--base ${postMerge.mergeCommit}^ --paths ${postMerge.files.join(" ")}` + : `--base origin/${base}`; + + return `tools codex spawn --mode review ${target} --name bs-${pr}-review --model gpt-6.1-sol --write ask --cwd ${wt} --label "babysit pr${pr} review" --prompt-file ${briefPath}`; +} + +interface PrView { + title: string; + baseRefName?: string; + mergeCommit?: { oid?: string } | null; + files?: Array<{ path: string }>; +} + if (import.meta.main) { const run = loadRun(); const pr = Number(flag("--pr")); @@ -72,17 +103,21 @@ if (import.meta.main) { process.exit(2); } - const view = Bun.spawnSync(["gh", "pr", "view", String(pr), "--repo", run.repo, "--json", "title"], { - stdout: "pipe", - stderr: "pipe", - }); + const view = Bun.spawnSync( + ["gh", "pr", "view", String(pr), "--repo", run.repo, "--json", "title,baseRefName,mergeCommit,files"], + { stdout: "pipe", stderr: "pipe" } + ); if (view.exitCode !== 0) { console.error(`✖ gh pr view ${pr}: ${view.stderr.toString().trim()}`); process.exit(1); } - const { title } = SafeJSON.parse(view.stdout.toString(), { strict: true }) as { title: string }; + const { title, baseRefName, mergeCommit, files } = SafeJSON.parse(view.stdout.toString(), { + strict: true, + }) as PrView; + const base = flag("--base") ?? baseRefName ?? "master"; + const postMerge = has("--post-merge"); const out = join(run.dir, `steer-review-${pr}.md`); await Bun.write( out, @@ -90,12 +125,29 @@ if (import.meta.main) { pr, title, wt, - base: flag("--base") ?? "master", + base, scratch: reviewScratch(run.dir, pr), - postMerge: has("--post-merge"), + postMerge, swift: has("--swift"), fresh: has("--fresh"), }) ); console.log(out); + + if (postMerge && !mergeCommit?.oid) { + console.error(`✖ PR #${pr} has no merge commit, so a post-merge review has no base.`); + process.exit(1); + } + + console.log( + reviewSpawnCommand({ + pr, + wt, + base, + briefPath: out, + ...(postMerge && mergeCommit?.oid + ? { postMerge: { mergeCommit: mergeCommit.oid, files: (files ?? []).map((file) => file.path) } } + : {}), + }) + ); } diff --git a/.claude/skills/babysit/scripts/scripts.test.ts b/.claude/skills/babysit/scripts/scripts.test.ts index 5d40f8780c..fc5636a6e1 100644 --- a/.claude/skills/babysit/scripts/scripts.test.ts +++ b/.claude/skills/babysit/scripts/scripts.test.ts @@ -26,7 +26,7 @@ import { appendEvent, readEvents, renderMarkdown } from "./ledger"; import { continueVersionPick } from "./pick-unified"; import { summarize, testEvidence } from "./plant"; import { hitsInAddedLines, probeAddedLines } from "./probe"; -import { reviewBrief } from "./review-brief"; +import { reviewBrief, reviewSpawnCommand } from "./review-brief"; import { loadRun, runPaths } from "./run"; import { trySwiftSlot } from "./swift-slot"; import { parseSwiftTargets, typecheckPackage } from "./swift-typecheck"; @@ -656,6 +656,35 @@ describe("independent review brief", () => { expect(fresh).not.toContain("already answered"); expect(reviewBrief(input)).toContain("found nothing more"); }); + + test("the reviewer starts in --mode review against the PR base, and the brief leaves depth to its contract", () => { + const input = { + pr: 498, + title: "t", + wt: "/w", + base: "feat/parent", + scratch: "/s", + postMerge: false, + swift: false, + }; + expect(reviewBrief(input)).not.toContain("A finding without evidence is a guess"); + expect(reviewBrief(input)).toContain("`Files reviewed` and `Not reviewed` lines"); + + expect( + reviewSpawnCommand({ pr: 498, wt: "/w", base: "feat/parent", briefPath: "/run/steer-review-498.md" }) + ).toBe( + 'tools codex spawn --mode review --base origin/feat/parent --name bs-498-review --model gpt-6.1-sol --write ask --cwd /w --label "babysit pr498 review" --prompt-file /run/steer-review-498.md' + ); + expect( + reviewSpawnCommand({ + pr: 498, + wt: "/w", + base: "master", + briefPath: "/b.md", + postMerge: { mergeCommit: "abc123", files: ["src/a.ts", "src/b.ts"] }, + }) + ).toContain("--mode review --base abc123^ --paths src/a.ts src/b.ts --name bs-498-review"); + }); }); describe("planted-regression summary", () => { test("reads bun counts and the XCTest total, and says so when there is none", () => { diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index 9d01c1c762..f7e21e794f 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.115", + "version": "1.0.116", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/handoff-to/SKILL.md b/plugins/genesis-tools/skills/handoff-to/SKILL.md index dea9c84987..98793c348f 100644 --- a/plugins/genesis-tools/skills/handoff-to/SKILL.md +++ b/plugins/genesis-tools/skills/handoff-to/SKILL.md @@ -84,18 +84,25 @@ Set a model explicitly; an Astra parent should not make every worker Astra. | Mechanical extraction, formatting, simple lookups, or a fully specified small edit | `gpt-6-luna` | low | | Bounded repository exploration, tracing an established call path, or gathering evidence | `gpt-6-luna` | medium | | Normal implementation and reproducible debugging | `gpt-6.1-sol` | medium | -| Code review of a PR or branch | `gpt-6.1-sol` | high; xhigh above about 50 changed files | +| Code review of a PR or branch | `gpt-6.1-sol` | `--mode review --base `: high up to 15 files and 800 lines, xhigh above | | Difficult cross-system reasoning, ambiguous failures, or an evidence-backed escalation | `gpt-6-astra` | high | `gpt-6.1-sol` accepts `low` to `max` effort only; `none` and `minimal` return an error, as on Astra. -**Effort is the review's depth: set it from the diff size, never leave a review at medium.** Measured on +**Effort is the review's depth: spawn every Codex review with `--mode review --base `.** Measured on 2026-10-22 with nine Sol reviews at `medium`: 1 to 4 minutes each, 8 to 26 tool calls, and 358 to 2,399 reasoning tokens, also for a 125-file, 6,200-line PR. The findings were real, but no worker could have read -every file. Pass `--effort high` for a review, `xhigh` above about 50 changed files, `max` only for a risky -cross-system PR, and tell the worker in the brief to read every changed file and to end with `Files reviewed: - of `. Check the depth afterwards: `tools codex status --name --json` carries the turn's -`durationMs`, `toolCalls` and `tokens.reasoning` under `cost.lastTurn`. +every file. `--mode review` now does the depth work the brief used to ask for: it computes the manifest +(`git diff --numstat ...HEAD`), gives the worker its tier's review contract (read every hunk, callers and +tests, probes, the `Files reviewed: n of N` line), picks the tier from the diff size when `--effort` is omitted +(high up to about 15 files and 800 lines, xhigh above; `max` only when you name it for a risky cross-system PR; +`low` and `medium` need `--allow-shallow`), and proposes a split by folder above about 60 files (`--paths`). +After each turn it gates the depth (coverage against the manifest, reasoning tokens per 100 changed lines, +finding shape): a miss steers the same session once with the unread files, a second miss reruns one tier higher +in a new session (`-`), and after that the review is `shallow`, never done. The brief names the +milestone, the PR and the negative constraints; it no longer restates depth rules. See every review run with +`tools codex reviews [--since 24h] [--pr ] [--label ] [--verdict shallow]`, and one in depth with +`tools codex review-depth `. Astra is the escalation tier, not the default for exploration. Keep the cheaper worker's findings and failed verification when escalating. If a model is unavailable, disclose the fallback and select the nearest suitable tier instead of silently choosing Astra. diff --git a/plugins/genesis-tools/skills/handoff-to/references/codex.md b/plugins/genesis-tools/skills/handoff-to/references/codex.md index 4961a0b639..87208d6cc2 100644 --- a/plugins/genesis-tools/skills/handoff-to/references/codex.md +++ b/plugins/genesis-tools/skills/handoff-to/references/codex.md @@ -15,8 +15,12 @@ The `tools codex spawn` command accepts an explicit model string. Availability s depends on the selected account and backend. Verify the recorded model after dispatch; do not treat a requested model or the parent's config as proof of what ran. Code-review workers should explicitly select Sol; `review_model` does not route arbitrary workers. -Give a review `--effort high`, or `xhigh` above about 50 changed files: at `medium` a review spends about -two thousand reasoning tokens however large the diff is (parent skill, "Effort is the review's depth"). +Spawn a review with `--mode review --base ` (or let it read the branch's PR target or upstream) and omit +`--effort`: the harness picks high or xhigh from the diff size, injects the manifest and the tier's contract, and +gates the depth after each turn (parent skill, "Effort is the review's depth"). At `medium` a review spent about +two thousand reasoning tokens however large the diff was, so `low` and `medium` need `--allow-shallow`. Every run +lands in `~/.genesis-tools/codex/reviews.jsonl`: `tools codex reviews` lists them, `tools codex review-depth ` +shows one. ## Choose the orchestration layer @@ -116,7 +120,7 @@ tools codex spawn --name --account work \ Named login defaults to a new vault-owned grant. `tools codex login work`, `tools ai codex login work`, and `tools ai accounts login work --provider codex` use one core. `--broker` is a compatibility alias. Explicit `--auth-file`/`--home` and `--import-native` are native-file opt-ins, not worker-startup repairs. Do not run login, import or migration without authorization; stop and report auth failures. -Other flags: `--model` / `--effort`, `--mode review|task`, `--session ` when no parent session can be discovered (resolution order: the explicit flag, then `$GENESIS_AGENTS_SESSION`, then `$GT_RENDEZVOUS_SESSION`, and only then the host session id of whoever runs the spawn — Claude Code, Codex or grok. An assigned swarm always outranks an inherited host id, so a nested worker cannot re-parent its children), `--no-agents` to disable the bus (do not — the bus is the point), `--project ` to set the thread's app-server project (without it the project that owns the cwd is used, and a worktree falls back to its main checkout). ⚠️ Codex Desktop ignores that id while its `localProjectTaskMembership` flag is off: it groups by cwd, and it sees a worker's thread only after it re-reads its thread list (`src/codex/README.md`). +Other flags: `--model` / `--effort`, `--mode review|task` (`review` with `--base `, `--allow-shallow`, `--paths `), `--session ` when no parent session can be discovered (resolution order: the explicit flag, then `$GENESIS_AGENTS_SESSION`, then `$GT_RENDEZVOUS_SESSION`, and only then the host session id of whoever runs the spawn — Claude Code, Codex or grok. An assigned swarm always outranks an inherited host id, so a nested worker cannot re-parent its children), `--no-agents` to disable the bus (do not — the bus is the point), `--project ` to set the thread's app-server project (without it the project that owns the cwd is used, and a worktree falls back to its main checkout). ⚠️ Codex Desktop ignores that id while its `localProjectTaskMembership` flag is off: it groups by cwd, and it sees a worker's thread only after it re-reads its thread list (`src/codex/README.md`). Worker records live in `~/.genesis-tools/codex/sessions/.*` (event log, metadata and daemon log). With `--account`, the worker stays bound to that GenesisTools account; without it, native authentication follows the effective home. Worker event logs are separate from native conversation history. From 8ca69d71b1bb5ba4e3d54a0ef4cd8fc8ebf40045 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:14:46 +0200 Subject: [PATCH 010/251] feat(codex): tools codex reviews import backfills the review ledger from sessions that ran before it, with the verdict their coverage line implies and a manifest up to the commit the review saw --- src/codex/README.md | 8 + src/codex/commands/reviews.ts | 85 ++++--- src/codex/lib/review/import.ts | 335 ++++++++++++++++++++++++++++ src/codex/lib/review/ledger.ts | 24 +- src/codex/lib/review/prepare.ts | 29 ++- src/codex/lib/review/report.ts | 16 +- src/codex/lib/review/review.test.ts | 177 ++++++++++++++- src/codex/lib/review/types.ts | 7 +- 8 files changed, 627 insertions(+), 54 deletions(-) create mode 100644 src/codex/lib/review/import.ts diff --git a/src/codex/README.md b/src/codex/README.md index d8c9990537..499a8a632a 100644 --- a/src/codex/README.md +++ b/src/codex/README.md @@ -293,8 +293,16 @@ Every review start, review turn and gate decision is one line of `~/.genesis-too tools codex reviews [--since 24h] [--pr ] [--label ] [--effort ] [--verdict pass|steered|escalated|shallow|open] [--name ] [--json] tools codex review-depth [--json] # one review: manifest, spend, coverage, reasoning per 100 lines, verdict, gate history tools codex review-depth --base # the same checks for a session spawned without --mode review +tools codex reviews import [--base ] [--no-cwd-from-session] [--json] ``` +`reviews import` backfills sessions that ran before the ledger: a start line, one turn line per booked turn of the +worker cost ledger, and a gate line marked `imported: true`. Its verdict comes from the final agent message (from the +session's event log): `pass` for `Files reviewed: n of n`, `shallow` for fewer, `imported` with no such line. The +manifest is `...` in the session's cwd, so fix commits made after +the review do not count; with no base (no `--base`, no PR target, no upstream) or a removed cwd its counts are null. +A session already in the ledger is skipped, so a repeated import adds nothing. + `login` and `usage` are doors onto the provider-neutral account core: the same code runs behind `tools ai accounts login --provider codex` and `tools ai usage --provider codex`. `tools ai accounts discover --provider codex` lists every `~/.codex*` profile on the machine and `--bind` turns the unbound ones into accounts. Sessions are read-only by default. For implementation work, use `--write ask` for supervised approvals or diff --git a/src/codex/commands/reviews.ts b/src/codex/commands/reviews.ts index cfa94646ec..a8cce6488f 100644 --- a/src/codex/commands/reviews.ts +++ b/src/codex/commands/reviews.ts @@ -3,7 +3,6 @@ import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { formatDuration, formatTokens } from "@genesiscz/utils/format"; import { logger, out } from "@genesiscz/utils/logger"; -import { isObject } from "@genesiscz/utils/object"; import * as p from "@genesiscz/utils/prompts/p"; import { createBoxTable, formatDotStatus, renderCliHeader, truncateDisplay } from "@genesiscz/utils/table"; import { @@ -15,6 +14,7 @@ import { import type { Command } from "commander"; import pc from "picocolors"; import { checkReview, type ReviewGateChecks } from "../lib/review/gate"; +import { importReviewSessions, lastAgentMessage } from "../lib/review/import"; import { type ReviewGateRecord, type ReviewLedgerRecord, @@ -157,7 +157,7 @@ async function runReviews(options: ReviewsOptions): Promise { table.push([ truncateDisplay(review.label ? `${review.name} ${pc.dim(review.label)}` : review.name, 36), review.pr === undefined ? (review.branch ? truncateDisplay(review.branch, 18) : "—") : `#${review.pr}`, - `${review.files} (${review.lines}L)`, + review.files === null ? "—" : `${review.files} (${review.lines}L)`, review.effort, formatDuration(review.durationMs), String(review.toolCalls), @@ -178,6 +178,47 @@ async function runReviews(options: ReviewsOptions): Promise { out.println(pc.dim(`One review in depth: ${toolCommand("codex review-depth")} `)); } +interface ImportOptions { + base?: string; + cwdFromSession: boolean; + json?: boolean; +} + +async function runImport(names: string[], options: ImportOptions): Promise { + const results = await importReviewSessions({ + names, + ...(options.base ? { base: options.base } : {}), + cwdFromSession: options.cwdFromSession, + }); + + if (options.json) { + out.result({ ledger: reviewLedgerPath(), results }); + return; + } + + const table = createBoxTable(["NAME", "STATUS", "VERDICT", "MANIFEST", "NOTES"]); + + for (const result of results) { + table.push([ + result.name, + result.status, + result.verdict ? verdictCell(result.verdict) : "—", + result.manifest + ? `${result.manifest.files} files, ${result.manifest.lines}L (${result.manifest.base}...${result.manifest.head.slice(0, 10)})` + : "—", + truncateDisplay(result.notes.join("; "), 70), + ]); + } + + out.println(table.toString()); + const imported = results.filter((result) => result.status === "imported").length; + out.println(`${imported} imported, ${results.length - imported} not, into ${reviewLedgerPath()}`); + + if (results.some((result) => result.status === "missing")) { + process.exitCode = 1; + } +} + interface DepthOptions { base?: string; json?: boolean; @@ -218,31 +259,6 @@ function checksLines(checks: ReviewGateChecks): string[] { ]; } -/** The last agent message of the session's own thread, from its event log. */ -async function lastAgentMessage(store: CodexSessionStore, meta: CodexSessionMeta): Promise { - const events = await store.readEvents(meta.name); - - for (const event of events.toReversed()) { - const params = event.params; - - if (event.method !== "item/completed" || !isObject(params) || !isObject(params.item)) { - continue; - } - - const { item, threadId } = params; - - if ( - item.type === "agentMessage" && - typeof item.text === "string" && - (threadId === undefined || threadId === meta.threadId) - ) { - return item.text; - } - } - - return ""; -} - /** A session spawned without `--mode review`: the gate's checks against a manifest computed now from `--base`. */ async function adHocDepth(store: CodexSessionStore, meta: CodexSessionMeta, options: DepthOptions): Promise { if (!options.base) { @@ -357,7 +373,7 @@ async function runReviewDepth(name: string, options: DepthOptions): Promise", "Only reviews active since: a duration back (24h, 7d), HH:MM today, or a date") @@ -371,6 +387,19 @@ export function registerReviewsCommands(program: Command): void { await runReviews(options); }); + reviews + .command("import ") + .description( + "Backfill the ledger from codex sessions that ran before it: start, turns from the cost ledger, and a gate line whose verdict the final Files reviewed line implies" + ) + .option("--base ", "The base of every named session; default each branch's PR target, else its upstream") + .option("--cwd-from-session", "Read the manifest in each session's own cwd (the default)", true) + .option("--no-cwd-from-session", "Leave every manifest null") + .option("--json", "Emit the import results as JSON") + .action(async (names: string[], options: ImportOptions) => { + await runImport(names, options); + }); + program .command("review-depth ") .description( diff --git a/src/codex/lib/review/import.ts b/src/codex/lib/review/import.ts new file mode 100644 index 0000000000..088eb2c03c --- /dev/null +++ b/src/codex/lib/review/import.ts @@ -0,0 +1,335 @@ +import { existsSync } from "node:fs"; +import { readWorkerCostRows, type WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import { isReviewEffort, manifestChangedLines, type ReviewManifest } from "@genesiscz/utils/worker/contract"; +import { type CodexSessionMeta, CodexSessionStore } from "../store"; +import { parseReviewReport, REASONING_FLOOR_PER_100_LINES, reasoningPer100Lines } from "./gate"; +import { + appendReviewRecord, + type ReviewLedgerBase, + type ReviewLedgerRecord, + readReviewRecords, + reviewLedgerPath, +} from "./ledger"; +import { buildManifest, type ChosenBase, chooseBase, defaultReviewGitReaders, type ReviewGitReaders } from "./prepare"; +import type { CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-import" }); + +/** The last agent message of the session's own thread, from its event log; "" when it has none. */ +export async function lastAgentMessage(store: CodexSessionStore, meta: CodexSessionMeta): Promise { + const events = await store.readEvents(meta.name); + + for (const event of events.toReversed()) { + const params = event.params; + + if (event.method !== "item/completed" || !isObject(params) || !isObject(params.item)) { + continue; + } + + const { item, threadId } = params; + + if ( + item.type === "agentMessage" && + typeof item.text === "string" && + (threadId === undefined || threadId === meta.threadId) + ) { + return item.text; + } + } + + return ""; +} + +/** Everything an import reads; injected so tests need no codex, no git and no network. */ +export interface ReviewImportReaders { + readMeta(name: string): Promise; + /** The session's booked turns from the worker cost ledger, oldest first. */ + costRows(meta: CodexSessionMeta): WorkerCostRow[]; + lastAgentMessage(meta: CodexSessionMeta): Promise; + cwdExists(path: string): boolean; + git: ReviewGitReaders; + /** The review ledger as it is now, for the already-imported check. */ + ledgerRecords(): ReviewLedgerRecord[]; +} + +export function defaultReviewImportReaders(ledgerPath = reviewLedgerPath()): ReviewImportReaders { + const store = new CodexSessionStore(); + + return { + readMeta: async (name) => store.readMeta(name), + costRows: (meta) => + readWorkerCostRows() + .filter( + (row) => + row.backend === "codex" && + row.name === meta.name && + (!meta.threadId || !row.sessionId || row.sessionId === meta.threadId) + ) + .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)), + lastAgentMessage: (meta) => lastAgentMessage(store, meta), + cwdExists: (path) => existsSync(path), + git: defaultReviewGitReaders(), + ledgerRecords: () => readReviewRecords(ledgerPath).records, + }; +} + +export interface ReviewImportManifest { + base: string; + /** The commit the manifest ends at: the last one before the session started, else HEAD. */ + head: string; + files: number; + lines: number; +} + +export interface ReviewImportResult { + name: string; + status: "imported" | "skipped" | "missing"; + verdict?: ReviewVerdict; + manifest: ReviewImportManifest | null; + /** Why something is missing or approximate, in one sentence per fact. */ + notes: string[]; +} + +export interface ReviewImportInput { + names: string[]; + /** One base for every named session; without it the PR target or upstream of each session's branch. */ + base?: string; + /** Read the manifest in the session's own cwd (default). False leaves every manifest null. */ + cwdFromSession?: boolean; + readers?: ReviewImportReaders; + ledgerPath?: string; +} + +interface ManifestOutcome { + manifest: ReviewManifest | null; + chosen: ChosenBase | null; + branch: string | null; + head: string | null; + notes: string[]; +} + +async function importManifest(meta: CodexSessionMeta, input: ReviewImportInput, git: ReviewGitReaders) { + const none = (note: string): ManifestOutcome => ({ + manifest: null, + chosen: null, + branch: null, + head: null, + notes: [note], + }); + + if (input.cwdFromSession === false) { + return none("manifest skipped (--no-cwd-from-session): file and line counts are null"); + } + + const cwd = meta.cwd; + const branch = await git.currentBranch(cwd); + let chosen: ChosenBase; + + try { + chosen = await chooseBase({ cwd, ...(input.base ? { base: input.base } : {}), readers: git, branch }); + } catch (err) { + return none(`no base: ${err instanceof Error ? err.message : String(err)} File and line counts are null.`); + } + + // The worktree may have gained fix commits since the review: diff up to the commit the review saw. + const seen = await git.commitBefore(cwd, meta.startedAt); + const notes = seen + ? [ + `manifest ${chosen.ref}...${seen.slice(0, 10)} (the last commit before the session started at ${meta.startedAt})`, + ] + : [`no commit before ${meta.startedAt}; the manifest runs to HEAD and may include later commits`]; + const head = seen ?? "HEAD"; + const manifest = buildManifest(chosen.ref, await git.numstat(cwd, chosen.ref, undefined, head)); + return { manifest, chosen, branch, head, notes } satisfies ManifestOutcome; +} + +function spentOf(rows: readonly WorkerCostRow[]): CodexReviewSpent { + return { + turns: rows.length, + durationMs: rows.reduce((sum, row) => sum + (row.durationMs ?? 0), 0), + toolCalls: rows.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0), + reasoningTokens: rows.reduce((sum, row) => sum + (row.tokens?.reasoning ?? 0), 0), + outputTokens: rows.reduce((sum, row) => sum + (row.tokens?.output ?? 0), 0), + costUsd: rows.some((row) => row.cost.apiEquivalentUsd === null) + ? null + : rows.reduce((sum, row) => sum + (row.cost.apiEquivalentUsd ?? 0), 0), + }; +} + +/** The verdict the final message's coverage line implies: n of n passes, fewer is shallow, none is `imported`. */ +function impliedVerdict( + coverage: { reviewed: number; total: number } | null, + manifest: ReviewManifest | null +): { verdict: ReviewVerdict; reason: string } { + if (!coverage) { + return { + verdict: "imported", + reason: "imported from a session that ran before the ledger; its final message has no Files reviewed line", + }; + } + + const mismatch = + manifest && coverage.total !== manifest.files.length + ? `; the reviewed manifest has ${manifest.files.length} files` + : ""; + + if (coverage.reviewed >= coverage.total) { + return { + verdict: "pass", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}${mismatch}`, + }; + } + + return { + verdict: "shallow", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, ${coverage.total - coverage.reviewed} unread${mismatch}`, + }; +} + +async function importOne(name: string, input: ReviewImportInput, readers: ReviewImportReaders, ledgerPath: string) { + const meta = await readers.readMeta(name); + + if (!meta) { + return { + name, + status: "missing", + manifest: null, + notes: ["no codex session record with that name"], + } satisfies ReviewImportResult; + } + + if (readers.ledgerRecords().some((record) => record.name === name)) { + return { + name, + status: "skipped", + manifest: null, + notes: ["already in the review ledger"], + } satisfies ReviewImportResult; + } + + const outcome = readers.cwdExists(meta.cwd) + ? await importManifest(meta, input, readers.git) + : { + manifest: null, + chosen: null, + branch: null, + head: null, + notes: [`${meta.cwd} is gone: file and line counts are null`], + }; + const { manifest, chosen } = outcome; + const rows = readers.costRows(meta); + const report = await readers.lastAgentMessage(meta); + const parsed = parseReviewReport(report); + const notes = [...outcome.notes]; + + if (rows.length === 0) { + notes.push("no booked turns in the worker cost ledger"); + } + + if (!report) { + notes.push("no final agent message in the session's event log"); + } + + const effort = meta.effort ?? "default"; + const base: ReviewLedgerBase = { + v: 1, + ts: meta.startedAt, + name, + chain: name, + ...(meta.label ? { label: meta.label } : {}), + ...(chosen?.pr ? { pr: chosen.pr.number } : {}), + ...(outcome.branch ? { branch: outcome.branch } : {}), + base: chosen?.ref ?? input.base ?? null, + files: manifest ? manifest.files.length : null, + lines: manifest ? manifestChangedLines(manifest) : null, + effort, + imported: true, + ...(outcome.head ? { head: outcome.head } : {}), + }; + const at = (ts: string) => ({ ...base, ts }); + + appendReviewRecord( + base, + { kind: "start", cwd: meta.cwd, effortSource: "imported", allowShallow: false }, + ledgerPath + ); + + rows.forEach((row, index) => { + appendReviewRecord( + at(row.ts), + { + kind: "turn", + turn: row.turn ?? index + 1, + outcome: row.outcome ?? "completed", + model: row.model, + durationMs: row.durationMs, + toolCalls: row.toolCalls ?? 0, + reasoningTokens: row.tokens?.reasoning ?? null, + outputTokens: row.tokens?.output ?? null, + costUsd: row.cost.apiEquivalentUsd, + // Only the final message survives, so only the last turn carries a coverage line. + coverage: index === rows.length - 1 ? parsed.coverage : null, + }, + ledgerPath + ); + }); + + const spent = spentOf(rows); + const { verdict, reason } = impliedVerdict(parsed.coverage, manifest); + appendReviewRecord( + at(rows.at(-1)?.ts ?? meta.lastEventAt), + { + kind: "gate", + step: 0, + verdict, + reason, + coverage: { + reviewed: parsed.coverage?.reviewed ?? null, + total: parsed.coverage?.total ?? manifest?.files.length ?? 0, + }, + reasoningPer100Lines: manifest ? reasoningPer100Lines(spent.reasoningTokens, manifest) : null, + floor: isReviewEffort(effort) ? REASONING_FLOOR_PER_100_LINES[effort] : 0, + findings: parsed.findings.length, + malformedFindings: parsed.findings.filter((finding) => !finding.ok).length, + unread: parsed.notReviewed.map((entry) => entry.path).slice(0, 40), + spent, + }, + ledgerPath + ); + log.info({ name, verdict, files: base.files, base: base.base, head: base.head }, "review imported"); + + return { + name, + status: "imported", + verdict, + manifest: + manifest && chosen + ? { + base: chosen.ref, + head: outcome.head ?? "HEAD", + files: manifest.files.length, + lines: manifestChangedLines(manifest), + } + : null, + notes, + } satisfies ReviewImportResult; +} + +/** + * Backfills the review ledger from codex sessions that ran before it existed: one start line, one turn line per + * booked turn, and one gate line whose verdict the final message's coverage line implies. A session already in the + * ledger is skipped, so a second import of the same names adds nothing. + */ +export async function importReviewSessions(input: ReviewImportInput): Promise { + const ledgerPath = input.ledgerPath ?? reviewLedgerPath(); + const readers = input.readers ?? defaultReviewImportReaders(ledgerPath); + const results: ReviewImportResult[] = []; + + for (const name of [...new Set(input.names)]) { + results.push(await importOne(name, input, readers, ledgerPath)); + } + + return results; +} diff --git a/src/codex/lib/review/ledger.ts b/src/codex/lib/review/ledger.ts index 8d5b5e30f9..5ff7fd99d2 100644 --- a/src/codex/lib/review/ledger.ts +++ b/src/codex/lib/review/ledger.ts @@ -4,7 +4,7 @@ import { readJsonlRows } from "@genesiscz/utils/jsonl"; import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; import { logger } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; -import { manifestChangedLines, type ReviewEffort } from "@genesiscz/utils/worker/contract"; +import { manifestChangedLines } from "@genesiscz/utils/worker/contract"; import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; const log = logger.child({ component: "codex:review-ledger" }); @@ -14,7 +14,7 @@ export function reviewLedgerPath(): string { return join(codexRoot(), "reviews.jsonl"); } -interface ReviewLedgerBase { +export interface ReviewLedgerBase { v: 1; ts: string; name: string; @@ -22,16 +22,22 @@ interface ReviewLedgerBase { label?: string; pr?: number; branch?: string; - base: string; - files: number; - lines: number; - effort: ReviewEffort; + /** Null only on an imported review whose base or worktree could not be found. */ + base: string | null; + files: number | null; + lines: number | null; + /** A review tier; an imported session keeps whatever effort it ran at. */ + effort: string; + /** Backfilled by `tools codex reviews import` from a session that ran before the ledger existed. */ + imported?: true; + /** Imported: the commit the review is taken to have read (the last one before the session started). */ + head?: string; } export interface ReviewStartRecord extends ReviewLedgerBase { kind: "start"; cwd: string; - effortSource: CodexReviewConfig["effortSource"]; + effortSource: CodexReviewConfig["effortSource"] | "imported"; allowShallow: boolean; parent?: string; } @@ -94,7 +100,7 @@ export function reviewLedgerBase(input: { name: string; review: CodexReviewConfi * review turn it describes. The failure is logged with the record's kind and session. */ export function appendReviewRecord( - base: ReturnType, + base: ReviewLedgerBase, entry: ReviewLedgerEntry, path = reviewLedgerPath() ): ReviewLedgerRecord | null { @@ -117,7 +123,7 @@ function isLedgerRecord(value: unknown): value is ReviewLedgerRecord { (value.kind === "start" || value.kind === "turn" || value.kind === "gate") && typeof value.name === "string" && typeof value.ts === "string" && - typeof value.base === "string" + (typeof value.base === "string" || value.base === null) ); } diff --git a/src/codex/lib/review/prepare.ts b/src/codex/lib/review/prepare.ts index 4c070f3974..f161f9eb43 100644 --- a/src/codex/lib/review/prepare.ts +++ b/src/codex/lib/review/prepare.ts @@ -26,8 +26,10 @@ export interface ReviewGitReaders { /** The newest PR of the branch; null when there is none or the host cannot answer. */ pullRequest(cwd: string, branch: string): Promise; upstream(cwd: string, branch: string): Promise; - /** `git diff --numstat ...HEAD`, limited to `paths` when given. */ - numstat(cwd: string, base: string, paths?: string[]): Promise; + /** `git diff --numstat ...` (head defaults to HEAD), limited to `paths` when given. */ + numstat(cwd: string, base: string, paths?: string[], head?: string): Promise; + /** The newest commit of HEAD's history committed at or before `at` (ISO time); null when there is none. */ + commitBefore(cwd: string, at: string): Promise; } export function defaultReviewGitReaders(): ReviewGitReaders { @@ -60,10 +62,20 @@ export function defaultReviewGitReaders(): ReviewGitReaders { return lookup.pr; }, upstream: (cwd, branch) => createGit({ cwd }).getTrackingBranch(branch), - async numstat(cwd, base, paths) { + async numstat(cwd, base, paths, head = "HEAD") { const git = createGit({ cwd }); - const mergeBase = await git.mergeBase(base, "HEAD"); - return git.numstat({ from: mergeBase, to: "HEAD", ...(paths?.length ? { paths } : {}) }); + const mergeBase = await git.mergeBase(base, head); + return git.numstat({ from: mergeBase, to: head, ...(paths?.length ? { paths } : {}) }); + }, + async commitBefore(cwd, at) { + const result = await createGit({ cwd }).executor.exec([ + "log", + "-1", + "--format=%H", + `--before=${at}`, + "HEAD", + ]); + return result.success && result.stdout ? result.stdout : null; }, }; } @@ -87,14 +99,17 @@ export interface PreparedReview { warnings: string[]; } -interface ChosenBase { +export interface ChosenBase { ref: string; source: ReviewBaseSource; detail: string; pr?: PrInfo; } -async function chooseBase(input: PrepareReviewInput & { readers: ReviewGitReaders; branch: string | null }) { +/** The base ladder of `--mode review`: the flag (verified), else the branch's PR target, else its upstream. */ +export async function chooseBase( + input: Pick & { readers: ReviewGitReaders; branch: string | null } +): Promise { const { readers, cwd, branch } = input; if (input.base) { diff --git a/src/codex/lib/review/report.ts b/src/codex/lib/review/report.ts index 4ed8837a57..e5b4ab5a80 100644 --- a/src/codex/lib/review/report.ts +++ b/src/codex/lib/review/report.ts @@ -1,6 +1,5 @@ import { parseSince } from "@genesiscz/utils/date"; import { parseDuration } from "@genesiscz/utils/format"; -import type { ReviewEffort } from "@genesiscz/utils/worker/contract"; import type { ReviewLedgerRecord } from "./ledger"; import type { ReviewVerdict } from "./types"; @@ -11,10 +10,11 @@ export interface ReviewSummary { label?: string; pr?: number; branch?: string; - base: string; - files: number; - lines: number; - effort: ReviewEffort; + base: string | null; + files: number | null; + lines: number | null; + effort: string; + imported?: true; startedAt: string; lastAt: string; turns: number; @@ -65,6 +65,7 @@ function emptySummary(record: ReviewLedgerRecord): ReviewSummary { files: record.files, lines: record.lines, effort: record.effort, + ...(record.imported ? { imported: true as const } : {}), startedAt: record.ts, lastAt: record.ts, turns: 0, @@ -120,7 +121,10 @@ export function summarizeReviews(records: readonly ReviewLedgerRecord[]): Review summary.coverage = `${record.coverage.reviewed}/${record.coverage.total}`; } - summary.reasoningPer100Lines = Math.round((summary.reasoningTokens * 100) / Math.max(1, summary.lines)); + summary.reasoningPer100Lines = + summary.lines === null + ? null + : Math.round((summary.reasoningTokens * 100) / Math.max(1, summary.lines)); continue; } diff --git a/src/codex/lib/review/review.test.ts b/src/codex/lib/review/review.test.ts index c30a91404a..d6e9174932 100644 --- a/src/codex/lib/review/review.test.ts +++ b/src/codex/lib/review/review.test.ts @@ -2,14 +2,17 @@ import { describe, expect, test } from "bun:test"; import { appendFileSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; import type { NumstatEntry, PrInfo } from "@genesiscz/utils/git"; import { SafeJSON } from "@genesiscz/utils/json"; import type { ReviewManifest } from "@genesiscz/utils/worker/contract"; import { codexDriver } from "../driver"; import { spawnCodexSession } from "../spawn"; +import type { CodexSessionMeta } from "../store"; import { escalatedReviewName, markReviewShallow, recordReviewTurn } from "./controller"; import { checkReview, decideGate, parseReviewReport } from "./gate"; +import { importReviewSessions, type ReviewImportReaders } from "./import"; import { type ReviewLedgerRecord, readReviewRecords } from "./ledger"; import { prepareReview, type ReviewGitReaders } from "./prepare"; import { filterReviews, parseReviewSince, reviewTotals, summarizeReviews } from "./report"; @@ -25,6 +28,7 @@ function readers(options: { upstream?: string | null; numstat?: NumstatEntry[]; refs?: string[]; + commitBefore?: (at: string) => string | null; }): ReviewGitReaders & { calls: string[] } { const calls: string[] = []; return { @@ -35,10 +39,11 @@ function readers(options: { refExists: async (_cwd, ref) => (options.refs ?? ["origin/master"]).includes(ref), pullRequest: async () => options.pr ?? null, upstream: async () => options.upstream ?? null, - numstat: async (_cwd, base, paths) => { - calls.push(`numstat ${base}${paths ? ` ${paths.join(",")}` : ""}`); + numstat: async (_cwd, base, paths, head) => { + calls.push(`numstat ${base}${head ? `...${head}` : ""}${paths ? ` ${paths.join(",")}` : ""}`); return options.numstat ?? [entry("src/a.ts", 40, 2)]; }, + commitBefore: async (_cwd, at) => (options.commitBefore ? options.commitBefore(at) : null), }; } @@ -566,3 +571,171 @@ describe("spawn guards", () => { ); }); }); + +function importedMeta(name: string, over: Partial = {}): CodexSessionMeta { + return { + name, + daemonPid: 0, + cwd: `/wt/${name}`, + sandbox: "read-only", + approvalPolicy: "never", + writePolicy: "deny", + status: "closed", + agentName: `codex_${name}`, + rendezvousSession: "s", + agentsEnabled: true, + startedAt: "2026-10-22T19:08:07.954Z", + lastEventAt: "2026-10-22T19:30:24.784Z", + codexVersion: "0.162.1", + pendingApprovals: {}, + effort: "medium", + label: `pr-split-review ${name}`, + threadId: `thread-${name}`, + ...over, + }; +} + +function costRow(name: string, turn: number, over: Partial = {}): WorkerCostRow { + return { + v: 1, + ts: `2026-10-22T19:1${turn}:00.000Z`, + backend: "codex", + name, + sessionId: `thread-${name}`, + turn, + model: "gpt-6.1-sol", + effort: "medium", + auth: "subscription", + account: null, + durationMs: 100_000, + toolCalls: 10, + tokens: { input: 1000, cacheRead: 0, cacheWrite: 0, output: 400, reasoning: 300 }, + cost: { + apiEquivalentUsd: 0.5, + basis: "catalog", + pricedAs: ["gpt-6.1-sol"], + longContextRequests: 0, + auth: "subscription", + billedUsd: 0, + claudeEquivalent: { estimate: true, opusModel: "o", sonnetModel: "s", opusUsd: 1, sonnetUsd: 0.5 }, + }, + cwd: `/wt/${name}`, + outcome: "completed", + ...over, + }; +} + +function importReaders(ledgerPath: string, options: { gone?: string[]; noBase?: boolean } = {}) { + const git = readers({ + pr: options.noBase ? null : PR, + numstat: [entry("src/a.ts", 30, 5), entry("src/b.ts", 10)], + commitBefore: (at) => (at.startsWith("2026-10-22") ? "abc1234567890" : null), + }); + const messages: Record = { + "rv-full": + "1. [High] src/a.ts:3 - bug. Scenario: x. Fix: y.\nRan: bun test\nFiles reviewed: 2 of 2\nNot reviewed: none", + "rv-part": "Findings: none\nRan: nothing\nFiles reviewed: 1 of 2\nNot reviewed: src/b.ts (time)", + "rv-gone": "a summary without the coverage line", + }; + const fake: ReviewImportReaders = { + readMeta: async (name) => (name in messages ? importedMeta(name) : null), + costRows: (meta) => + meta.name === "rv-part" ? [costRow(meta.name, 1), costRow(meta.name, 2)] : [costRow(meta.name, 1)], + lastAgentMessage: async (meta) => messages[meta.name] ?? "", + cwdExists: (path) => !(options.gone ?? []).some((name) => path.endsWith(name)), + git, + ledgerRecords: () => readReviewRecords(ledgerPath).records, + }; + return { fake, git }; +} + +describe("reviews import", () => { + test("backfills start, turns and a gate whose verdict the coverage line implies, diffing up to the commit the review saw", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake, git } = importReaders(ledgerPath, { gone: ["rv-gone"] }); + const results = await importReviewSessions({ + names: ["rv-full", "rv-part", "rv-gone", "rv-none", "rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + + expect(results.map((result) => [result.name, result.status, result.verdict ?? null])).toEqual([ + ["rv-full", "imported", "pass"], + ["rv-part", "imported", "shallow"], + ["rv-gone", "imported", "imported"], + ["rv-none", "missing", null], + ]); + expect(results[0].manifest).toEqual({ base: "origin/master", head: "abc1234567890", files: 2, lines: 45 }); + expect(results[2].manifest).toBeNull(); + expect(results[2].notes[0]).toContain("/wt/rv-gone is gone"); + expect(git.calls).toEqual(["numstat origin/master...abc1234567890", "numstat origin/master...abc1234567890"]); + + const records = readReviewRecords(ledgerPath).records; + expect(records.map((record) => `${record.name}:${record.kind}`)).toEqual([ + "rv-full:start", + "rv-full:turn", + "rv-full:gate", + "rv-part:start", + "rv-part:turn", + "rv-part:turn", + "rv-part:gate", + "rv-gone:start", + "rv-gone:turn", + "rv-gone:gate", + ]); + expect(records.every((record) => record.imported === true)).toBe(true); + expect(records[2]).toMatchObject({ + kind: "gate", + pr: 12, + files: 2, + lines: 45, + effort: "medium", + head: "abc1234567890", + }); + expect(records[6]).toMatchObject({ + kind: "gate", + reason: "imported: Files reviewed 1 of 2, 1 unread", + unread: ["src/b.ts"], + }); + expect(records[9]).toMatchObject({ + base: "origin/master", + files: null, + lines: null, + reasoningPer100Lines: null, + }); + + const summaries = summarizeReviews(records); + expect(summaries.find((review) => review.name === "rv-part")).toMatchObject({ + turns: 2, + toolCalls: 20, + costUsd: 1, + coverage: "1/2", + reasoningPer100Lines: 1333, + verdict: "shallow", + }); + expect(summaries.find((review) => review.name === "rv-gone")?.reasoningPer100Lines).toBeNull(); + + // A second import of the same names adds nothing. + const again = await importReviewSessions({ names: ["rv-full", "rv-part"], readers: fake, ledgerPath }); + expect(again.map((result) => result.status)).toEqual(["skipped", "skipped"]); + expect(readReviewRecords(ledgerPath).records).toHaveLength(10); + }); + + test("without a base or with --no-cwd-from-session the manifest is null and the note says why", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath, { noBase: true }); + const [derived] = await importReviewSessions({ names: ["rv-full"], readers: fake, ledgerPath }); + expect(derived.manifest).toBeNull(); + expect(derived.notes[0]).toContain("no base: --mode review needs --base "); + + const [skipped] = await importReviewSessions({ + names: ["rv-part"], + cwdFromSession: false, + readers: fake, + ledgerPath, + }); + expect(skipped.notes[0]).toContain("manifest skipped"); + expect(readReviewRecords(ledgerPath).records.filter((record) => record.kind === "gate")).toHaveLength(2); + }); +}); diff --git a/src/codex/lib/review/types.ts b/src/codex/lib/review/types.ts index bf630babc7..582038caba 100644 --- a/src/codex/lib/review/types.ts +++ b/src/codex/lib/review/types.ts @@ -1,7 +1,10 @@ import type { ReviewEffort, ReviewManifest } from "@genesiscz/utils/worker/contract"; -/** What the depth gate decided about one review turn. */ -export const REVIEW_VERDICTS = ["pass", "steered", "escalated", "shallow"] as const; +/** + * What the depth gate decided about one review turn. `imported` is never decided by the gate: it marks a review + * backfilled from a session that ran before the ledger and whose final message has no coverage line. + */ +export const REVIEW_VERDICTS = ["pass", "steered", "escalated", "shallow", "imported"] as const; export type ReviewVerdict = (typeof REVIEW_VERDICTS)[number]; /** Where the base ref came from; printed at spawn, because a derived base is a guess the caller must see. */ From db30b8d662cf950dd11d332f0f9ffe3d3ca2980d Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 00:27:29 +0200 Subject: [PATCH 011/251] fix(plugins, hooks): area rules forget only the compacting agent's areas, since a subagent's compaction wiped every agent and caused 59 of 60 repeats --- .claude-plugin/marketplace.json | 4 +- .../genesis-tools/.claude-plugin/plugin.json | 2 +- .../genesis-tools/hooks/area-rules.test.ts | 106 +++++++++++++- plugins/genesis-tools/hooks/area-rules.ts | 134 ++++++++++++++++-- 4 files changed, 234 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 199c3b2c86..83a659349d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.116", + "version": "1.0.117", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -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.116", + "version": "1.0.117", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index f7e21e794f..849dd8c208 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.116", + "version": "1.0.117", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/hooks/area-rules.test.ts b/plugins/genesis-tools/hooks/area-rules.test.ts index e8b365c5a2..9c15a42f33 100644 --- a/plugins/genesis-tools/hooks/area-rules.test.ts +++ b/plugins/genesis-tools/hooks/area-rules.test.ts @@ -2,7 +2,17 @@ import { afterAll, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { decide, frontmatterPaths, isBroadGlob, pathTokens, readRules, tsconfigAliases } from "./area-rules.ts"; +import { + agentTranscript, + compactedSince, + decide, + frontmatterPaths, + isBroadGlob, + markCompaction, + pathTokens, + readRules, + tsconfigAliases, +} from "./area-rules.ts"; const root = mkdtempSync(join(tmpdir(), "area-rules-")); mkdirSync(join(root, ".git")); @@ -135,3 +145,97 @@ describe("decide", () => { expect(decision).toEqual({ add: [], native: [] }); }); }); + +const row = (fields: Record) => `${JSON.stringify(fields)}\n`; + +describe("compaction", () => { + const transcripts = join(root, "transcripts"); + mkdirSync(join(transcripts, "s1", "subagents"), { recursive: true }); + const at = Date.parse("2026-10-13T18:12:20.000Z"); + const stamp = (offsetMs: number) => new Date(at + offsetMs).toISOString(); + + it("maps a subagent to its own transcript and another harness to none", () => { + const main = join(transcripts, "s1.jsonl"); + expect(agentTranscript({ transcript_path: main })).toBe(main); + expect(agentTranscript({ transcript_path: main, agent_id: "a1" })).toBe( + join(transcripts, "s1", "subagents", "agent-a1.jsonl") + ); + expect(agentTranscript({ transcript_path: "/x/.codex/sessions/rollout-1.jsonl" })).toBeUndefined(); + }); + + it("finds the agent's own boundary, and keeps state only when the transcript proves there was none", () => { + const compacted = join(transcripts, "compacted.jsonl"); + writeFileSync( + compacted, + row({ type: "user", timestamp: stamp(-600_000) }) + + row({ type: "system", subtype: "compact_boundary", timestamp: stamp(80) }) + + row({ type: "user", timestamp: stamp(5_000) }) + ); + const other = join(transcripts, "other.jsonl"); + writeFileSync( + other, + row({ type: "user", timestamp: stamp(-600_000) }) + row({ type: "user", timestamp: stamp(5_000) }) + ); + const older = join(transcripts, "older.jsonl"); + writeFileSync(older, row({ type: "system", subtype: "compact_boundary", timestamp: stamp(-3_600_000) })); + + expect(compactedSince(compacted, at)).toBe(true); + expect(compactedSince(other, at)).toBe(false); + expect(compactedSince(older, at)).toBe(false); + expect(compactedSince(join(transcripts, "missing.jsonl"), at)).toBe(true); + expect(compactedSince(undefined, at)).toBe(true); + }); + + it("marks every agent once and keeps the earliest compaction", () => { + const once = markCompaction({ main: ["a.md"], a1: ["b.md"] }, 1); + expect(markCompaction(once, 2)).toEqual({ + main: ["a.md"], + a1: ["b.md"], + "@compact:main": ["1"], + "@compact:a1": ["1"], + }); + }); + + it("a compaction re-adds the areas of the agent that compacted, and only of it (real hook processes)", async () => { + const mainSearch = { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "rg x src/du" }, + }; + const subSearch = { ...mainSearch, agent_id: "a1", tool_input: { command: "rg x src/utils/ai" } }; + + for (const compacting of ["subagent", "main"]) { + const session = `s-${compacting}`; + const main = join(transcripts, `${session}.jsonl`); + const sub = join(transcripts, session, "subagents", "agent-a1.jsonl"); + mkdirSync(join(transcripts, session, "subagents"), { recursive: true }); + for (const file of [main, sub]) { + writeFileSync(file, row({ type: "user", timestamp: new Date(Date.now() - 600_000).toISOString() })); + } + + const run = async (payload: Record) => { + const child = Bun.spawn(["bun", join(import.meta.dir, "area-rules.ts")], { + stdin: new TextEncoder().encode( + JSON.stringify({ session_id: session, transcript_path: main, cwd: root, ...payload }) + ), + stdout: "pipe", + env: { ...process.env, GENESIS_TOOLS_HOME: join(root, "home") }, + }); + const out = await new Response(child.stdout).text(); + await child.exited; + return [...out.matchAll(/Contents of \S+?([^/\s]+\/CLAUDE\.md|[^/\s]+\.md) \(area rules\)/g)].map( + (m) => m[1] + ); + }; + + expect(await run(mainSearch)).toEqual(["du/CLAUDE.md"]); + expect(await run(subSearch)).toEqual(["ai.md"]); + await run({ hook_event_name: "SessionStart", source: "compact" }); + const boundary = row({ type: "system", subtype: "compact_boundary", timestamp: new Date().toISOString() }); + writeFileSync(compacting === "main" ? main : sub, boundary, { flag: "a" }); + + expect(await run(mainSearch)).toEqual(compacting === "main" ? ["du/CLAUDE.md"] : []); + expect(await run(subSearch)).toEqual(compacting === "main" ? [] : ["ai.md"]); + } + }); +}); diff --git a/plugins/genesis-tools/hooks/area-rules.ts b/plugins/genesis-tools/hooks/area-rules.ts index 34b65f9c61..5083578957 100644 --- a/plugins/genesis-tools/hooks/area-rules.ts +++ b/plugins/genesis-tools/hooks/area-rules.ts @@ -8,13 +8,26 @@ // // PostToolUse on Grep, Glob and Bash: this hook finds the project paths the call touched and adds each matching // area file to the context once per agent. On Read, Write and Edit it only records the areas Claude Code loads by -// itself, so it does not add them a second time. SessionStart after a compaction forgets what was added, because -// the compaction summary does not carry the files. - -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +// itself, so it does not add them a second time. After a compaction the agent that compacted forgets what was +// added, because the compaction summary does not carry the files. + +import { + appendFileSync, + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { parseLenientJson } from "./agents-talk-hint"; +import { harnessOf } from "./harness"; import { lockWorktreesForToolUse } from "./worktree-lock"; const SafeJSON = JSON; @@ -22,9 +35,16 @@ const SafeJSON = JSON; /** A response spanning more areas than this is a broad search: only the areas its input names are added. */ const BROAD_SEARCH_AREAS = 2; const MAX_SCAN_CHARS = 200_000; +/** State key prefix: `@compact:` holds the time of a compaction that agent has not been checked against. */ +const COMPACT_MARK = "@compact:"; +/** How much of a transcript's end the compaction check reads. A boundary sat up to 670 KB before the next rows. */ +const TRANSCRIPT_TAIL_BYTES = 4_000_000; +/** A compact_boundary row was stamped at most 0.8 s before its SessionStart hook (146 compactions); the margin. */ +const COMPACT_SLACK_MS = 10_000; export interface HookPayload { session_id?: string; + transcript_path?: string; cwd?: string; hook_event_name?: string; source?: string; @@ -248,6 +268,98 @@ function writeState(sessionId: string, state: Record): void { writeFileSync(file, SafeJSON.stringify(state)); } +/** + * SessionStart `compact` names the session but not the agent that compacted: a subagent's compaction carries the + * parent's session id and transcript and no `agent_id` (the hook log of 2026-10-13 18:12, session 4578e02a). So + * every agent is marked here and checked against its own transcript at its next tool call. Wiping every agent + * caused 59 of the 60 repeated injections measured from 2026-10-13 to 2026-10-23. + */ +export function markCompaction(state: Record, now: number): Record { + const marked = { ...state }; + for (const agent of Object.keys(state)) { + if (!agent.startsWith(COMPACT_MARK) && !marked[`${COMPACT_MARK}${agent}`]) { + marked[`${COMPACT_MARK}${agent}`] = [String(now)]; + } + } + + return marked; +} + +/** The agent's own Claude Code transcript; undefined for another harness, whose transcript has no boundary rows. */ +export function agentTranscript(payload: HookPayload): string | undefined { + const transcript = payload.transcript_path; + if (!transcript || harnessOf({ transcript_path: transcript }) !== "claude") { + return undefined; + } + + if (!payload.agent_id) { + return transcript; + } + + return join(dirname(transcript), basename(transcript, ".jsonl"), "subagents", `agent-${payload.agent_id}.jsonl`); +} + +/** + * Whether the transcript holds a compaction at or after `since`. When the read tail does not reach back past + * `since`, or the file cannot be read, the answer is true: a repeated area costs context, a lost one costs rules. + */ +export function compactedSince(transcript: string | undefined, since: number): boolean { + if (!transcript) { + return true; + } + + let fd: number; + + try { + fd = openSync(transcript, "r"); + } catch { + // No transcript at this path: assume the compaction was this agent's. + return true; + } + + try { + const size = fstatSync(fd).size; + const length = Math.min(size, TRANSCRIPT_TAIL_BYTES); + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, size - length); + // The first line of a partial tail is cut; the rest are whole rows. + const start = length < size ? buffer.indexOf(10) + 1 : 0; + const from = since - COMPACT_SLACK_MS; + for (let at = buffer.indexOf('"compact_boundary"', start); at !== -1; ) { + const lineStart = buffer.lastIndexOf(10, at) + 1; + const lineEnd = buffer.indexOf(10, at); + const end = lineEnd === -1 ? length : lineEnd; + const row = parseRow(buffer.toString("utf8", Math.max(lineStart, start), end)); + if ( + row?.type === "system" && + row.subtype === "compact_boundary" && + Date.parse(String(row.timestamp)) >= from + ) { + return true; + } + + at = buffer.indexOf('"compact_boundary"', end); + } + + const firstStamp = Date.parse( + buffer.toString("utf8", start, Math.min(length, start + 64_000)).match(/"timestamp":"([^"]+)"/)?.[1] ?? "" + ); + return length < size && !(firstStamp <= from); + } finally { + closeSync(fd); + } +} + +function parseRow(line: string): Record | null { + try { + const row: unknown = SafeJSON.parse(line); + return isRecord(row) ? row : null; + } catch { + // A row cut by a concurrent append. + return null; + } +} + function textOf(value: unknown): string { return typeof value === "string" ? value : SafeJSON.stringify(value ?? ""); } @@ -322,8 +434,10 @@ if (import.meta.main) { } if (payload.hook_event_name === "SessionStart") { - if (payload.source === "compact" || payload.source === "clear") { + if (payload.source === "clear") { writeState(sessionId, {}); + } else if (payload.source === "compact") { + writeState(sessionId, markCompaction(readState(sessionId), Date.now())); } process.exit(0); @@ -337,9 +451,13 @@ if (import.meta.main) { const rules = readRules(root); const state = readState(sessionId); const agent = payload.agent_id ?? "main"; - const seen = state[agent] ?? []; + const mark = `${COMPACT_MARK}${agent}`; + const compactedAt = state[mark] ? Number(state[mark][0]) : null; + delete state[mark]; + const seen = + compactedAt !== null && compactedSince(agentTranscript(payload), compactedAt) ? [] : (state[agent] ?? []); const decision = decide({ payload, root, rules, seen, aliases: tsconfigAliases(root) }); - if (decision.add.length === 0 && decision.native.length === 0) { + if (decision.add.length === 0 && decision.native.length === 0 && compactedAt === null) { process.exit(0); } From 00991caf2bbd05f7c07589ada3087710443dc786 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 22 Oct 2026 23:15:37 +0200 Subject: [PATCH 012/251] fix(codex): reviews import logs each import to the day log only, since its table already prints the result --- src/codex/lib/review/import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex/lib/review/import.ts b/src/codex/lib/review/import.ts index 088eb2c03c..1f4084d00e 100644 --- a/src/codex/lib/review/import.ts +++ b/src/codex/lib/review/import.ts @@ -298,7 +298,7 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review }, ledgerPath ); - log.info({ name, verdict, files: base.files, base: base.base, head: base.head }, "review imported"); + log.debug({ name, verdict, files: base.files, base: base.base, head: base.head }, "review imported"); return { name, From 41f86c00e4544ebfa5dd76d2e7dfa1f9471a07c8 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 00:38:28 +0200 Subject: [PATCH 013/251] feat(agents, hooks): the mods plugin gains /decisions, reply links, a git and cache band, [ask] rows, spinner progress, an agents band and a hook ledger with dedupe, each switched by a nested mods. key --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- .../genesis-tools-mods/hooks/agent-model.tsx | 131 ++--- .../genesis-tools-mods/hooks/agents-band.tsx | 99 ++++ plugins/genesis-tools-mods/hooks/ask-row.tsx | 102 ++++ plugins/genesis-tools-mods/hooks/band.tsx | 24 + .../genesis-tools-mods/hooks/cache-band.tsx | 193 +++++++ .../genesis-tools-mods/hooks/decisions.tsx | 266 ++++++++++ plugins/genesis-tools-mods/hooks/git-band.tsx | 306 +++++++++++ .../genesis-tools-mods/hooks/hook-ledger.tsx | 194 +++++++ plugins/genesis-tools-mods/hooks/idle.ts | 36 +- plugins/genesis-tools-mods/hooks/links.tsx | 488 ++++++++++++++++++ plugins/genesis-tools-mods/hooks/register.tsx | 308 +++++++++-- plugins/genesis-tools-mods/hooks/sections.ts | 33 +- plugins/genesis-tools-mods/hooks/settings.ts | 122 +++++ plugins/genesis-tools-mods/hooks/shared.ts | 134 +++++ plugins/genesis-tools-mods/hooks/spinner.tsx | 68 +++ .../genesis-tools-mods/hooks/tmp-usage.tsx | 93 ++++ .../tests/agent-model.test.tsx | 4 +- .../tests/features.test.tsx | 206 ++++++++ .../genesis-tools-mods/tests/fold.test.tsx | 2 +- plugins/genesis-tools-mods/types/index.d.ts | 96 +++- src/agents/README.md | 35 +- src/agents/commands/hooks.ts | 41 +- src/agents/lib/hooks/config.ts | 171 ++++-- src/agents/lib/hooks/diff/diff.test.ts | 2 +- src/agents/lib/hooks/diff/run.ts | 4 +- src/agents/lib/hooks/hooks.test.ts | 52 +- src/agents/lib/hooks/mods-plugin.test.ts | 34 +- src/agents/lib/hooks/set-config.ts | 90 +++- 30 files changed, 3077 insertions(+), 265 deletions(-) create mode 100644 plugins/genesis-tools-mods/hooks/agents-band.tsx create mode 100644 plugins/genesis-tools-mods/hooks/ask-row.tsx create mode 100644 plugins/genesis-tools-mods/hooks/band.tsx create mode 100644 plugins/genesis-tools-mods/hooks/cache-band.tsx create mode 100644 plugins/genesis-tools-mods/hooks/decisions.tsx create mode 100644 plugins/genesis-tools-mods/hooks/git-band.tsx create mode 100644 plugins/genesis-tools-mods/hooks/hook-ledger.tsx create mode 100644 plugins/genesis-tools-mods/hooks/links.tsx create mode 100644 plugins/genesis-tools-mods/hooks/settings.ts create mode 100644 plugins/genesis-tools-mods/hooks/shared.ts create mode 100644 plugins/genesis-tools-mods/hooks/spinner.tsx create mode 100644 plugins/genesis-tools-mods/hooks/tmp-usage.tsx create mode 100644 plugins/genesis-tools-mods/tests/features.test.tsx diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 83a659349d..563a2e939d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "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, puts the model a subagent runs on in front of its description, after the agent type, so the launch block, the agents panel and the finished line name it, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.6.3", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe. Install with: tools agents hooks mods install --write.", + "version": "1.7.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 5abb1cee5b..e91d7c3aa5 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.6.3", - "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, puts the model a subagent runs on in front of its description, after the agent type, so the launch block, the agents panel and the finished line name it, 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.", + "version": "1.7.0", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe. Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx index a6078863b6..c48a4cb140 100644 --- a/plugins/genesis-tools-mods/hooks/agent-model.tsx +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -11,25 +11,38 @@ // Named only when the model is known before the spawn: the call's `model` parameter, or the parent's model for a // fork. Without either, the agent's own definition or the default subagent model decides later, so the // description stays as it was rather than guess. -import type { On, TurnStepChunk, TurnStepResult } from "claude-code"; +import type { AgentSpawnInput, HookStream, TurnStepChunk, TurnStepResult } from "claude-code"; +import { isRecord } from "./shared"; -/** `claude-sonnet-5-5` shows as `sonnet-5-5`; a dated id loses its date; an alias such as `sonnet` stays. */ -function modelLabel(id: string): string { - return id.replace(/^claude-/, "").replace(/-\d{8}$/, ""); -} +export type Format = "label" | "short"; -function withModel(description: string, label: string): string { - const prefix = `${label} · `; +const LETTERS: Record = { opus: "O", sonnet: "S", fable: "F", haiku: "H" }; - return description.startsWith(prefix) ? description : `${prefix}${description}`; +/** + * `label`: `claude-sonnet-5-5` shows as `sonnet-5-5`, a dated id loses its date, an alias such as `sonnet` stays. + * `short` (`mods.agentModel.format`): one letter per family, O S F H, else the label. + */ +export function modelLabel(id: string, format: Format): string { + const label = id.replace(/^claude-/, "").replace(/-\d{8}$/, ""); + + if (format === "label") { + return label; + } + + const family = Object.keys(LETTERS).find((name) => label.startsWith(name)); + + return family ? (LETTERS[family] ?? label) : label; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +/** `sonnet · Pair one`, or `(S) Pair one` in the short form; a description that already has it stays. */ +function withModel(description: string, label: string, format: Format): string { + const prefix = format === "short" ? `(${label}) ` : `${label} · `; + + return description.startsWith(prefix) ? description : `${prefix}${description}`; } /** Agent arguments with the model in the description, or `undefined` when they stay as the model wrote them. */ -function labelled(input: unknown): Record | undefined { +function labelled(input: unknown, format: Format): Record | undefined { if (!isRecord(input) || typeof input.description !== "string" || typeof input.model !== "string") { return undefined; } @@ -38,13 +51,13 @@ function labelled(input: unknown): Record | undefined { return undefined; } - return { ...input, description: withModel(input.description, modelLabel(input.model)) }; + return { ...input, description: withModel(input.description, modelLabel(input.model, format), format) }; } /** The argument JSON of one Agent block, rewritten when it parses and names a model; else as streamed. */ -function rewrittenJson(json: string): string { +function rewrittenJson(json: string, format: Format): string { try { - const next = labelled(JSON.parse(json)); + const next = labelled(JSON.parse(json), format); return next ? JSON.stringify(next) : json; } catch { @@ -53,63 +66,61 @@ function rewrittenJson(json: string): string { } } -export function agentModelHooks(on: On): void { - on("turn.step", async function* (_$, e, next) { - const stream = next(e); - const agentBlocks = new Set(); - const held = new Map(); - - function* release(): Generator { - for (const [index, json] of held) { - yield { kind: "input", index, json: rewrittenJson(json) }; - } - - held.clear(); +/** The turn.step stage: each Agent call's argument JSON is held until it ends, then passed on with the model in it. */ +export async function* agentModelStep( + stream: HookStream, + format: Format +): AsyncGenerator { + const agentBlocks = new Set(); + const held = new Map(); + + function* release(): Generator { + for (const [index, json] of held) { + yield { kind: "input", index, json: rewrittenJson(json, format) }; } - for await (const chunk of stream) { - if (chunk.kind === "tool" && chunk.name === "Agent") { - yield* release(); - agentBlocks.add(chunk.index); - held.set(chunk.index, ""); - yield chunk; - continue; - } - - if (chunk.kind === "input" && agentBlocks.has(chunk.index)) { - held.set(chunk.index, (held.get(chunk.index) ?? "") + chunk.json); - continue; - } - - // The first chunk after an Agent block's arguments (its block end, the next block, the stop) releases them. + held.clear(); + } + + for await (const chunk of stream) { + if (chunk.kind === "tool" && chunk.name === "Agent") { yield* release(); + agentBlocks.add(chunk.index); + held.set(chunk.index, ""); yield chunk; + continue; } + if (chunk.kind === "input" && agentBlocks.has(chunk.index)) { + held.set(chunk.index, (held.get(chunk.index) ?? "") + chunk.json); + continue; + } + + // The first chunk after an Agent block's arguments (its block end, the next block, the stop) releases them. yield* release(); + yield chunk; + } - // Patched too, so a hook above reads the arguments as recorded. A test stub at the bottom returns none. - const result: TurnStepResult | undefined = await stream.result; + yield* release(); - if (!result) { - return result; - } + // Patched too, so a hook above reads the arguments as recorded. A test stub at the bottom returns none. + const result: TurnStepResult | undefined = await stream.result; - return { - ...result, - toolUses: result.toolUses.map((use) => - use.name === "Agent" ? { ...use, input: labelled(use.input) ?? use.input } : use - ), - }; - }); + if (!result) { + return result; + } - on("agent.spawn", (_$, e, next) => { - const model = e.fork ? e.parentModel : e.model; + return { + ...result, + toolUses: result.toolUses.map((use) => + use.name === "Agent" ? { ...use, input: labelled(use.input, format) ?? use.input } : use + ), + }; +} - if (!model) { - return next(e); - } +/** The agent.spawn input with the model in front of its description, when the model is known before the spawn. */ +export function spawnWithModel(e: AgentSpawnInput, format: Format): AgentSpawnInput { + const model = e.fork ? e.parentModel : e.model; - return next({ ...e, description: withModel(e.description, modelLabel(model)) }); - }); + return model ? { ...e, description: withModel(e.description, modelLabel(model, format), format) } : e; } diff --git a/plugins/genesis-tools-mods/hooks/agents-band.tsx b/plugins/genesis-tools-mods/hooks/agents-band.tsx new file mode 100644 index 0000000000..16e1df6611 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/agents-band.tsx @@ -0,0 +1,99 @@ +// The live agents band: one row per working subagent with its model, what it is for, how long it has run, how long +// since its last tool call, and how many tool calls it made. The engine's agents panel shows none of the first +// three, and a subagent past `mods.agentsBand.warnMinutes` turns red. The model and start come from `agent.spawn`'s +// answer (kept in `spawned`, which survives a reload); activity from the subagent's own `tool.call`s. +import type { AgentSpawnInput, AgentSpawnResult, ElementTable, RenderElement } from "claude-code"; +import type { SpawnedAgents } from "../types"; +import { modelLabel } from "./agent-model"; +import { type Feature, type Host, settingsOf, shortDuration } from "./shared"; + +let spawned: SpawnedAgents = {}; +const activity = new Map(); +const BUSY = new Set(["pending", "running", "waiting"]); +const MAX_ROWS = 6; + +async function beat(host: Host): Promise { + if (!(await settingsOf(host)).agentsBand.enabled) { + return; + } + + const list = await host.agents().catch(() => []); + + if (list.some((agent) => BUSY.has(agent.status))) { + host.redraw(); + } +} + +export const agentsBand: Feature = { + async start(host) { + host.every(10_000, () => { + void beat(host); + }); + }, + beforeTool(_host, e) { + if (e.agentId !== undefined) { + const before = activity.get(e.agentId); + activity.set(e.agentId, { lastAt: Date.now(), lastTool: e.tool, tools: (before?.tools ?? 0) + 1 }); + } + + return undefined; + }, +}; + +/** Keeps the model and start of a spawned subagent, as `agent.spawn` answered. */ +export function recordSpawn(host: Host, e: AgentSpawnInput, result: AgentSpawnResult): void { + const agentId = result.agentId; + + if (!agentId) { + return; + } + + const model = result.model || e.model || e.parentModel; + // The agent-model mod may have put the model in front already; the row prints it once, itself. + const description = e.description.replace(/^\([A-Z]\) /, "").replace(`${modelLabel(model, "label")} · `, ""); + spawned = { ...spawned, [agentId]: { model, description, at: Date.now() } }; + host.redraw(); +} + +/** The band's agents rows, or null when no subagent works. */ +export async function agentsRow(host: Host, ui: ElementTable): Promise { + const all = await settingsOf(host); + const settings = all.agentsBand; + + if (!settings.enabled) { + return null; + } + + const list = (await host.agents().catch(() => [])).filter((agent) => BUSY.has(agent.status) && !agent.teammateId); + + if (list.length === 0) { + return null; + } + + const held = spawned; + const { Box, Text } = ui; + const now = Date.now(); + const rows = list.slice(0, MAX_ROWS).map((agent) => { + const known = held[agent.id]; + const seen = activity.get(agent.id); + const age = known ? now - known.at : undefined; + const late = age !== undefined && age > settings.warnMinutes * 60_000; + const model = known ? modelLabel(known.model, all.agentModel.format) : "?"; + const idle = seen + ? ` · idle ${shortDuration(now - seen.lastAt)} · ${seen.tools} tools, last ${seen.lastTool}` + : ""; + + return ( + + {`${late ? "⚠ " : "● "}(${model}) ${known?.description ?? agent.description}${age === undefined ? "" : ` · ${shortDuration(age)}`}${idle}`} + + ); + }); + + return ( + + {rows} + {list.length > MAX_ROWS ? {`+${list.length - MAX_ROWS} more agents`} : null} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/ask-row.tsx b/plugins/genesis-tools-mods/hooks/ask-row.tsx new file mode 100644 index 0000000000..bcff5decd6 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/ask-row.tsx @@ -0,0 +1,102 @@ +// `[ask]` on a finished tool row: a press puts the row, quoted, into the prompt where the cursor is, so a question +// about a tool call, its output or the hook text in it needs no copy and no screenshot. The folded group line +// ("ran 3 shell commands") quotes each call's input; a single row also quotes its output, cut to MAX_OUTPUT. +// Each press goes to `mods-log/ask.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import { type Host, isRecord, logMod, settingsOf } from "./shared"; + +const MAX_OUTPUT = 800; +const MAX_INPUT = 300; + +function textOf(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value.map(textOf).join("\n"); + } + + if (isRecord(value)) { + if (typeof value.text === "string") { + return value.text; + } + + if (typeof value.stdout === "string") { + return [value.stdout, typeof value.stderr === "string" ? value.stderr : ""].filter(Boolean).join("\n"); + } + } + + return value === undefined ? "" : JSON.stringify(value); +} + +/** What a call was asked to do, in one short line: the command, the path, the pattern, else its JSON. */ +export function inputLine(tool: string, input: unknown): string { + const held = isRecord(input) ? input : {}; + const main = [held.command, held.file_path, held.path, held.pattern, held.description, held.prompt].find( + (value): value is string => typeof value === "string" + ); + const text = (main ?? JSON.stringify(input ?? {})).replace(/\s+/g, " ").trim(); + + return `${tool}: ${text.length > MAX_INPUT ? `${text.slice(0, MAX_INPUT)}…` : text}`; +} + +function cut(text: string): string { + const trimmed = text.trim(); + + return trimmed.length > MAX_OUTPUT + ? `${trimmed.slice(0, MAX_OUTPUT)}\n… (${trimmed.length - MAX_OUTPUT} more characters)` + : trimmed; +} + +/** The quote a press puts into the prompt: each line under `> `, then an empty line for the question. */ +export function quoteOf(rows: readonly { tool: string; input: unknown; output?: unknown }[]): string { + const body = rows + .map((row) => { + const output = row.output === undefined ? "" : cut(textOf(row.output)); + + return output ? `${inputLine(row.tool, row.input)}\n${output}` : inputLine(row.tool, row.input); + }) + .join("\n\n"); + + return `${body + .split("\n") + .map((line) => `> ${line}`) + .join("\n")}\n\n`; +} + +async function ask(host: Host, id: string, rows: Parameters[0]): Promise { + const text = quoteOf(rows); + + await host.fill({ text, mode: "insert" }); + await logMod(host, "ask", { id, tools: rows.map((row) => row.tool), chars: text.length }); +} + +/** A finished tool row with `[ask]` beside it; the row as drawn while running, or with the mod off. */ +export async function askOnRow( + host: Host, + ui: ElementTable, + drawn: RenderElement, + row: { id: string; isRunning: boolean; calls: { tool: string; input: unknown; output?: unknown }[] } +): Promise { + if (row.isRunning || !(await settingsOf(host)).askRow.enabled) { + return drawn; + } + + return withButton(ui, drawn, `ask-${row.id}`, () => ask(host, row.id, row.calls)); +} + +function withButton(ui: ElementTable, drawn: RenderElement, key: string, onPress: () => Promise): RenderElement { + const { Box, Button } = ui; + + return ( + + + {drawn} + + + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/band.tsx b/plugins/genesis-tools-mods/hooks/band.tsx new file mode 100644 index 0000000000..49e448c33f --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/band.tsx @@ -0,0 +1,24 @@ +// The band above the prompt: every feature's row (git, cache, agents, /tmp) stacked in a fixed order above what +// the engine and the plugins beneath draw there. A row is null when its feature is off or has nothing to say. +import type { ElementTable, RenderElement } from "claude-code"; + +export function bandTree( + ui: ElementTable, + rows: readonly (RenderElement | null)[], + below: RenderElement +): RenderElement { + const shown = rows.filter((row) => row !== null); + + if (shown.length === 0) { + return below; + } + + const { Box } = ui; + + return ( + + {shown} + {below} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/cache-band.tsx b/plugins/genesis-tools-mods/hooks/cache-band.tsx new file mode 100644 index 0000000000..58a66f684c --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/cache-band.tsx @@ -0,0 +1,193 @@ +// The cache countdown. Each session writes `/mods-state/cache/.json` when its main loop finishes a +// turn (the prompt cache was written then), and an `ended` mark when it ends. The band shows this session's minutes +// left dim, and red with a background under `mods.cacheBand.warnMinutes`; with `others` on, it also shows every +// other session whose cache runs out within that threshold, so a reply that would rewrite a cold cache is visible +// from any open session. Reading the folder is a file listing, no process; it runs every 15 seconds. +import type { ElementTable, RenderElement } from "claude-code"; +import type { CacheRow, CacheRows } from "../types"; +import { agentsDir, type Feature, type Host, isRecord, settingsOf, shortDuration } from "./shared"; + +let rows: CacheRows = []; + +/** Claude Code's prompt cache for a subscription session lives an hour after its last write. */ +const TTL_MS = 60 * 60_000; +const EVERY_MS = 15_000; + +interface CacheMark { + sessionId: string; + label: string; + lastAt: number; + ttlMs: number; + ended?: boolean; +} + +async function folder(host: Host): Promise { + const dir = await agentsDir(host); + + return dir ? `${dir}/mods-state/cache` : undefined; +} + +async function labelOf(host: Host): Promise { + const cwd = await host.cwd(); + const id = await host.sessionId(); + + return `${cwd.split("/").filter(Boolean).pop() ?? "~"}·${id.slice(0, 6)}`; +} + +async function mark(host: Host, ended: boolean): Promise { + const dir = await folder(host); + + if (!dir) { + return; + } + + const sessionId = await host.sessionId(); + const held: CacheMark = { sessionId, label: await labelOf(host), lastAt: Date.now(), ttlMs: TTL_MS, ended }; + + if (ended) { + const before = await host.read(`${dir}/${sessionId}.json`).catch(() => undefined); + + try { + const parsed: unknown = before ? JSON.parse(before) : undefined; + + if (isRecord(parsed) && typeof parsed.lastAt === "number") { + held.lastAt = parsed.lastAt; + } + } catch { + // A broken mark is rewritten whole. + } + } + + await host.write(`${dir}/${sessionId}.json`, JSON.stringify(held)); +} + +/** This session's row, then the other live sessions inside the warning window, the soonest first. */ +export function cacheRowsOf(marks: readonly CacheMark[], ownId: string, warnMs: number, now: number): CacheRows { + const out: CacheRow[] = []; + + for (const held of marks) { + const expiresAt = held.lastAt + held.ttlMs; + const own = held.sessionId === ownId; + + if (own) { + out.unshift({ sessionId: held.sessionId, label: held.label, expiresAt, own }); + continue; + } + + if (!held.ended && expiresAt > now && expiresAt - now <= warnMs) { + out.push({ sessionId: held.sessionId, label: held.label, expiresAt, own }); + } + } + + return [ + ...out.filter((row) => row.own), + ...out.filter((row) => !row.own).sort((a, b) => a.expiresAt - b.expiresAt), + ]; +} + +async function scan(host: Host): Promise { + const settings = (await settingsOf(host)).cacheBand; + const dir = await folder(host); + + if (!settings.enabled || !dir) { + rows = []; + host.redraw(); + return; + } + + const ownId = await host.sessionId(); + const entries = settings.others + ? await host.list(dir).catch(() => []) + : [{ name: `${ownId}.json`, kind: "file" as const }]; + const marks: CacheMark[] = []; + + for (const entry of entries) { + if (entry.kind !== "file" || !entry.name.endsWith(".json")) { + continue; + } + + const text = await host.read(`${dir}/${entry.name}`).catch(() => undefined); + + try { + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.sessionId === "string" && typeof parsed.lastAt === "number") { + marks.push({ + sessionId: parsed.sessionId, + label: String(parsed.label ?? parsed.sessionId.slice(0, 6)), + lastAt: parsed.lastAt, + ttlMs: typeof parsed.ttlMs === "number" ? parsed.ttlMs : TTL_MS, + ended: parsed.ended === true, + }); + } + } catch { + // A mark half written by another session is read on the next pass. + } + } + + rows = cacheRowsOf(marks, ownId, settings.warnMinutes * 60_000, Date.now()); + // Redrawn on every pass, so a countdown under the warning moves each 15 s. + host.redraw(); +} + +export const cacheBand: Feature = { + async start(host) { + host.every(EVERY_MS, () => { + void scan(host); + }); + void scan(host); + }, + async afterTurn(host, e) { + if (e.agentId === undefined && (await settingsOf(host)).cacheBand.enabled) { + await mark(host, false); + void scan(host); + } + }, +}; + +/** The `ended` mark, so other sessions stop counting this one down. */ +export async function markEnded(host: Host): Promise { + await mark(host, true).catch(() => undefined); +} + +/** The band's cache row, or null with nothing to show. */ +export async function cacheRow(host: Host, ui: ElementTable): Promise { + const held = rows; + const settings = (await settingsOf(host)).cacheBand; + + if (!settings.enabled || held.length === 0) { + return null; + } + + const { Box, Text } = ui; + const now = Date.now(); + const warnMs = settings.warnMinutes * 60_000; + const cell = (row: CacheRow) => { + const left = row.expiresAt - now; + const name = row.own ? "cache" : `${row.label} cache`; + + if (left <= 0) { + return row.own ? ( + + {`${name} cold`} + + ) : null; + } + + return left <= warnMs ? ( + + {` ${name} ${shortDuration(left)} `} + + ) : ( + + {`${name} ${Math.ceil(left / 60_000)}m`} + + ); + }; + + return ( + + {held.map(cell)} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/decisions.tsx b/plugins/genesis-tools-mods/hooks/decisions.tsx new file mode 100644 index 0000000000..5feade1f7d --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/decisions.tsx @@ -0,0 +1,266 @@ +// `/decisions`: the open `❓ DECISION N` questions of this session in a pane, each with its options as Buttons. +// A press stages an answer (a second press on it unstages), an Input takes an answer in the person's own words, +// and [send] submits every staged answer as one message (`D16: a)` per line); [put in prompt] leaves it in the +// prompt to edit first. The list comes from the session's own messages, so a decision asked before this mod +// loaded is found too: a first ask is a `❓ DECISION N` that is not a link (a repeat is `[❓ DECISION N](…)`), +// its options are the `a)` lines under it, and it closes when a later user message answers it (`D16: a)`, +// `D16 b)`, `16 a)`, `D16: own words`) or a later assistant message says it replaces it. +import type { ElementTable, RenderElement } from "claude-code"; +import type { DecisionOption, OpenDecision, OpenDecisions, StagedDecisions } from "../types"; +import { type Feature, type Host, logMod, settingsOf } from "./shared"; + +export const DECISIONS_PANE = "genesis-decisions"; +let open: OpenDecisions = []; +let staged: StagedDecisions = {}; + +const ASK = /(^|[^[])❓\s*\**\s*DECISION\s+(\d+)\**\s*[:.]?\s*(.*)$/; +const OPTION = /^\s*(?:[-*]\s+)?\**\(?([a-z])\)\**\s*(.+)$/; +const ANSWER_LETTER = /\bD(?:ECISION)?\s*(\d+)\s*[:-]?\s*\(?([a-z])\)/gi; +const ANSWER_BARE = /^\s*(\d+)\s+([a-z])\)/gm; +const ANSWER_OWN = /^\s*D(\d+)\s*:\s*(.+)$/gm; +const REPLACES = /replaces?\s+\[?❓\s*DECISION\s+(\d+)/gi; + +function clean(text: string): string { + return text + .replace(/\*\*/g, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .trim(); +} + +/** The decisions an assistant text asks for the first time, with the options listed under each. */ +export function asksIn(text: string): OpenDecision[] { + const lines = text.split("\n"); + const found: OpenDecision[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = ASK.exec(lines[i] ?? ""); + + if (!match) { + continue; + } + + let title = clean(match[3] ?? ""); + const options: DecisionOption[] = []; + let j = i + 1; + + for (; j < lines.length && j <= i + 14; j++) { + const line = lines[j] ?? ""; + + if (ASK.test(line)) { + break; + } + + const option = OPTION.exec(line); + + if (option) { + options.push({ letter: option[1] ?? "", text: clean(option[2] ?? "") }); + continue; + } + + if (!title && line.trim()) { + title = clean(line); + continue; + } + + if (options.length > 0 && line.trim() && !/^\s/.test(line)) { + break; + } + } + + found.push({ n: Number(match[2]), title, options }); + i = j - 1; + } + + return found; +} + +/** The decision numbers a user text answers. */ +export function answersIn(text: string): Set { + const answered = new Set(); + + for (const pattern of [ANSWER_LETTER, ANSWER_BARE, ANSWER_OWN]) { + for (const match of text.matchAll(pattern)) { + answered.add(Number(match[1])); + } + } + + return answered; +} + +/** The decisions still open after reading the messages in order: asked, and since then neither answered nor replaced. */ +export function openDecisions(messages: readonly { role: string; text: string }[]): OpenDecisions { + const asked = new Map(); + + for (const message of messages) { + if (message.role === "assistant") { + for (const decision of asksIn(message.text)) { + if (!asked.has(decision.n)) { + asked.set(decision.n, decision); + } + } + + for (const match of message.text.matchAll(REPLACES)) { + asked.delete(Number(match[1])); + } + + continue; + } + + for (const n of answersIn(message.text)) { + asked.delete(n); + } + } + + return [...asked.values()].sort((a, b) => a.n - b.n); +} + +/** The message `[send]` submits: one `D: ` line per staged decision, in number order. */ +export function stagedMessage(answers: StagedDecisions): string { + return Object.entries(answers) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([n, answer]) => `D${n}: ${answer}`) + .join("\n"); +} + +async function refresh(host: Host): Promise { + const list = openDecisions(await host.messages()); + const numbers = new Set(list.map((decision) => String(decision.n))); + + open = list; + staged = Object.fromEntries(Object.entries(staged).filter(([n]) => numbers.has(n))); + host.redraw(); + + return list; +} + +export const decisions: Feature = { + commands: [ + { + name: "decisions", + description: "Open decisions of this session: stage an answer per decision, then send them in one message", + async run(host) { + if (!(await settingsOf(host)).decisions.enabled) { + return { + text: "The decisions mod is off: tools agents hooks config set mods.decisions.enabled true --write", + }; + } + + const list = await refresh(host); + await host.openPane(DECISIONS_PANE, "Decisions"); + await logMod(host, "decisions", { event: "open", open: list.map((decision) => decision.n) }); + + return list.length === 0 ? { text: "No open decisions in this session." } : {}; + }, + }, + ], +}; + +/** The decisions pane: per decision its options as Buttons and a field for an answer in the person's own words. */ +export function decisionsPane(host: Host, elements: ElementTable): RenderElement { + const { Box, Button, Text } = elements; + // The mobile app draws no field yet; there an answer is staged from the options alone. + const Input = "Input" in elements ? elements.Input : undefined; + const list = open; + const answers = staged; + const stage = (n: number, answer: string, how: string) => { + const key = String(n); + + const next = { ...staged }; + + if (next[key] === answer) { + delete next[key]; + } else { + next[key] = answer; + } + + staged = next; + host.redraw(); + void logMod(host, "decisions", { event: "stage", n, answer, how }); + }; + const message = stagedMessage(answers); + const send = async (how: "send" | "fill") => { + if (!message) { + return; + } + + await logMod(host, "decisions", { event: how, message }); + + if (how === "send") { + await host.submit(message); + } else { + await host.fill({ text: message, mode: "replace" }); + } + + staged = {}; + await host.closePane(DECISIONS_PANE); + }; + + return ( + + {list.length === 0 ? No open decisions. : null} + {list.map((decision) => ( + + + D{decision.n} {decision.title} + + + {decision.options.map((option) => { + const answer = `${option.letter})`; + const isStaged = answers[String(decision.n)] === answer; + + return ( + + ); + })} + + {Input ? ( + { + if (value.trim()) { + stage(decision.n, value.trim(), "own"); + } + }} + /> + ) : null} + + ))} + + Staged: + {message || "nothing yet"} + + + + + + + + + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/git-band.tsx b/plugins/genesis-tools-mods/hooks/git-band.tsx new file mode 100644 index 0000000000..9c7e07f040 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/git-band.tsx @@ -0,0 +1,306 @@ +// The git band: one line above the prompt with the branch, unpushed and unpulled commits, the dirty count, and +// the pull requests that matter here (the current branch's, plus the ones `/mod-git pr add` tracks) with their +// CI counts. It refreshes when the session starts, after a Bash call that ran git or gh, every 3 minutes, and on +// `/mod-git refresh`; `gh` answers are reused for 2 minutes. `/mod-git on|off|toggle` switches it for this +// session, `mods.git.enabled` for every session. Each refresh and command goes to `mods-log/git.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import type { BandPr, GitBand, GitBandLine, GitBandSwitch } from "../types"; +import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; + +/** The last refresh; null until one finished. */ +let line: GitBandLine = null; +/** `/mod-git on|off` for this session; null follows `mods.git.enabled`. */ +let turnedOn: GitBandSwitch = null; + +const GH_TTL_MS = 2 * 60_000; +const EVERY_MS = 3 * 60_000; +const ghCache = new Map(); +let refreshing: Promise | undefined; + +async function run(host: Host, argv: string[], cwd: string): Promise { + const done = await host.run(argv, { cwd, timeoutMs: 15_000 }).catch(() => undefined); + + return done?.exitCode === 0 ? done.stdout : undefined; +} + +async function rootOf(host: Host): Promise { + const cwd = await host.cwd(); + + return (await run(host, ["git", "rev-parse", "--show-toplevel"], cwd))?.trim() || undefined; +} + +const storeKey = (root: string) => `git-band-prs:${root}`; + +export async function trackedPrs(host: Host, root: string): Promise { + const held = await host.storeGet(storeKey(root)); + + return Array.isArray(held) ? held.filter((n): n is number => typeof n === "number") : []; +} + +/** CI counts out of `statusCheckRollup`: a check run has `conclusion`, a commit status has `state`. */ +export function checkCounts(rollup: unknown): { passed: number; failed: number; pending: number } { + const counts = { passed: 0, failed: 0, pending: 0 }; + + for (const check of Array.isArray(rollup) ? rollup : []) { + if (!isRecord(check)) { + continue; + } + + const verdict = String(check.conclusion || check.state || "").toUpperCase(); + + if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(verdict)) { + counts.passed++; + } else if ( + ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(verdict) + ) { + counts.failed++; + } else { + counts.pending++; + } + } + + return counts; +} + +async function prOf(host: Host, root: string, which: string, tracked: boolean): Promise { + const key = `${root}|${which}`; + const held = ghCache.get(key); + + if (held && Date.now() - held.at < GH_TTL_MS) { + return held.pr ? { ...held.pr, tracked } : null; + } + + const argv = [ + "gh", + "pr", + "view", + ...(which ? [which] : []), + "--json", + "number,title,url,state,isDraft,statusCheckRollup", + ]; + const text = await run(host, argv, root); + let pr: BandPr | null = null; + + try { + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.number === "number") { + pr = { + number: parsed.number, + title: String(parsed.title ?? ""), + url: String(parsed.url ?? ""), + state: String(parsed.state ?? ""), + draft: parsed.isDraft === true, + tracked, + ...checkCounts(parsed.statusCheckRollup), + }; + } + } catch { + pr = null; + } + + ghCache.set(key, { at: Date.now(), pr }); + + return pr; +} + +/** `## branch...origin/branch [ahead 2, behind 1]` and one line per changed path, from `git status --porcelain --branch`. */ +export function parseStatus(text: string): Pick { + const [head = "", ...rest] = text.split("\n"); + const branch = /^## (?:No commits yet on )?([^.\s]+(?:\.(?!\.)[^.\s]*)*)/.exec(head)?.[1] ?? "?"; + + return { + branch: branch === "HEAD" ? "detached" : branch, + ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0), + behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0), + dirty: rest.filter((row) => row.trim()).length, + }; +} + +async function isOn(host: Host): Promise { + const held = turnedOn; + + return held ?? (await settingsOf(host)).git.enabled; +} + +async function refreshNow(host: Host, why: string): Promise { + const root = await rootOf(host); + + if (!root) { + line = null; + host.redraw(); + return; + } + + const started = Date.now(); + const status = await run(host, ["git", "status", "--porcelain", "--branch"], root); + + if (status === undefined) { + return; + } + + const tracked = await trackedPrs(host, root); + const own = await prOf(host, root, "", false); + const others = await Promise.all( + tracked.filter((n) => n !== own?.number).map((n) => prOf(host, root, String(n), true)) + ); + const prs = [own ? { ...own, tracked: tracked.includes(own.number) } : null, ...others].filter( + (pr): pr is BandPr => pr !== null + ); + const band: GitBand = { at: Date.now(), ...parseStatus(status), prs }; + + line = band; + host.redraw(); + await logMod(host, "git", { + event: "refresh", + why, + root, + branch: band.branch, + ahead: band.ahead, + behind: band.behind, + dirty: band.dirty, + prs: prs.map((pr) => ({ n: pr.number, passed: pr.passed, failed: pr.failed, pending: pr.pending })), + ms: Date.now() - started, + }); +} + +/** One refresh at a time; a call while one runs waits for it instead of starting a second `gh`. */ +export function refresh(host: Host, why: string): Promise { + refreshing ??= refreshNow(host, why).finally(() => { + refreshing = undefined; + }); + + return refreshing; +} + +const USAGE = "/mod-git on | off | toggle | refresh | status | pr add | pr rm | pr list"; + +/** The answer to `/mod-git `. */ +async function command(host: Host, args: string): Promise { + const [verb = "status", sub, ...rest] = args.trim().split(/\s+/).filter(Boolean); + + if (verb === "on" || verb === "off" || verb === "toggle") { + const now = verb === "toggle" ? !(await isOn(host)) : verb === "on"; + turnedOn = now; + host.redraw(); + + if (now) { + await refresh(host, "command"); + } + + return `Git band ${now ? "on" : "off"} for this session.`; + } + + if (verb === "refresh") { + ghCache.clear(); + await refresh(host, "command"); + + return "Git band refreshed."; + } + + const root = await rootOf(host); + + if (!root) { + return "Not in a git repository."; + } + + if (verb === "pr") { + const numbers = rest.map((n) => Number(n.replace(/^#/, ""))).filter((n) => Number.isInteger(n) && n > 0); + const held = await trackedPrs(host, root); + + if (sub === "list" || sub === undefined) { + return held.length ? `Tracked here: ${held.map((n) => `#${n}`).join(", ")}` : "No tracked PRs here."; + } + + if ((sub !== "add" && sub !== "rm") || numbers.length === 0) { + return USAGE; + } + + const next = sub === "add" ? [...new Set([...held, ...numbers])] : held.filter((n) => !numbers.includes(n)); + await host.storeSet(storeKey(root), next); + await refresh(host, `pr ${sub}`); + + return `Tracked here: ${next.length ? next.map((n) => `#${n}`).join(", ") : "none"}`; + } + + if (verb === "status") { + const band = line; + const on = await isOn(host); + + return `Git band ${on ? "on" : "off"}; last refresh ${band ? new Date(band.at).toLocaleTimeString() : "never"}.`; + } + + return USAGE; +} + +export const gitBand: Feature = { + async start(host) { + host.every(EVERY_MS, () => { + void isOn(host).then((yes) => (yes ? refresh(host, "timer") : undefined)); + }); + + if (await isOn(host)) { + void refresh(host, "start"); + } + }, + commands: [ + { + name: "mod-git", + description: "The git band: on, off, toggle, refresh, status, pr add , pr rm , pr list", + argumentHint: "on|off|toggle|refresh|status|pr add ", + async run(host, args) { + const text = await command(host, args); + await logMod(host, "git", { event: "command", args, answer: text }); + + return { text }; + }, + }, + ], + async afterTool(host, e) { + if (e.tool === "Bash" && /(^|[\s;&|(])(git|gh)\s/.test(e.command) && (await isOn(host))) { + void refresh(host, "tool"); + } + }, +}; + +function ciText(pr: BandPr): string { + const parts = [ + pr.passed ? `✓${pr.passed}` : "", + pr.failed ? `✗${pr.failed}` : "", + pr.pending ? `…${pr.pending}` : "", + ]; + + return parts.filter(Boolean).join(" ") || "no CI"; +} + +/** The band's git row, or null while the band is off or has nothing yet. */ +export async function gitRow(host: Host, ui: ElementTable): Promise { + const band = line; + + if (!band || !(await isOn(host))) { + return null; + } + + const { Box, Link, Text } = ui; + const sync = [band.ahead ? `↑${band.ahead}` : "", band.behind ? `↓${band.behind}` : ""].filter(Boolean).join(" "); + + return ( + + ⎇ + {band.branch} + {sync ? {sync} : null} + {band.dirty ? ●{band.dirty} : clean} + {band.prs.map((pr) => ( + // A Link, so a click on `#507` opens the PR (asked for on 2026-10-23: "the PR is again not clickable"). + + + {`· #${pr.number}${pr.draft ? " draft" : ""}${pr.state === "OPEN" ? "" : ` ${pr.state.toLowerCase()}`} ${ciText(pr)}`} + + + ))} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx new file mode 100644 index 0000000000..64a5417ffd --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx @@ -0,0 +1,194 @@ +// The hook ledger. Every classic (settings and plugin) hook's answer passes through `classic.*`, so this counts what +// the hooks hand the model: `additionalContext` entries and `block` reasons (a Stop nudge is one), per event, for +// the main loop and for subagents. `mods.hooks.tokens` pins the total as this plugin's status line +// (`hooks ~38.2k tok · 41 fires`; tokens are characters / 4, an estimate). `mods.hooks.dedupe` drops a repeat: +// an entry, or an area-rule block inside one, already handed to the same loop since its last compaction becomes a +// one-line receipt. Each injection and each drop goes to `mods-log/hooks.jsonl`. +import type { HookCount, HookLedger } from "../types"; +import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; + +/** Counted since this module loaded: a hot reload of the plugin starts the count again. */ +let ledger: HookLedger = { + main: { fires: 0, chars: 0 }, + agents: { fires: 0, chars: 0 }, + byEvent: {}, + dropped: { fires: 0, chars: 0 }, +}; + +/** Shorter entries are never dropped: a receipt would save nothing. */ +const MIN_DEDUPE_CHARS = 200; +const AREA_BLOCK = /(?=Contents of \S+ \(area rules\):)/; + +/** What each loop has been handed since its last compaction: a key per whole entry and per area-rule block. */ +const seen = new Map>(); + +function keyOf(text: string): string { + let hash = 5381; + + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + } + + return `${text.length}:${hash >>> 0}`; +} + +export function tokensText(chars: number): string { + const tokens = chars / 4; + + return tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${Math.round(tokens)}`; +} + +function receipt(what: string, chars: number, at: number): string { + return `[genesis-tools-mods: ${what} repeated, ${chars} characters already in this context since ${new Date(at).toLocaleTimeString()}; dropped]`; +} + +/** + * One entry with its repeats taken out: a whole repeat becomes a receipt; an entry of area-rule blocks keeps its + * new blocks and names the dropped ones. `held` remembers what the loop has now. + */ +export function dedupeEntry( + text: string, + held: Map, + now: number +): { text: string; droppedChars: number; dropped: string[] } { + const whole = keyOf(text); + const before = held.get(whole); + + if (before !== undefined && text.length >= MIN_DEDUPE_CHARS) { + const head = text.split("\n")[0]?.slice(0, 80) ?? "hook context"; + const note = receipt(`"${head}"`, text.length, before); + + return { text: note, droppedChars: text.length - note.length, dropped: [head] }; + } + + held.set(whole, now); + const [preface = "", ...blocks] = text.split(AREA_BLOCK); + + if (blocks.length === 0) { + return { text, droppedChars: 0, dropped: [] }; + } + + const kept: string[] = []; + const dropped: string[] = []; + let droppedChars = 0; + + for (const block of blocks) { + const key = keyOf(block.trim()); + const at = held.get(key); + const path = /^Contents of (\S+)/.exec(block)?.[1] ?? "area rules"; + + if (at === undefined) { + held.set(key, now); + kept.push(block); + continue; + } + + const note = receipt(`area rules ${path}`, block.length, at); + dropped.push(path); + droppedChars += block.length - note.length; + kept.push(`${note}\n\n`); + } + + return { text: dropped.length ? `${preface}${kept.join("")}`.trimEnd() : text, droppedChars, dropped }; +} + +function add(count: HookCount, chars: number): HookCount { + return { fires: count.fires + 1, chars: count.chars + chars }; +} + +async function showStatus(host: Host): Promise { + if (!(await settingsOf(host)).hooks.tokens) { + host.status(undefined); + return; + } + + const held = ledger; + const parts = [`hooks ${tokensText(held.main.chars)} tok · ${held.main.fires} fires`]; + + if (held.agents.fires > 0) { + parts.push(`agents ${tokensText(held.agents.chars)}`); + } + + if (held.dropped.fires > 0) { + parts.push(`deduped ${tokensText(held.dropped.chars)}`); + } + + host.status(parts.join(" · ")); +} + +/** + * Counts what one classic hook event handed the model and takes out the repeats. Answers the `additionalContext` to + * pass on in place of the hooks' own, or undefined to pass their answer on unchanged. + */ +export async function ledgerClassic(host: Host, input: unknown, answer: unknown): Promise { + { + if (!isRecord(input) || !isRecord(answer)) { + return undefined; + } + + const event = typeof input.hook_event_name === "string" ? input.hook_event_name : "?"; + const agent = typeof input.agent_id === "string" ? input.agent_id : "main"; + + if (event === "SessionStart" && (input.source === "compact" || input.source === "clear")) { + seen.delete(agent); + } + + const entries = Array.isArray(answer.additionalContext) + ? answer.additionalContext.filter((entry): entry is string => typeof entry === "string") + : []; + const block = typeof answer.block === "string" ? answer.block : ""; + + if (entries.length === 0 && !block) { + return undefined; + } + + const settings = (await settingsOf(host)).hooks; + const held = seen.get(agent) ?? new Map(); + seen.set(agent, held); + const now = Date.now(); + let droppedChars = 0; + const dropped: string[] = []; + const kept = entries.map((entry) => { + if (!settings.dedupe) { + return entry; + } + + const out = dedupeEntry(entry, held, now); + droppedChars += out.droppedChars; + dropped.push(...out.dropped); + + return out.text; + }); + const chars = kept.reduce((sum, entry) => sum + entry.length, 0) + block.length; + + const before = ledger; + ledger = { + main: agent === "main" ? add(before.main, chars) : before.main, + agents: agent === "main" ? before.agents : add(before.agents, chars), + byEvent: { ...before.byEvent, [event]: add(before.byEvent[event] ?? { fires: 0, chars: 0 }, chars) }, + dropped: dropped.length ? add(before.dropped, droppedChars) : before.dropped, + }; + await logMod(host, "hooks", { + event, + agent, + tool: typeof input.tool_name === "string" ? input.tool_name : undefined, + chars, + heads: kept.map((entry) => entry.split("\n")[0]?.slice(0, 100)), + block: block ? block.slice(0, 100) : undefined, + dropped: dropped.length ? dropped : undefined, + droppedChars: droppedChars || undefined, + }); + await showStatus(host); + + return dropped.length ? kept : undefined; + } +} + +export const hookLedger: Feature = { + async start(host) { + await showStatus(host); + }, + compacted(agentId) { + seen.delete(agentId ?? "main"); + }, +}; diff --git a/plugins/genesis-tools-mods/hooks/idle.ts b/plugins/genesis-tools-mods/hooks/idle.ts index 6968c99fab..c40c990ded 100644 --- a/plugins/genesis-tools-mods/hooks/idle.ts +++ b/plugins/genesis-tools-mods/hooks/idle.ts @@ -1,3 +1,5 @@ +import { modsSettings } from "./settings"; + /** The idle-compaction mod's settings, from the `mods` section of the hooks file. */ export interface IdleCompactSettings { enabled: boolean; @@ -17,38 +19,14 @@ export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minut /** 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 { - 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 + * `mods.idleCompact` of the hooks file: `{ enabled, minutes, minTokens }`, or the flat `mods.idleCompact`, + * `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` of before 2026-10-23 + * (`tools agents hooks config set mods.idleCompact.enabled 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, - }; + return modsSettings(hooksFile).idleCompact; } /** @@ -79,7 +57,7 @@ export function idleCompactSkip({ tokens: number | undefined; }): string | undefined { if (!settings.enabled) { - return "mods.idleCompact is off"; + return "mods.idleCompact.enabled is off"; } if (surfaces === 0) { diff --git a/plugins/genesis-tools-mods/hooks/links.tsx b/plugins/genesis-tools-mods/hooks/links.tsx new file mode 100644 index 0000000000..bde1328a89 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/links.tsx @@ -0,0 +1,488 @@ +// The link fixer: the main loop's reply text is rewritten line by line as it streams, so a path, a commit or a +// repeated decision reaches the screen and the transcript as a link (AGENTS.md "Clickable paths"). Code fences, +// existing markdown links and bare URLs are left as they are, and nothing is linked unless it resolves: a path +// that exists, a commit the repository has, a decision asked in an earlier message. Every transform and every +// candidate it rejected goes to `mods-log/links.jsonl` with the reason, to tune the heuristics from real replies. +import type { HookStream, TurnStepChunk, TurnStepInput, TurnStepResult } from "claude-code"; +import { type Host, isRecord, logMod } from "./shared"; + +const DOC = new Set(["md", "mdx", "json", "jsonl", "yaml", "yml", "toml", "log", "txt", "csv"]); +const CODE = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "swift", + "py", + "go", + "rs", + "rb", + "php", + "java", + "kt", + "sh", + "zsh", + "html", + "css", + "scss", + "sql", + "proto", + "c", + "h", + "m", + "mm", + "cpp", + "vue", + "svelte", +]); +const ANY_EXT = new Set([ + ...DOC, + ...CODE, + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "pdf", + "zst", + "plist", + "xml", + "lock", + "env", +]); + +/** A path as written: absolute or under the home, relative with a slash, or a bare name with a known extension. */ +const PATH = + /(?:~\/|\/(?:Users|tmp|private|opt|var|Applications|Library|Volumes)\/)[^\s`'"()<>[\]|,;]+|(?:\.{1,2}\/)?(?:[\w@.+-]+\/)+[\w@.+-]*(?::\d+(?:-\d+)?)?|[\w@+-][\w@.+-]*\.[a-zA-Z][a-zA-Z0-9]{0,6}(?::\d+(?:-\d+)?)?/g; +/** A commit: 7 to 12 hex characters with at least one digit and one letter. */ +const SHA = /\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,12}\b/g; +const DECISION = /❓\s*DECISION\s+(\d+)/g; +/** A pull request named by number: `#507`, not inside a word or a URL anchor. */ +const PR = /(?; + log: Record[]; +} + +/** Paths the tools of this session named, by file name, so a bare `register.tsx` finds its one absolute path. */ +const known = new Map>(); +const commits = new Map(); + +function remember(path: unknown): void { + if (typeof path !== "string" || !path.startsWith("/")) { + return; + } + + const name = path.split("/").filter(Boolean).pop(); + + if (!name) { + return; + } + + const set = known.get(name) ?? new Set(); + set.add(path.replace(/\/+$/, "")); + known.set(name, set); +} + +function encodePath(path: string): string { + return encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +function extOf(path: string): string { + const name = path.split("/").pop() ?? ""; + const dot = name.lastIndexOf("."); + + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +/** The link target for an absolute path, by kind (AGENTS.md "Clickable paths"). */ +export function targetFor(path: string, kind: "file" | "dir", line?: number, end?: number): string { + if (kind === "dir") { + return `https://genesis.tools/finder/reveal?target=${encodePath(path)}`; + } + + const ext = extOf(path); + + if (DOC.has(ext)) { + const range = line ? `&range=${line}${end ? `-${end}` : ""}` : ""; + + return `https://genesis.tools/md/open?path=${encodePath(path)}${range}`; + } + + if (line) { + return `cursor://file${encodePath(path)}:${line}`; + } + + return encodePath(path); +} + +async function kindOf(host: Host, path: string): Promise<"file" | "dir" | undefined> { + const stat = await host.stat(path).catch(() => undefined); + + return stat?.kind === "file" || stat?.kind === "dir" ? stat.kind : undefined; +} + +/** Resolves one candidate to an existing absolute path, or says why not. */ +async function resolve( + ctx: LinkContext, + token: string +): Promise<{ path: string; kind: "file" | "dir"; line?: number; end?: number } | { reason: string }> { + const lineMatch = /^(.*?):(\d+)(?:-(\d+))?$/.exec(token); + const raw = (lineMatch ? lineMatch[1] : token) ?? token; + const line = lineMatch ? Number(lineMatch[2]) : undefined; + const end = lineMatch?.[3] ? Number(lineMatch[3]) : undefined; + const bare = !raw.includes("/"); + + if (bare && !ANY_EXT.has(extOf(raw))) { + return { reason: "no known extension" }; + } + + const tries: string[] = []; + + if (raw.startsWith("~/")) { + tries.push(`${ctx.home}${raw.slice(1)}`); + } else if (raw.startsWith("/")) { + tries.push(raw); + } else if (bare) { + const found = [...(known.get(raw) ?? [])]; + + if (found.length > 1) { + return { reason: `ambiguous: ${found.length} paths in this session` }; + } + + tries.push(...found, `${ctx.cwd}/${raw}`); + } else { + const rel = raw.replace(/^\.\//, ""); + tries.push(`${ctx.cwd}/${rel}`); + + if (ctx.root && ctx.root !== ctx.cwd) { + tries.push(`${ctx.root}/${rel}`); + } + } + + for (const path of tries) { + const clean = path.replace(/\/+$/, ""); + const kind = await kindOf(ctx.host, clean); + + if (kind) { + return { path: clean, kind, line, end }; + } + } + + return { reason: "not on disk" }; +} + +const pullRequests = new Map(); + +/** The PR's title and URL from `gh`, kept for the session; null for a number that is no PR of this repository. */ +async function pullRequest(ctx: LinkContext, n: string): Promise<{ title: string; url: string } | null> { + if (!ctx.root || !ctx.repo) { + return null; + } + + const key = `${ctx.repo}#${n}`; + const held = pullRequests.get(key); + + if (held !== undefined) { + return held; + } + + const run = await ctx.host + .run(["gh", "pr", "view", n, "--json", "title,url"], { cwd: ctx.root, timeoutMs: 8_000 }) + .catch(() => undefined); + let found: { title: string; url: string } | null = null; + + try { + const parsed: unknown = run?.exitCode === 0 ? JSON.parse(run.stdout) : undefined; + + if (isRecord(parsed) && typeof parsed.title === "string" && typeof parsed.url === "string") { + found = { title: parsed.title.replace(/^Draft: /, ""), url: parsed.url }; + } + } catch { + found = null; + } + + pullRequests.set(key, found); + + return found; +} + +async function isCommit(ctx: LinkContext, sha: string): Promise { + if (!ctx.root) { + return false; + } + + const key = `${ctx.root}|${sha}`; + const held = commits.get(key); + + if (held !== undefined) { + return held; + } + + const run = await ctx.host + .run(["git", "-C", ctx.root, "cat-file", "-t", sha], { timeoutMs: 3_000 }) + .catch(() => undefined); + const yes = run?.exitCode === 0 && run.stdout.trim() === "commit"; + commits.set(key, yes); + + return yes; +} + +/** Links the paths, decisions and commits of one stretch of plain text (no link, URL or code span in it). */ +async function linkPlain(ctx: LinkContext, text: string): Promise { + let out = ""; + let at = 0; + const hits: { start: number; end: number; kind: "path" | "sha" | "decision" | "pr"; token: string }[] = []; + + for (const match of text.matchAll(DECISION)) { + hits.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + kind: "decision", + token: match[0], + }); + } + + for (const match of text.matchAll(PATH)) { + hits.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + kind: "path", + token: match[0], + }); + } + + for (const match of text.matchAll(PR)) { + hits.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length, kind: "pr", token: match[0] }); + } + + for (const match of text.matchAll(SHA)) { + hits.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length, kind: "sha", token: match[0] }); + } + + hits.sort((a, b) => a.start - b.start || b.end - a.end); + + for (const hit of hits) { + if (hit.start < at) { + continue; + } + + let replacement: string | undefined; + let start = hit.start; + let end = hit.end; + + if (hit.kind === "pr") { + const pr = await pullRequest(ctx, hit.token.slice(1)); + + if (pr) { + // The rule: a PR's label carries its title, unless the reply already writes it after the number. + const titled = text.slice(end, end + 2) === " ("; + replacement = `[${titled ? hit.token : `${hit.token} (${pr.title})`}](${pr.url})`; + } + } else if (hit.kind === "decision") { + const n = Number(/\d+/.exec(hit.token)?.[0]); + + if (ctx.askedBefore.has(n)) { + replacement = `[${hit.token}](https://genesis.tools/claude/find/${ctx.sessionId}/decision/${n})`; + } + } else if (hit.kind === "sha") { + if (ctx.repo && (await isCommit(ctx, hit.token))) { + const bold = text.slice(start - 2, start) === "**" && text.slice(end, end + 2) === "**"; + start = bold ? start - 2 : start; + end = bold ? end + 2 : end; + const label = bold ? `**${hit.token}**` : hit.token; + replacement = `[${label}](https://genesis.tools/hub?repo=${encodeURIComponent(ctx.repo)}&commit=${hit.token})`; + } + } else { + const token = hit.token.replace(/[.,:;!?]+$/, ""); + end = hit.start + token.length; + const found = await resolve(ctx, token); + + if ("path" in found) { + replacement = `[${token}](${targetFor(found.path, found.kind, found.line, found.end)})`; + } else if (token.includes("/") || ANY_EXT.has(extOf(token.replace(/:\d+(-\d+)?$/, "")))) { + ctx.log.push({ kind: "miss", token, reason: found.reason }); + } + } + + if (replacement === undefined) { + continue; + } + + ctx.log.push({ kind: hit.kind, token: text.slice(start, end), link: replacement }); + out += text.slice(at, start) + replacement; + at = end; + } + + return out + text.slice(at); +} + +/** One line of reply text with its candidates linked; links, URLs and code spans that do not resolve stay. */ +export async function linkLine(ctx: LinkContext, line: string): Promise { + let out = ""; + let at = 0; + + for (const match of line.matchAll(PROTECTED)) { + const start = match.index ?? 0; + out += await linkPlain(ctx, line.slice(at, start)); + at = start + match[0].length; + + const code = match[3]; + + if (code) { + const inner = code.slice(1, -1).trim(); + const found = /\s/.test(inner) ? { reason: "code with spaces" } : await resolve(ctx, inner); + + if ("path" in found) { + const link = `[${inner}](${targetFor(found.path, found.kind, found.line, found.end)})`; + ctx.log.push({ kind: "code-path", token: code, link }); + out += link; + continue; + } + } + + out += match[0]; + } + + return out + (await linkPlain(ctx, line.slice(at))); +} + +/** `messageCount`: the messages the request carried; later ones are this reply itself, which asks nothing earlier. */ +async function contextOf(host: Host, messageCount: number): Promise { + const home = (await host.env("HOME")) ?? ""; + const cwd = await host.cwd(); + const sessionId = await host.sessionId(); + const top = await host + .run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], { timeoutMs: 3_000 }) + .catch(() => undefined); + const root = top?.exitCode === 0 ? top.stdout.trim() : undefined; + const origin = root + ? await host + .run(["git", "-C", root, "remote", "get-url", "origin"], { timeoutMs: 3_000 }) + .catch(() => undefined) + : undefined; + const repo = + origin?.exitCode === 0 ? /github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?\s*$/.exec(origin.stdout)?.[1] : undefined; + const askedBefore = new Set(); + + for (const message of (await host.messages()).slice(0, messageCount)) { + if (message.role !== "assistant") { + continue; + } + + for (const match of message.text.matchAll(/(^|[^[])❓\s*\**\s*DECISION\s+(\d+)/gm)) { + askedBefore.add(Number(match[2])); + } + } + + return { host, home, cwd, root, repo, sessionId, askedBefore, log: [] }; +} + +/** Notes the absolute paths a tool call names, so a bare file name in a later reply finds its one path. */ +export function rememberToolPaths(input: unknown): void { + if (isRecord(input)) { + remember(input.file_path); + remember(input.notebook_path); + remember(input.path); + + if (typeof input.command === "string") { + for (const match of input.command.matchAll(/(?:^|[\s'"=])(\/(?:Users|tmp|private)\/[^\s'"|;&)]+)/g)) { + remember(match[1]); + } + } + } +} + +/** The turn.step stage of the main loop: reply text passes line by line, each line linked (see `linkLine`). */ +export async function* linksStep( + host: Host, + e: TurnStepInput, + stream: HookStream +): AsyncGenerator { + { + let ctx: LinkContext | undefined; + const lines = new Map(); + const fenced = new Set(); + let answer = ""; + let changed = false; + + async function* emit(index: number, line: string): AsyncGenerator { + if (/^\s*```/.test(line)) { + if (fenced.has(index)) { + fenced.delete(index); + } else { + fenced.add(index); + } + + answer += line; + yield { kind: "text", index, text: line }; + return; + } + + if (fenced.has(index) || !line.trim()) { + answer += line; + yield { kind: "text", index, text: line }; + return; + } + + ctx ??= await contextOf(host, e.messageCount); + const linked = await linkLine(ctx, line); + changed ||= linked !== line; + answer += linked; + yield { kind: "text", index, text: linked }; + } + + async function* flush(): AsyncGenerator { + for (const [index, rest] of lines) { + if (rest) { + yield* emit(index, rest); + } + } + + lines.clear(); + } + + for await (const chunk of stream) { + if (chunk.kind !== "text") { + yield* flush(); + yield chunk; + continue; + } + + let held = (lines.get(chunk.index) ?? "") + chunk.text; + let newline = held.indexOf("\n"); + + while (newline !== -1) { + yield* emit(chunk.index, held.slice(0, newline + 1)); + held = held.slice(newline + 1); + newline = held.indexOf("\n"); + } + + lines.set(chunk.index, held); + } + + yield* flush(); + + if (ctx && ctx.log.length > 0) { + await logMod(host, "links", { session: ctx.sessionId, turn: e.turnId, step: e.index, items: ctx.log }); + } + + // The recorded answer carries the links too, so a hook above and the transcript read what was shown. + const result: TurnStepResult | undefined = await stream.result; + + return result && changed ? { ...result, answer } : result; + } +} diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index ce7aa2a883..621d4182d4 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,8 +1,25 @@ -import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; +import type { + ElementTable, + EngineInterface, + HookStream, + Register, + RenderElement, + Timer, + TurnStepChunk, + TurnStepResult, +} from "claude-code"; import { atom, read, update } from "claude-code"; import type { FlippedFiles, ShownCalls } from "../types"; -import { agentModelHooks } from "./agent-model"; +import { agentModelStep, spawnWithModel } from "./agent-model"; +import { agentsBand, agentsRow, recordSpawn } from "./agents-band"; +import { askOnRow } from "./ask-row"; +import { bandTree } from "./band"; +import { cacheBand, cacheRow, markEnded } from "./cache-band"; +import { DECISIONS_PANE, decisions, decisionsPane } from "./decisions"; +import { gitBand, gitRow } from "./git-band"; +import { hookLedger, ledgerClassic } from "./hook-ledger"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; +import { linksStep, rememberToolPaths } from "./links"; import { type CallSection, collapseGitSyncEnabled, @@ -13,6 +30,9 @@ import { openByDefault, sectionsOfCalls, } from "./sections"; +import { agentsDir, type Feature, type Host, readHooksFile, settingsOf } from "./shared"; +import { progressSuffix, spinner } from "./spinner"; +import { tmpRow, tmpUsage } from "./tmp-usage"; // 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 @@ -28,20 +48,9 @@ import { // One drawing may hold 100,000 characters in all (CodeProps.source); stay well inside it. const MAX_CHARS = 60_000; -/** `/.genesis-tools/agents`; `GENESIS_TOOLS_HOME` replaces the home, as it does for every tool. */ -async function agentsDir($: EngineInterface): Promise { - const home = (await $.env.get("GENESIS_TOOLS_HOME")) ?? (await $.env.get("HOME")); - - return home ? `${home}/.genesis-tools/agents` : undefined; -} - -async function readHooksFile($: EngineInterface, dir: string | undefined): Promise { - return dir ? $.fs.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; -} - interface Diffs { sections: CallSection[]; - /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.collapseGitSync` is on. */ + /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.diff.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[]; @@ -49,13 +58,14 @@ interface Diffs { 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; + const host = hostOf($); + const dir = calls.length > 0 ? await agentsDir(host) : undefined; if (!dir) { return none; } - const hooksFile = await readHooksFile($, dir); + const hooksFile = await readHooksFile(host, dir); if (!fullDiffEnabled(hooksFile)) { return none; @@ -86,7 +96,7 @@ async function fullDiffs($: EngineInterface, calls: readonly { id: string; input // The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last // request, and gives up at the first gate that refuses (`near_limit` refuses while the account's last reply // carried a rate-limit warning, even after the window reset). Then the 1-hour cache expires and the next -// prompt pays for the whole context again. This timer compacts at `mods.idleCompactMinutes` (56) after the +// prompt pays for the whole context again. This timer compacts at `mods.idleCompact.minutes` (56) after the // last main-loop turn, unless a compaction already happened. A reload of the mod drops the timer; the next // turn arms it again. let idleTimer: Timer | undefined; @@ -99,8 +109,9 @@ function stopIdleTimer(): void { async function compactIfIdle($: EngineInterface): Promise { idleTimer = undefined; - // Read again at the fire time, so `config set mods.idleCompact false` stops a timer already armed. - const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + // Read again at the fire time, so `config set mods.idleCompact.enabled false` stops a timer already armed. + const host = hostOf($); + const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host))); const [surfaces, usage] = await Promise.all([$.session.surfaces(), $.session.usage()]); if (idleCompactSkip({ settings, surfaces: surfaces.length, tokens: usage.context.tokens }) !== undefined) { @@ -284,8 +295,151 @@ async function announceSession($: EngineInterface): Promise { } } +/** A stage's generator as a stream the next stage reads, with the `result` a `next(e)` stream carries. */ +function streamOf(stage: AsyncGenerator): HookStream { + let settle: (value: R) => void = () => undefined; + let fail: (error: unknown) => void = () => undefined; + const result = new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }); + + // An unread result must not surface as an unhandled rejection; a stage that reads it still sees the error. + result.catch(() => undefined); + + async function* run(): AsyncGenerator { + try { + const value = yield* stage; + settle(value); + + return value; + } catch (error) { + fail(error); + throw error; + } + } + + return Object.assign(run(), { result }); +} + +/** + * The engine as the feature files reach it. Built here because only this file may hand `$` on (see `shared.ts`); + * each closure spells the call it makes, so `claude plugin validate` reads every capability the plugin uses. + */ +function hostOf($: EngineInterface): Host { + return { + run: (argv, init) => $.process.run(argv, init), + read: (path) => $.fs.read(path), + write: (path, text) => $.fs.write(path, text), + list: (path) => $.fs.list(path), + exists: (path) => $.fs.exists(path), + stat: (path) => $.fs.stat(path), + env: (name) => (name === "HOME" ? $.env.get("HOME") : $.env.get("GENESIS_TOOLS_HOME")), + cwd: () => $.session.cwd(), + sessionId: () => $.session.id(), + messages: () => $.session.messages(), + agents: () => $.agent.list(), + every: (ms, fn) => { + $.clock.every(ms, fn); + }, + storeGet: (key) => $.store.get(key), + storeSet: (key, value) => $.store.set(key, value), + status: (text) => $.ui.status(text), + fill: (args) => $.prompt.fill(args), + submit: (text) => $.prompt.submit({ text }), + openPane: (id, title) => $.ui.open({ id, title, focus: true }), + closePane: (id) => $.ui.close({ id }), + redraw: () => $.ui.invalidate("ui.render"), + }; +} + +// The features that share events. The engine takes one unmatched hook per event from a plugin, so each event +// below has one hook that calls every feature's part in this order. +const FEATURES: Feature[] = [decisions, gitBand, spinner, cacheBand, tmpUsage, agentsBand, hookLedger]; + export const register: Register = (on) => { - agentModelHooks(on); + on("session.start", async ($, e, next) => { + const host = hostOf($); + + for (const feature of FEATURES) { + for (const command of feature.commands ?? []) { + await $.command.register({ + name: command.name, + description: command.description, + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), + }); + } + + await feature.start?.(host); + } + + return next(e); + }); + + on("session.end", async ($, e, next) => { + await markEnded(hostOf($)); + + return next(e); + }); + + on("command.run", async ($, e, next) => { + const command = FEATURES.flatMap((feature) => feature.commands ?? []).find((held) => held.name === e.command); + + return command ? command.run(hostOf($), e.args ?? "") : next(e); + }); + + on("tool.call", async ($, e, next) => { + const host = hostOf($); + rememberToolPaths(e); + const ends = FEATURES.flatMap((feature) => feature.beforeTool?.(host, e) ?? []); + + try { + const result = await next(e); + + for (const feature of FEATURES) { + await feature.afterTool?.(host, e); + } + + return result; + } finally { + for (const end of ends) { + end(); + } + } + }); + + // Two stages over the model's stream: the Agent arguments get the model, the main loop's reply text its links. + on("turn.step", async function* ($, e, next) { + const host = hostOf($); + const settings = await settingsOf(host); + let stream: HookStream = next(e); + + if (settings.agentModel.enabled) { + stream = streamOf(agentModelStep(stream, settings.agentModel.format)); + } + + if (settings.links.enabled && e.agentId === undefined) { + stream = streamOf(linksStep(host, e, stream)); + } + + return yield* stream; + }); + + on("agent.spawn", async ($, e, next) => { + const host = hostOf($); + const settings = (await settingsOf(host)).agentModel; + const result = await next(settings.enabled ? spawnWithModel(e, settings.format) : e); + recordSpawn(host, e, result); + + return result; + }); + + on("classic.*", async ($, e, next) => { + const result = await next(e); + const kept = await ledgerClassic(hostOf($), e, result); + + return kept ? { ...result, additionalContext: kept } : result; + }); on("turn.start", async ($, e, next) => { stopIdleTimer(); @@ -296,11 +450,12 @@ export const register: Register = (on) => { on("turn.complete", async ($, e, next) => { const done = await next(e); + const host = hostOf($); if (e.agentId === undefined) { stopIdleTimer(); - const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host))); if (settings.enabled) { idleTimer = $.clock.after(settings.minutes * 60_000, () => { @@ -309,6 +464,10 @@ export const register: Register = (on) => { } } + for (const feature of FEATURES) { + await feature.afterTurn?.(host, e); + } + return done; }); @@ -320,43 +479,100 @@ export const register: Register = (on) => { stopIdleTimer(); } + if (result.skip === undefined) { + for (const feature of FEATURES) { + feature.compacted?.(e.agentId); + } + } + return result; }); - on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { - const drawn = await next(e); - - if (e.props.isExpanded) { - return drawn; + on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => { + if (e.props.hasSurvey) { + return next(e); } - 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 diffs = await fullDiffs($, calls); - - if (diffs.sections.length === 0 && diffs.omitted.length === 0) { - return drawn; - } + const host = hostOf($); + const ui = $.ui.resolve(e); + const rows = await Promise.all([gitRow(host, ui), cacheRow(host, ui), agentsRow(host, ui), tmpRow(host, ui)]); - return drawDiffs($, $.ui.resolve(e), e.requestId, diffs, drawn); + return bandTree(ui, rows, await next(e)); }); - on("ui.render", { component: "ToolUse" }, async ($, e, next) => { - const drawn = await next(e); + on("ui.render", { component: "Pane", requestId: DECISIONS_PANE }, async ($, e) => { + return decisionsPane(hostOf($), $.ui.resolve(e)); + }); - if (e.props.tool !== "Bash" || e.props.isRunning) { - return drawn; + on("ui.render", { component: "Spinner" }, async ($, e, next) => { + if (!(await settingsOf(hostOf($))).spinner.enabled) { + return next(e); } - const diffs = await fullDiffs($, [{ id: e.props.tool_use_id, input: e.props.input }]); + return next({ ...e, props: { ...e.props, suffix: progressSuffix(e.props.suffix, Date.now()) } }); + }); - if (diffs.sections.length === 0 && diffs.omitted.length === 0) { - return drawn; - } + on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { + const ui = $.ui.resolve(e); + const drawn = await withGroupDiffs($, e.props, e.requestId, ui, await next(e)); + const calls = e.props.calls.map((call) => ({ tool: call.tool, input: call.input })); + + return askOnRow(hostOf($), ui, drawn, { + id: e.requestId, + isRunning: e.props.isActive || e.props.isExpanded, + calls, + }); + }); - return drawDiffs($, $.ui.resolve(e), e.props.tool_use_id, diffs, drawn); + on("ui.render", { component: "ToolUse" }, async ($, e, next) => { + const ui = $.ui.resolve(e); + const { tool, input, output, tool_use_id, isRunning } = e.props; + const drawn = await withCallDiffs($, e.props, ui, await next(e)); + + return askOnRow(hostOf($), ui, drawn, { id: tool_use_id, isRunning, calls: [{ tool, input, output }] }); }); }; +async function withGroupDiffs( + $: EngineInterface, + props: { + isExpanded: boolean; + calls: readonly { tool: string; input: unknown; isRunning: boolean; tool_use_id?: string }[]; + }, + row: string, + ui: ElementTable, + drawn: RenderElement +): Promise { + if (props.isExpanded) { + return drawn; + } + + const calls = props.calls.flatMap((call) => + call.tool === "Bash" && !call.isRunning && call.tool_use_id ? [{ id: call.tool_use_id, input: call.input }] : [] + ); + const diffs = await fullDiffs($, calls); + + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { + return drawn; + } + + return drawDiffs($, ui, row, diffs, drawn); +} + +async function withCallDiffs( + $: EngineInterface, + props: { tool: string; isRunning: boolean; tool_use_id: string; input: unknown }, + ui: ElementTable, + drawn: RenderElement +): Promise { + if (props.tool !== "Bash" || props.isRunning) { + return drawn; + } + + const diffs = await fullDiffs($, [{ id: props.tool_use_id, input: props.input }]); + + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { + return drawn; + } + + return drawDiffs($, ui, 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 ea8fab3784..30ad8abff0 100644 --- a/plugins/genesis-tools-mods/hooks/sections.ts +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -1,3 +1,5 @@ +import { isRecord, modsSettings } from "./settings"; + /** One file of a diff-hook full-diff note: its path, its line counts and its unified-diff hunks. */ export interface Section { path: string; @@ -132,37 +134,14 @@ export async function sectionsOfCalls({ return { sections, omitted }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -/** - * 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. - */ -function modsSwitch(hooksFile: string | undefined, key: "fullDiff" | "collapseGitSync"): boolean { - if (hooksFile === undefined) { - return true; - } - - try { - const stored: unknown = JSON.parse(hooksFile); - const mods = isRecord(stored) ? stored.mods : undefined; - const value = isRecord(mods) ? mods[key] : undefined; - - return typeof value === "boolean" ? value : true; - } catch { - return true; - } -} - +/** `mods.diff.fullDiff` of the hooks file (the flat `mods.fullDiff` of before still counts); on by default. */ export function fullDiffEnabled(hooksFile: string | undefined): boolean { - return modsSwitch(hooksFile, "fullDiff"); + return modsSettings(hooksFile).diff.fullDiff; } +/** `mods.diff.collapseGitSync` of the hooks file (the flat `mods.collapseGitSync` of before still counts). */ export function collapseGitSyncEnabled(hooksFile: string | undefined): boolean { - return modsSwitch(hooksFile, "collapseGitSync"); + return modsSettings(hooksFile).diff.collapseGitSync; } /** The command of a Bash call's input, or an empty string. */ diff --git a/plugins/genesis-tools-mods/hooks/settings.ts b/plugins/genesis-tools-mods/hooks/settings.ts new file mode 100644 index 0000000000..c3c39566ea --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/settings.ts @@ -0,0 +1,122 @@ +// The `mods` section of the hooks file as this plugin reads it. No engine types here: the CLI's own tests import +// this file to hold its defaults equal to `src/agents/lib/hooks/config.ts`, outside the plugin's type world. + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The `mods` section of the hooks file, one object per feature (`tools agents hooks config set mods..`). + * The CLI keeps the same shape and defaults in `src/agents/lib/hooks/config.ts` (`ModsConfig`); a test holds the two + * equal, since this plugin runs outside the repository and cannot import it. + */ +export interface ModsSettings { + diff: { fullDiff: boolean; collapseGitSync: boolean }; + idleCompact: { enabled: boolean; minutes: number; minTokens: number }; + agentModel: { enabled: boolean; format: "label" | "short" }; + decisions: { enabled: boolean }; + links: { enabled: boolean }; + git: { enabled: boolean }; + askRow: { enabled: boolean }; + spinner: { enabled: boolean }; + cacheBand: { enabled: boolean; warnMinutes: number; others: boolean }; + tmpUsage: { enabled: boolean; minutes: number }; + agentsBand: { enabled: boolean; warnMinutes: number }; + hooks: { tokens: boolean; dedupe: boolean }; +} + +export const MODS_DEFAULTS: ModsSettings = { + diff: { fullDiff: true, collapseGitSync: true }, + idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, + agentModel: { enabled: true, format: "label" }, + decisions: { enabled: true }, + links: { enabled: true }, + git: { enabled: true }, + askRow: { enabled: true }, + spinner: { enabled: true }, + cacheBand: { enabled: true, warnMinutes: 10, others: true }, + tmpUsage: { enabled: true, minutes: 5 }, + agentsBand: { enabled: true, warnMinutes: 60 }, + hooks: { tokens: true, dedupe: true }, +}; + +function whole(value: unknown, min: number, max: number, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +/** + * The `mods` section of a hooks file. A missing or broken file, and a value of the wrong kind, keep the default. + * The flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.collapseGitSync`, `mods.idleCompact` as a boolean, + * `mods.idleCompactMinutes`, `mods.idleCompactMinTokens`) still count where the object leaves a field out. + */ +export function modsSettings(hooksFile: string | undefined): ModsSettings { + let held: Record = {}; + + try { + const stored: unknown = hooksFile === undefined ? undefined : JSON.parse(hooksFile); + const mods = isRecord(stored) ? stored.mods : undefined; + held = isRecord(mods) ? mods : {}; + } catch { + held = {}; + } + + const part = (key: string): Record => { + const value = held[key]; + + return isRecord(value) ? value : {}; + }; + const d = MODS_DEFAULTS; + const diff = part("diff"); + const idle = part("idleCompact"); + const legacyIdle = typeof held.idleCompact === "boolean" ? held.idleCompact : undefined; + const model = part("agentModel"); + const cache = part("cacheBand"); + const tmp = part("tmpUsage"); + const agents = part("agentsBand"); + const hooks = part("hooks"); + const enabled = (key: string, fallback: boolean) => bool(part(key).enabled, fallback); + + return { + diff: { + fullDiff: bool(diff.fullDiff, bool(held.fullDiff, d.diff.fullDiff)), + collapseGitSync: bool(diff.collapseGitSync, bool(held.collapseGitSync, d.diff.collapseGitSync)), + }, + idleCompact: { + enabled: bool(idle.enabled, legacyIdle ?? d.idleCompact.enabled), + minutes: whole(idle.minutes, 1, 59, whole(held.idleCompactMinutes, 1, 59, d.idleCompact.minutes)), + minTokens: whole( + idle.minTokens, + 0, + Number.MAX_SAFE_INTEGER, + whole(held.idleCompactMinTokens, 0, Number.MAX_SAFE_INTEGER, d.idleCompact.minTokens) + ), + }, + agentModel: { + enabled: bool(model.enabled, d.agentModel.enabled), + format: model.format === "short" || model.format === "label" ? model.format : d.agentModel.format, + }, + decisions: { enabled: enabled("decisions", d.decisions.enabled) }, + links: { enabled: enabled("links", d.links.enabled) }, + git: { enabled: enabled("git", d.git.enabled) }, + askRow: { enabled: enabled("askRow", d.askRow.enabled) }, + spinner: { enabled: enabled("spinner", d.spinner.enabled) }, + cacheBand: { + enabled: bool(cache.enabled, d.cacheBand.enabled), + warnMinutes: whole(cache.warnMinutes, 1, 59, d.cacheBand.warnMinutes), + others: bool(cache.others, d.cacheBand.others), + }, + tmpUsage: { + enabled: bool(tmp.enabled, d.tmpUsage.enabled), + minutes: whole(tmp.minutes, 1, 120, d.tmpUsage.minutes), + }, + agentsBand: { + enabled: bool(agents.enabled, d.agentsBand.enabled), + warnMinutes: whole(agents.warnMinutes, 1, 24 * 60, d.agentsBand.warnMinutes), + }, + hooks: { tokens: bool(hooks.tokens, d.hooks.tokens), dedupe: bool(hooks.dedupe, d.hooks.dedupe) }, + }; +} diff --git a/plugins/genesis-tools-mods/hooks/shared.ts b/plugins/genesis-tools-mods/hooks/shared.ts new file mode 100644 index 0000000000..d1e81d6d57 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/shared.ts @@ -0,0 +1,134 @@ +// Helpers every feature of this plugin shares: the host the features reach the engine through, where GenesisTools +// keeps its files, the `mods.*` switches of the hooks file, and the per-feature log the features write so their +// heuristics can be improved from real use. +// +// 🛑 Only `register.tsx` sees `$`. `claude plugin validate` refuses `$` passed into a function another file declares, +// and a `read`/`update` of an atom another file declares (checked 2026-10-23), so a feature file gets a `Host`: the +// closures `register.tsx` builds over `$`, each spelled `$.noun.method(...)` there. A feature keeps its state in +// module variables and calls `host.redraw()` after a change, which draws this plugin's `ui.render` hooks again. +import type { + AgentInfo, + CommandRunResult, + FsEntry, + FsStat, + ProcessRunInit, + ProcessRunResult, + PromptFillArgs, + ToolCallInput, + TurnCompleteInput, +} from "claude-code"; +import { type ModsSettings, modsSettings } from "./settings"; + +export { isRecord, MODS_DEFAULTS, type ModsSettings, modsSettings } from "./settings"; + +export interface Host { + run: (argv: readonly string[], init?: ProcessRunInit) => Promise; + read: (path: string) => Promise; + write: (path: string, text: string) => Promise; + list: (path: string) => Promise; + exists: (path: string) => Promise; + stat: (path: string) => Promise; + /** The validator lists every variable a module reads, so the names are fixed. */ + env: (name: "HOME" | "GENESIS_TOOLS_HOME") => Promise; + cwd: () => Promise; + sessionId: () => Promise; + messages: () => Promise; + agents: () => Promise; + every: (ms: number, fn: () => void) => void; + storeGet: (key: string) => Promise; + storeSet: (key: string, value: unknown) => Promise; + status: (text: string | undefined) => void; + fill: (args: PromptFillArgs) => Promise; + submit: (text: string) => Promise; + openPane: (id: string, title: string) => Promise; + closePane: (id: string) => Promise; + /** Draws this plugin's `ui.render` hooks again, so a band or pane shows the state just changed. */ + redraw: () => void; +} + +/** `/.genesis-tools/agents`; `GENESIS_TOOLS_HOME` replaces the home, as it does for every tool. */ +export async function agentsDir(host: Host): Promise { + const home = (await host.env("GENESIS_TOOLS_HOME")) ?? (await host.env("HOME")); + + return home ? `${home}/.genesis-tools/agents` : undefined; +} + +export async function readHooksFile(host: Host, dir: string | undefined): Promise { + return dir ? host.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; +} + +let cached: { at: number; value: ModsSettings } | undefined; + +/** `modsSettings` of the hooks file, read at most every 10 s: a band redraws often, and the file changes rarely. */ +export async function settingsOf(host: Host): Promise { + const now = Date.now(); + + if (cached && now - cached.at < 10_000) { + return cached.value; + } + + const value = modsSettings(await readHooksFile(host, await agentsDir(host))); + cached = { at: now, value }; + + return value; +} + +/** + * Appends one JSON line to `/mods-log/.jsonl`, the record of what a feature did, so its heuristics + * can be checked against what the person then did. `$.fs.write` replaces a file, so the append goes through `sh`. + */ +export async function logMod(host: Host, feature: string, record: Record): Promise { + const dir = await agentsDir(host); + + if (!dir) { + return; + } + + const line = `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`; + + await host + .run(["/bin/sh", "-c", 'mkdir -p "$(dirname "$1")" && cat >> "$1"', "sh", `${dir}/mods-log/${feature}.jsonl`], { + stdin: line, + timeoutMs: 5_000, + }) + .catch(() => undefined); +} + +/** `12s`, `4m 05s`, `1h 02m`. */ +export function shortDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + + if (s < 60) { + return `${s}s`; + } + + if (s < 3600) { + return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; + } + + return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`; +} + +/** + * One feature's part in the events several features share. The engine takes one unmatched hook per event from a + * plugin, so `register.tsx` holds that hook and calls each feature's part in turn. + */ +export interface Feature { + /** On session start: start timers, take a first reading. */ + start?: (host: Host) => Promise; + /** Slash commands, registered at session start and answered by `command.run`. */ + commands?: { + name: string; + description: string; + argumentHint?: string; + run: (host: Host, args: string) => Promise; + }[]; + /** Before a tool call runs; the function it returns runs when the call has ended, however it ended. */ + beforeTool?: (host: Host, e: ToolCallInput) => (() => void) | undefined; + /** After a tool call answered. */ + afterTool?: (host: Host, e: ToolCallInput) => Promise; + /** After a turn completed, the main loop's or a subagent's (`e.agentId`). */ + afterTurn?: (host: Host, e: TurnCompleteInput) => Promise; + /** After a loop compacted (the main loop when `agentId` is undefined). */ + compacted?: (agentId: string | undefined) => void; +} diff --git a/plugins/genesis-tools-mods/hooks/spinner.tsx b/plugins/genesis-tools-mods/hooks/spinner.tsx new file mode 100644 index 0000000000..918f562b0c --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/spinner.tsx @@ -0,0 +1,68 @@ +// Live progress on the spinner line: while the main loop waits on a tool, the line names the tool, what it runs and +// for how long, and how many subagents are working (`Sauteing… · Bash bun run test 2m 10s · 2 agents`). A timer +// draws the line again every 2 s while anything runs. `mods.spinner.enabled` switches it. +import { type Feature, type Host, isRecord, settingsOf, shortDuration } from "./shared"; + +const running = new Map(); +let agents = 0; +const BUSY = new Set(["pending", "running", "waiting"]); + +export function toolLabel(tool: string, input: unknown): string { + const held = isRecord(input) ? input : {}; + const what = [held.description, held.command, held.file_path, held.pattern, held.url].find( + (value): value is string => typeof value === "string" && value.trim() !== "" + ); + const short = what ? what.replace(/\s+/g, " ").trim() : ""; + + return short ? `${tool} ${short.length > 40 ? `${short.slice(0, 40)}…` : short}` : tool; +} + +/** The suffix after the spinner word: the engine's own, then the longest running tool and the busy agents. */ +export function progressSuffix(base: string, now: number): string { + const parts: string[] = []; + const oldest = [...running.values()].sort((a, b) => a.at - b.at)[0]; + + if (oldest) { + const more = running.size > 1 ? ` +${running.size - 1}` : ""; + parts.push(`${oldest.label} ${shortDuration(now - oldest.at)}${more}`); + } + + if (agents > 0) { + parts.push(`${agents} agent${agents === 1 ? "" : "s"}`); + } + + return parts.length ? `${base} · ${parts.join(" · ")}` : base; +} + +async function beat(host: Host): Promise { + if (!(await settingsOf(host)).spinner.enabled) { + return; + } + + const list = await host.agents().catch(() => []); + agents = list.filter((agent) => BUSY.has(agent.status)).length; + + if (running.size > 0 || agents > 0) { + host.redraw(); + } +} + +export const spinner: Feature = { + async start(host) { + host.every(2_000, () => { + void beat(host); + }); + }, + beforeTool(_host, e) { + if (e.agentId !== undefined) { + return undefined; + } + + const id = e.tool_use_id; + running.set(id, { label: toolLabel(e.tool, e), at: Date.now() }); + + return () => { + running.delete(id); + }; + }, +}; diff --git a/plugins/genesis-tools-mods/hooks/tmp-usage.tsx b/plugins/genesis-tools-mods/hooks/tmp-usage.tsx new file mode 100644 index 0000000000..814cc7587c --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/tmp-usage.tsx @@ -0,0 +1,93 @@ +// The /tmp row: how much this session's scratch folder (`/tmp/cc//`) holds and the free space on +// the system disk, every `mods.tmpUsage.minutes`. All of /tmp/cc is not measured: `du` over it took 75 s and 38 s of +// system CPU at 67 GB (2026-10-23), too much for a band. Each reading goes to `mods-log/tmp.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import type { TmpReading, TmpUsage } from "../types"; +import { type Feature, type Host, logMod, settingsOf } from "./shared"; + +let usage: TmpUsage = null; +const LOW_FREE_KB = 10 * 1024 * 1024; + +async function kbOf(host: Host, argv: string[], column: number): Promise { + const done = await host.run(argv, { timeoutMs: 60_000 }).catch(() => undefined); + const lines = done?.stdout.trim().split("\n") ?? []; + const value = Number(lines[lines.length - 1]?.trim().split(/\s+/)[column]); + + return Number.isFinite(value) && lines.length > 0 ? value : null; +} + +/** The main checkout's folder name, so every worktree of one repository shares its /tmp/cc project folder. */ +async function projectOf(host: Host): Promise { + const cwd = await host.cwd(); + const done = await host + .run(["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeoutMs: 5_000 }) + .catch(() => undefined); + const common = done?.exitCode === 0 ? done.stdout.trim() : ""; + const main = common.endsWith("/.git") ? common.slice(0, -5) : cwd; + + return main.split("/").filter(Boolean).pop() ?? "unknown"; +} + +async function measure(host: Host): Promise { + const settings = (await settingsOf(host)).tmpUsage; + + if (!settings.enabled) { + return; + } + + const folder = `/tmp/cc/${await projectOf(host)}/${await host.sessionId()}`; + const sessionKb = (await host.exists(folder)) ? await kbOf(host, ["du", "-sk", folder], 0) : 0; + const reading: TmpReading = { at: Date.now(), sessionKb, freeKb: await kbOf(host, ["df", "-k", "/"], 3) }; + + usage = reading; + host.redraw(); + await logMod(host, "tmp", { folder, ...reading }); +} + +export const tmpUsage: Feature = { + async start(host) { + const settings = (await settingsOf(host)).tmpUsage; + + host.every(settings.minutes * 60_000, () => { + void measure(host); + }); + void measure(host); + }, +}; + +export function sizeText(kb: number | null): string { + if (kb === null) { + return "?"; + } + + if (kb < 1024) { + return `${kb}K`; + } + + if (kb < 1024 * 1024) { + return `${Math.round(kb / 1024)}M`; + } + + return `${(kb / 1024 / 1024).toFixed(1)}G`; +} + +/** The band's /tmp row, or null while off or not measured yet. */ +export async function tmpRow(host: Host, ui: ElementTable): Promise { + const held = usage; + + if (!held || !(await settingsOf(host)).tmpUsage.enabled) { + return null; + } + + const { Box, Text } = ui; + const low = held.freeKb !== null && held.freeKb < LOW_FREE_KB; + + return ( + + {`tmp: session ${sizeText(held.sessionKb)} ·`} + + {`disk free ${sizeText(held.freeKb)}`} + + + ); +} diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx index ebb3964034..36c13a783c 100644 --- a/plugins/genesis-tools-mods/tests/agent-model.test.tsx +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -1,10 +1,11 @@ import type { AgentSpawnInput, On, TurnStepChunk } from "claude-code"; -import { expect, test } from "claude-code/testing"; +import { expect, mock, test } from "claude-code/testing"; // Run with `claude plugin test plugins/genesis-tools-mods`, with the fold tests. /** The engine's own spawn, beneath the plugin: records the description the plugin passed down. */ function engine(on: On, seen: string[]): void { + mock.env(on, { HOME: "/home/test" }); on("agent.spawn", async (_$, e) => { seen.push(e.description); @@ -56,6 +57,7 @@ test("a spawn that names no model keeps its description", async ($, on) => { /** The model's response beneath the plugin: an Agent call that names a model, an Agent call that does not, a Bash call. */ function response(on: On): void { + mock.env(on, { HOME: "/home/test" }); on("turn.step", async function* () { yield { kind: "tool", index: 0, id: "toolu_a", name: "Agent" }; yield { kind: "input", index: 0, json: '{"description":"Pair one",' }; diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx new file mode 100644 index 0000000000..dfe5bd6dc7 --- /dev/null +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -0,0 +1,206 @@ +import type { On, TurnStepChunk } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; +import { quoteOf } from "../hooks/ask-row"; +import { cacheRowsOf } from "../hooks/cache-band"; +import { openDecisions, stagedMessage } from "../hooks/decisions"; +import { checkCounts, parseStatus } from "../hooks/git-band"; +import { dedupeEntry, tokensText } from "../hooks/hook-ledger"; +import { targetFor } from "../hooks/links"; +import { toolLabel } from "../hooks/spinner"; + +// Run with `claude plugin test plugins/genesis-tools-mods`. The pure parts of each feature first, then the link +// fixer end to end over a streamed reply. + +test("open decisions: asked, minus answered and replaced, with their options", async () => { + const list = openDecisions([ + { + role: "assistant", + text: "❓ DECISION 3: colour step\n**a)** plugin\n**b)** highlight.js\n\n❓ DECISION 4 merge?\na) now\nb) later", + }, + { role: "user", text: "D3: b)" }, + { role: "assistant", text: "Repeat of [❓ DECISION 4](https://x) stays open.\n❓ DECISION 5 name\na) one" }, + { role: "assistant", text: "❓ DECISION 6 replaces ❓ DECISION 5\na) two" }, + ]); + + expect(list.map((decision) => decision.n)).toEqual([4, 6]); + expect(list[0]?.options).toEqual([ + { letter: "a", text: "now" }, + { letter: "b", text: "later" }, + ]); + expect(stagedMessage({ "6": "a)", "4": "my own words" })).toBe("D4: my own words\nD6: a)"); +}); + +test("git band: status line and CI counts", async () => { + expect(parseStatus("## feat/x...origin/feat/x [ahead 2, behind 1]\n M a.ts\n?? b.ts\n")).toEqual({ + branch: "feat/x", + ahead: 2, + behind: 1, + dirty: 2, + }); + expect(parseStatus("## HEAD (no branch)\n").branch).toBe("detached"); + expect( + checkCounts([ + { conclusion: "SUCCESS" }, + { conclusion: "FAILURE" }, + { state: "PENDING" }, + { conclusion: "", status: "IN_PROGRESS" }, + { state: "SUCCESS" }, + ]) + ).toEqual({ passed: 2, failed: 1, pending: 2 }); +}); + +test("hook ledger: a repeated entry and a repeated area block become receipts", async () => { + const held = new Map(); + const area = (path: string) => `Contents of ${path} (area rules):\n\n${"rule text ".repeat(40)}`; + const first = `The Bash call touched files.\n\n${area("/r/a.md")}\n\n${area("/r/b.md")}`; + + expect(dedupeEntry(first, held, 1).dropped).toEqual([]); + + const second = dedupeEntry(`The Read call touched files.\n\n${area("/r/b.md")}\n\n${area("/r/c.md")}`, held, 2); + expect(second.dropped).toEqual(["/r/b.md"]); + expect(second.text).toContain("Contents of /r/c.md (area rules)"); + expect(second.text).toContain("area rules /r/b.md repeated"); + expect(second.droppedChars).toBeGreaterThan(300); + + const whole = dedupeEntry(first, held, 3); + expect(whole.text.startsWith("[genesis-tools-mods:")).toBe(true); + expect(dedupeEntry("short", new Map([["5:0", 1]]), 4).text).toBe("short"); + expect(tokensText(38_200 * 4)).toBe("~38.2k"); +}); + +test("cache band: own row first, others only inside the warning window", async () => { + const minute = 60_000; + const now = 100 * minute; + const marks = [ + { sessionId: "other-late", label: "b", lastAt: now - 55 * minute, ttlMs: 60 * minute }, + { sessionId: "own", label: "a", lastAt: now - 10 * minute, ttlMs: 60 * minute }, + { sessionId: "other-soon", label: "c", lastAt: now - 58 * minute, ttlMs: 60 * minute }, + { sessionId: "other-fresh", label: "d", lastAt: now - 5 * minute, ttlMs: 60 * minute }, + { sessionId: "other-ended", label: "e", lastAt: now - 58 * minute, ttlMs: 60 * minute, ended: true }, + { sessionId: "other-cold", label: "f", lastAt: now - 70 * minute, ttlMs: 60 * minute }, + ]; + + expect(cacheRowsOf(marks, "own", 10 * minute, now).map((row) => row.sessionId)).toEqual([ + "own", + "other-soon", + "other-late", + ]); +}); + +test("ask quote and spinner label", async () => { + expect(quoteOf([{ tool: "Bash", input: { command: "ls -la" }, output: { stdout: "a\nb" } }])).toBe( + "> Bash: ls -la\n> a\n> b\n\n" + ); + expect(toolLabel("Bash", { command: "bun run test", description: "Run the suite" })).toBe("Bash Run the suite"); + expect(toolLabel("Read", {})).toBe("Read"); +}); + +test("link targets by kind", async () => { + expect(targetFor("/r/My Notes/a.md", "file", 3, 9)).toBe( + "https://genesis.tools/md/open?path=/r/My%20Notes/a.md&range=3-9" + ); + expect(targetFor("/r/src", "dir")).toBe("https://genesis.tools/finder/reveal?target=/r/src"); + expect(targetFor("/r/src/a.ts", "file", 12)).toBe("cursor://file/r/src/a.ts:12"); + expect(targetFor("/r/src/a (1).ts", "file")).toBe("/r/src/a%20%281%29.ts"); +}); + +/** The machine beneath the plugin: a repository at /repo with three paths and one commit. */ +function machine(on: On): void { + mock.env(on, { HOME: "/home/test" }); + const files: Record = { + "/repo/src/a.ts": "file", + "/repo/docs": "dir", + "/repo/notes/plan.md": "file", + }; + + on("fs.read", async () => { + throw new Error("no file"); + }); + on("fs.stat", async (_$, e) => { + const kind = files[e.path]; + + if (!kind) { + throw new Error(`ENOENT ${e.path}`); + } + + return { value: { kind, size: 1, mtimeMs: 1, isLink: false } }; + }); + on("session.cwd", async () => ({ value: "/repo" })); + on("session.id", async () => ({ value: "sess-1" })); + on("session.messages", async () => ({ + // The second message is this reply itself, past the request's `messageCount` of 1: it asks nothing earlier. + value: [ + { role: "assistant", text: "❓ DECISION 3 pick one\na) x", toolUses: [] }, + { role: "assistant", text: "not ❓ DECISION 9.", toolUses: [] }, + ], + })); + on("process.run", async (_$, e) => { + const argv = e.argv.join(" "); + const answer = (stdout: string, exitCode = 0) => ({ + value: { exitCode, stdout, stderr: "", isStdoutTruncated: false, isStderrTruncated: false }, + }); + + if (argv.endsWith("rev-parse --show-toplevel")) { + return answer("/repo\n"); + } + + if (argv.endsWith("remote get-url origin")) { + return answer("git@github.com:acme/widgets.git\n"); + } + + if (argv.endsWith("pr view 42 --json title,url")) { + return answer('{"title":"feat: widgets","url":"https://github.com/acme/widgets/pull/42"}'); + } + + if (argv.endsWith("cat-file -t abc1234f")) { + return answer("commit\n"); + } + + if (argv.includes("/mods-log/")) { + return answer(""); + } + + return answer("", 1); + }); + on("turn.step", async function* () { + yield { kind: "text", index: 0, text: "See src/a.ts:12 and `docs/` and **abc1" }; + yield { + kind: "text", + index: 0, + text: "234f**.\nRepeat ❓ DECISION 3 now, not ❓ DECISION 9.\n```\nsrc/a.ts\n```\n", + }; + yield { + kind: "text", + index: 0, + text: "Already [a](src/a.ts), https://b.c/src/a.ts#9, missing/x.ts, notes/plan.md, PR #42, #43", + }; + yield { kind: "stop", stopReason: "end_turn", usage: null }; + + return { turnId: "t1", index: 0, answer: "", toolUses: [], stopReason: "end_turn", usage: null }; + }); +} + +test("the link fixer links what resolves, as the reply streams, and leaves the rest", async ($, on) => { + machine(on); + + const stream = $.turn.step({ turnId: "t1", index: 0, model: "claude-haiku-5-5", messageCount: 1 }); + const chunks: TurnStepChunk[] = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + const text = chunks.map((chunk) => (chunk.kind === "text" ? chunk.text : "")).join(""); + + expect(text).toBe( + [ + "See [src/a.ts:12](cursor://file/repo/src/a.ts:12) and [docs/](https://genesis.tools/finder/reveal?target=/repo/docs) and [**abc1234f**](https://genesis.tools/hub?repo=acme%2Fwidgets&commit=abc1234f).", + "Repeat [❓ DECISION 3](https://genesis.tools/claude/find/sess-1/decision/3) now, not ❓ DECISION 9.", + "```", + "src/a.ts", + "```", + "Already [a](src/a.ts), https://b.c/src/a.ts#9, missing/x.ts, [notes/plan.md](https://genesis.tools/md/open?path=/repo/notes/plan.md), PR [#42 (feat: widgets)](https://github.com/acme/widgets/pull/42), #43", + ].join("\n") + ); + expect(chunks.at(-1)?.kind).toBe("stop"); +}); diff --git a/plugins/genesis-tools-mods/tests/fold.test.tsx b/plugins/genesis-tools-mods/tests/fold.test.tsx index 155b1fa549..aae2403a75 100644 --- a/plugins/genesis-tools-mods/tests/fold.test.tsx +++ b/plugins/genesis-tools-mods/tests/fold.test.tsx @@ -169,7 +169,7 @@ test("a git pull's diff starts hidden behind one button, which shows it and hide await ui.unmount(); }); -test("mods.collapseGitSync off draws a git pull like any other call", async ($, on) => { +test("mods.diff.collapseGitSync off (the flat key of before) 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}}', diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts index 8f4f0c4329..61b7300c32 100644 --- a/plugins/genesis-tools-mods/types/index.d.ts +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -4,8 +4,102 @@ export type FlippedFiles = string[]; /** The git-sync calls whose hidden diff the person opened: `|`, the newest 500. */ export type ShownCalls = string[]; +/** One option of a decision: its letter and its text. */ +export interface DecisionOption { + letter: string; + text: string; +} + +/** A `❓ DECISION N` the assistant asked and nobody answered or replaced yet. */ +export interface OpenDecision { + n: number; + title: string; + options: DecisionOption[]; +} + +/** The open decisions `/decisions` lists, as last read from the session's messages. */ +export type OpenDecisions = OpenDecision[]; + +/** What the person staged per decision number: `a)` for an option, or their own words. */ +export type StagedDecisions = Record; + +/** One pull request the git band shows. */ +export interface BandPr { + number: number; + title: string; + url: string; + state: string; + draft: boolean; + passed: number; + failed: number; + pending: number; + tracked: boolean; +} + +/** The git band's line, as the last refresh found it. */ +export interface GitBand { + at: number; + branch: string; + ahead: number; + behind: number; + dirty: number; + prs: BandPr[]; +} + +/** The git band of this session: `null` until the first refresh. */ +export type GitBandLine = GitBand | null; + +/** `/mod-git on|off` for this session; `null` follows `mods.git.enabled`. */ +export type GitBandSwitch = boolean | null; + +/** One session whose prompt cache runs out soon, as the cache band shows it. */ +export interface CacheRow { + sessionId: string; + label: string; + expiresAt: number; + own: boolean; +} + +/** The rows of the cache band: this session's, then other sessions under the warning threshold. */ +export type CacheRows = CacheRow[]; + +/** The last /tmp reading: KB of this session's scratch folder and free KB on the system disk. */ +export interface TmpReading { + at: number; + sessionKb: number | null; + freeKb: number | null; +} + +export type TmpUsage = TmpReading | null; + +/** A subagent this session spawned, as `agent.spawn` answered: its model, what it was for, when it started. */ +export interface SpawnedAgent { + model: string; + description: string; + at: number; +} + +export type SpawnedAgents = Record; + +/** What the hooks handed the model, per loop (`main` or a subagent id summed as `agents`). */ +export interface HookCount { + fires: number; + chars: number; +} + +/** The hook ledger: what settings hooks injected this session, and what the dedupe dropped as a repeat. */ +export interface HookLedger { + main: HookCount; + agents: HookCount; + byEvent: Record; + dropped: HookCount; +} + declare module "claude-code" { interface PluginState { - "genesis-tools-mods": { flipped: FlippedFiles; shown: ShownCalls }; + "genesis-tools-mods": { + flipped: FlippedFiles; + shown: ShownCalls; + }; } } diff --git a/src/agents/README.md b/src/agents/README.md index 613af2134d..190e17c107 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -212,26 +212,51 @@ from this repository's marketplace (`plugins/genesis-tools-mods/`). It is a Clau message cut lines, it draws the call's whole diff (the `hook-diffs/.md` note) with Claude Code's own 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 +`tools agents hooks config set mods.diff.fullDiff false --write` turns it off without an uninstall (the mod reads 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 +and `mods.diff.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 +`[ show diff from git pull ]` button, because it is other people's work arriving; `config set mods.diff.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 -(`mods.idleCompactMinutes`, 1 to 59), while the 1-hour prompt cache is still warm, so the next prompt does not +(`mods.idleCompact.minutes`, 1 to 59), while the 1-hour prompt cache is still warm, so the next prompt does not pay for the whole context again. Claude Code's own idle compaction tries once at 54 minutes and gives up at its first refused check (a rate-limit warning on the last reply is enough); the mod does not run that check, so a compaction can be spent while the account is in its warning. It skips a session that compacted after its last -turn, a context under `mods.idleCompactMinTokens` (200,000) and a session with no screen (`claude -p`), and it +turn, a context under `mods.idleCompact.minTokens` (200,000) and a session with no screen (`claude -p`), and it appends a notice that starts `Compacted while idle`. `tools agents hooks config set mods.idleCompact false --write` turns it off, also for a timer already armed. A reload of the mod drops the timer until the next turn ends. +**The daily mods.** The same plugin carries more features, each an object under `mods` in the hooks file, all on +by default; `config set mods. false --write` turns one off (it means `mods..enabled`), and the +flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.idleCompactMinutes`, …) are still read and still set. +Each feature appends what it did to `~/.genesis-tools/agents/mods-log/.jsonl`, so its heuristics can be +checked against real use. + +- `mods.agentModel` (`format` `label` or `short`): `sonnet · Pair one` or `(S) Pair one` on a subagent's rows. +- `mods.decisions`: `/decisions` opens the open `❓ DECISION N` questions in a pane. A press stages an option, a + field stages your own words, and `send` submits `D16: a)` per decision in one message. +- `mods.links`: the main loop's reply is linked as it streams, one line at a time. A path that exists (absolute, + `~/`, relative to the working folder or the repository, or a bare file name a tool of the session named), with + an optional `:line` or `:line-line`, a commit SHA that `git cat-file` knows, and a repeated `❓ DECISION N` become + links; code fences, inline code that does not resolve, existing links and URLs stay as they are. +- `mods.git`: a band line above the prompt with the branch, unpushed and unpulled commits, the dirty count and the + PRs (the branch's own and the ones `/mod-git pr add 123` tracks) with CI counts. `/mod-git on|off|toggle|refresh`. +- `mods.askRow`: `[ask]` beside a finished tool row puts the row, quoted, into the prompt. +- `mods.spinner`: the spinner line names the running tool, its time and the working subagents. +- `mods.cacheBand` (`warnMinutes` 10, `others`): the prompt cache countdown, red under `warnMinutes`, and the other + sessions whose cache runs out within it. +- `mods.tmpUsage` (`minutes` 5): the size of the session's `/tmp/cc` folder and the free disk. +- `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls. +- `mods.hooks` (`tokens`, `dedupe`): the status line counts what the settings and plugin hooks handed the model + (`hooks ~38.2k tok · 41 fires`, characters / 4), and a block already handed to the same loop since its last + compaction is replaced with a one-line receipt. + A file or a whole folder moved with `mv` or `git mv` is one line, `old/ → new/ (284 files)`, when the new copies match the old ones byte for byte (up to 4,000 files; a file over 4 MiB is not compared). diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index 2fef996364..9766e45f5d 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -729,7 +729,7 @@ export function registerHooksCommands(program: Command): void { /** * `hooks mods`: the Claude Code mods of this repository (the `genesis-tools-mods` plugin). They share the - * hooks file: `mods.fullDiff` switches the full-diff mod, which reads the diff hook's `hook-diffs` notes, and + * hooks file: `mods.diff.fullDiff` switches the full-diff mod, which reads the diff hook's `hook-diffs` notes, and * `mods.idleCompact` the idle-compaction mod. */ function registerMods(hooks: Command): void { @@ -766,13 +766,38 @@ 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`)}` - ); - - if (config.mods.fullDiff && fullDiff === "off") { + const onOff = (on: boolean) => (on ? pc.green("on") : pc.dim("off")); + const mods = config.mods; + const rows: [string, boolean, string][] = [ + ["mods.diff.fullDiff", mods.diff.fullDiff, ""], + ["mods.diff.collapseGitSync", mods.diff.collapseGitSync, ""], + [ + "mods.idleCompact", + mods.idleCompact.enabled, + `after ${mods.idleCompact.minutes} min · floor ${mods.idleCompact.minTokens} tokens`, + ], + ["mods.agentModel", mods.agentModel.enabled, `format ${mods.agentModel.format}`], + ["mods.decisions", mods.decisions.enabled, ""], + ["mods.links", mods.links.enabled, ""], + ["mods.git", mods.git.enabled, ""], + ["mods.askRow", mods.askRow.enabled, ""], + ["mods.spinner", mods.spinner.enabled, ""], + [ + "mods.cacheBand", + mods.cacheBand.enabled, + `red under ${mods.cacheBand.warnMinutes} min · others ${mods.cacheBand.others ? "on" : "off"}`, + ], + ["mods.tmpUsage", mods.tmpUsage.enabled, `every ${mods.tmpUsage.minutes} min`], + ["mods.agentsBand", mods.agentsBand.enabled, `red past ${mods.agentsBand.warnMinutes} min`], + ["mods.hooks.tokens", mods.hooks.tokens, ""], + ["mods.hooks.dedupe", mods.hooks.dedupe, ""], + ]; + + for (const [name, on, detail] of rows) { + ui.raw(`${name}: ${onOff(on)}${detail ? pc.dim(` · ${detail}`) : ""}`); + } + + if (mods.diff.fullDiff && fullDiff === "off") { ui.raw(pc.yellow(`diff.fullDiff is off, so no call has a full-diff note for the mod to draw.`)); } diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index bd9a068fdc..38d58693be 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -263,30 +263,55 @@ export interface AgentsTalkConfig { } /** - * The Claude Code mods of the `genesis-tools-mods` plugin (`tools agents hooks mods install`). The plugin - * reads this section of the hooks file itself, so a change here needs no reinstall. + * The Claude Code mods of the `genesis-tools-mods` plugin (`tools agents hooks mods install`), one object per + * feature (`mods..`). The plugin reads this section of the hooks file itself, so a change here + * needs no reinstall. It keeps the same shape and defaults in its own `hooks/shared.ts` (`ModsSettings`), since it + * runs outside this repository; `mods-plugin.test.ts` holds the two equal. */ export interface ModsConfig { + diff: { + /** + * Draw the whole diff of a call whose hook message was cut, with Claude Code's own diff element, + * 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; + }; /** - * Draw the whole diff of a call whose hook message was cut, with Claude Code's own diff element, - * 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. + * Compact an idle Claude session `minutes` 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, such as a rate-limit + * warning). Skips a context under `minTokens`. `minutes` is 1 to 59: after the engine's own try at 54 + * minutes, before the cache expires at 60. */ - collapseGitSync: boolean; + idleCompact: { enabled: boolean; minutes: number; minTokens: number }; + /** A subagent's model in front of its description: `sonnet · Pair one` (`label`) or `(S) Pair one` (`short`). */ + agentModel: { enabled: boolean; format: "label" | "short" }; + /** `/decisions`: the open decisions of the session in a pane, an answer staged per decision, sent in one message. */ + decisions: { enabled: boolean }; + /** Bare paths, folders, commits and repeated decisions in the main loop's reply become links as it streams. */ + links: { enabled: boolean }; + /** The git band above the prompt: branch, unpushed, dirty, PRs with CI; `/mod-git` switches it per session. */ + git: { enabled: boolean }; + /** `[ask]` beside a finished tool row: quotes the row into the prompt. */ + askRow: { enabled: boolean }; + /** The running tool, its time and the working subagents on the spinner line. */ + spinner: { enabled: boolean }; + /** The prompt cache countdown in the band, red under `warnMinutes`; `others` adds other sessions under it. */ + cacheBand: { enabled: boolean; warnMinutes: number; others: boolean }; + /** The size of the session's /tmp/cc scratch folder and the free disk, every `minutes`. */ + tmpUsage: { enabled: boolean; minutes: number }; + /** One band row per working subagent: model, age, idle time, tool calls; red past `warnMinutes`. */ + agentsBand: { enabled: boolean; warnMinutes: number }; /** - * 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, - * such as a rate-limit warning). Skips a context under `idleCompactMinTokens`. + * What the classic hooks hand the model: `tokens` pins the estimate as the plugin's status line, `dedupe` + * replaces a context block already given to the same loop since its last compaction with a one-line receipt. */ - idleCompact: boolean; - /** 1 to 59: after the engine's own try at 54 minutes, before the cache expires at 60. */ - idleCompactMinutes: number; - idleCompactMinTokens: number; + hooks: { tokens: boolean; dedupe: boolean }; } /** @@ -433,11 +458,18 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { }, agentsTalk: { hint: true }, mods: { - fullDiff: true, - collapseGitSync: true, - idleCompact: true, - idleCompactMinutes: 56, - idleCompactMinTokens: 200_000, + diff: { fullDiff: true, collapseGitSync: true }, + idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, + agentModel: { enabled: true, format: "label" }, + decisions: { enabled: true }, + links: { enabled: true }, + git: { enabled: true }, + askRow: { enabled: true }, + spinner: { enabled: true }, + cacheBand: { enabled: true, warnMinutes: 10, others: true }, + tmpUsage: { enabled: true, minutes: 5 }, + agentsBand: { enabled: true, warnMinutes: 60 }, + hooks: { tokens: true, dedupe: true }, }, unpushed: { enabled: true, maxCommits: 3, maxAgeMinutes: 60, remindEveryMinutes: 30 }, logPath: defaultLogPath(), @@ -1010,30 +1042,91 @@ export function mergeStoredConfig(stored: StoredHooksConfig): HooksConfig { const STOP_HOOK_MODES: readonly DecisionStopHook[] = ["off", "warn", "block"]; -/** Field by field, like `diff`: a stored value of the wrong type falls back to the default. */ -function mergeMods(stored: Partial | undefined): ModsConfig { +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function partOf(value: unknown): Record { + return isPlainObject(value) ? value : {}; +} + +/** + * Field by field, like `diff`: a stored value of the wrong type falls back to the default. The flat keys of before + * 2026-10-23 (`mods.fullDiff`, `mods.collapseGitSync`, `mods.idleCompact` as a boolean, `mods.idleCompactMinutes`, + * `mods.idleCompactMinTokens`) still count where the object leaves a field out; the next write stores the objects. + */ +export function mergeMods(stored: unknown): ModsConfig { const base = DEFAULT_HOOKS_CONFIG.mods; + const held = partOf(stored); + const diff = partOf(held.diff); + const idle = partOf(held.idleCompact); + const legacyIdle = typeof held.idleCompact === "boolean" ? held.idleCompact : undefined; + const model = partOf(held.agentModel); + const cache = partOf(held.cacheBand); + const tmp = partOf(held.tmpUsage); + const agents = partOf(held.agentsBand); + const hooks = partOf(held.hooks); + const enabled = (key: string, fallback: boolean) => boolOr(partOf(held[key]).enabled, fallback); const minutes = countOr({ - field: "mods.idleCompactMinutes", - value: stored?.idleCompactMinutes, - fallback: base.idleCompactMinutes, + field: "mods.idleCompact.minutes", + value: idle.minutes ?? held.idleCompactMinutes, + fallback: base.idleCompact.minutes, }); 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, - idleCompactMinTokens: countOr({ - field: "mods.idleCompactMinTokens", - value: stored?.idleCompactMinTokens, - fallback: base.idleCompactMinTokens, - min: 0, - }), + diff: { + fullDiff: boolOr(diff.fullDiff, boolOr(held.fullDiff, base.diff.fullDiff)), + collapseGitSync: boolOr(diff.collapseGitSync, boolOr(held.collapseGitSync, base.diff.collapseGitSync)), + }, + idleCompact: { + enabled: boolOr(idle.enabled, legacyIdle ?? base.idleCompact.enabled), + // The mod reads the file itself and drops 60 or more the same way. + minutes: minutes < 60 ? minutes : base.idleCompact.minutes, + minTokens: countOr({ + field: "mods.idleCompact.minTokens", + value: idle.minTokens ?? held.idleCompactMinTokens, + fallback: base.idleCompact.minTokens, + min: 0, + }), + }, + agentModel: { + enabled: boolOr(model.enabled, base.agentModel.enabled), + format: model.format === "short" || model.format === "label" ? model.format : base.agentModel.format, + }, + decisions: { enabled: enabled("decisions", base.decisions.enabled) }, + links: { enabled: enabled("links", base.links.enabled) }, + git: { enabled: enabled("git", base.git.enabled) }, + askRow: { enabled: enabled("askRow", base.askRow.enabled) }, + spinner: { enabled: enabled("spinner", base.spinner.enabled) }, + cacheBand: { + enabled: boolOr(cache.enabled, base.cacheBand.enabled), + warnMinutes: minutesOr("mods.cacheBand.warnMinutes", cache.warnMinutes, base.cacheBand.warnMinutes, 59), + others: boolOr(cache.others, base.cacheBand.others), + }, + tmpUsage: { + enabled: boolOr(tmp.enabled, base.tmpUsage.enabled), + minutes: minutesOr("mods.tmpUsage.minutes", tmp.minutes, base.tmpUsage.minutes, 120), + }, + agentsBand: { + enabled: boolOr(agents.enabled, base.agentsBand.enabled), + warnMinutes: minutesOr( + "mods.agentsBand.warnMinutes", + agents.warnMinutes, + base.agentsBand.warnMinutes, + 24 * 60 + ), + }, + hooks: { tokens: boolOr(hooks.tokens, base.hooks.tokens), dedupe: boolOr(hooks.dedupe, base.hooks.dedupe) }, }; } +/** A whole number of minutes from 1 to `max`, as the plugin reads it; else the default. */ +function minutesOr(field: string, value: unknown, fallback: number, max: number): number { + const minutes = countOr({ field, value, fallback }); + + return minutes <= max ? minutes : fallback; +} + function mergeUnpushed(stored: Partial | undefined): UnpushedConfig { const base = DEFAULT_HOOKS_CONFIG.unpushed; diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index 9e32a84bd0..a9daa09ed0 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -4111,7 +4111,7 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(modDrawsDiffs({ config: on, harness: "codex", isEnabled: yes })).toBe(false); expect( modDrawsDiffs({ - config: { ...on, mods: { ...on.mods, fullDiff: false } }, + config: { ...on, mods: { ...on.mods, diff: { ...on.mods.diff, fullDiff: false } } }, harness: "claude", isEnabled: yes, }) diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index 5a1b5b6011..142a3fcda9 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -730,7 +730,7 @@ function installedMod(sessionId: string | undefined): boolean { } /** - * Claude Code with the full-diff mod (`genesis-tools-mods`) loaded in this session and `mods.fullDiff` on: the mod + * Claude Code with the full-diff mod (`genesis-tools-mods`) loaded in this session and `mods.diff.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({ @@ -746,7 +746,7 @@ export function modDrawsDiffs({ }): boolean { return ( harness === "claude" && - config.mods.fullDiff && + config.mods.diff.fullDiff && diffFor(config, harness).fullDiff !== "off" && isEnabled(sessionId) ); diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 46a5bd0124..9000fa42ac 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -642,38 +642,50 @@ describe("applySetting", () => { expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "rules.find-from-root", "loud")).toThrow("takes one of"); }); - it("diff.commandLines and mods.fullDiff are set, checked and loaded", () => { + it("diff.commandLines and mods.diff.fullDiff are set, checked and loaded", () => { expect(DEFAULT_HOOKS_CONFIG.diff.commandLines).toBe(3); - expect(DEFAULT_HOOKS_CONFIG.mods.fullDiff).toBe(true); + expect(DEFAULT_HOOKS_CONFIG.mods.diff.fullDiff).toBe(true); expect(applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "0").diff.commandLines).toBe(0); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "false").mods.fullDiff).toBe(false); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.diff.fullDiff", "false").mods.diff.fullDiff).toBe(false); + // The flat key of before 2026-10-23 is an alias. + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "false").mods.diff.fullDiff).toBe(false); expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "-1")).toThrow(); - expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "off")).toThrow("true or false"); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.diff.fullDiff", "off")).toThrow("true or false"); const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-mods-")), "hooks.json"); - writeFileSync(path, SafeJSON.stringify({ diff: { commandLines: 5 }, mods: { fullDiff: false } })); + writeFileSync(path, SafeJSON.stringify({ diff: { commandLines: 5 }, mods: { diff: { fullDiff: false } } })); expect(loadHooksConfig(path).diff.commandLines).toBe(5); - expect(loadHooksConfig(path).mods.fullDiff).toBe(false); + expect(loadHooksConfig(path).mods.diff.fullDiff).toBe(false); }); - it("mods.idleCompact* are set, checked and loaded", () => { - expect(DEFAULT_HOOKS_CONFIG.mods.idleCompactMinutes).toBe(56); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact", "false").mods.idleCompact).toBe(false); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "50").mods.idleCompactMinutes).toBe(50); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinTokens", "0").mods.idleCompactMinTokens).toBe(0); - expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "60")).toThrow("1 to 59"); + it("mods.idleCompact.* are set, checked and loaded, and the flat keys of before still read", () => { + expect(DEFAULT_HOOKS_CONFIG.mods.idleCompact.minutes).toBe(56); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact", "false").mods.idleCompact.enabled).toBe(false); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact.minutes", "50").mods.idleCompact.minutes).toBe(50); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinTokens", "0").mods.idleCompact.minTokens).toBe(0); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact.minutes", "60")).toThrow("1 to 59"); const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-idle-")), "hooks.json"); - writeFileSync(path, SafeJSON.stringify({ mods: { idleCompactMinutes: 75, idleCompactMinTokens: 1000 } })); - expect(loadHooksConfig(path).mods).toEqual({ - fullDiff: true, - collapseGitSync: true, - idleCompact: true, - idleCompactMinutes: 56, - idleCompactMinTokens: 1000, - }); + writeFileSync( + path, + SafeJSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 75, idleCompactMinTokens: 1000 } }) + ); + expect(loadHooksConfig(path).mods.idleCompact).toEqual({ enabled: false, minutes: 56, minTokens: 1000 }); + }); + + it("the other mods keys are set and checked by the type of their default", () => { + const set = (key: string, value: string) => applySetting(DEFAULT_HOOKS_CONFIG, key, value).mods; + + expect(set("mods.agentModel.format", "short").agentModel).toEqual({ enabled: true, format: "short" }); + expect(() => set("mods.agentModel.format", "tiny")).toThrow("label or short"); + expect(set("mods.links", "false").links.enabled).toBe(false); + expect(set("mods.cacheBand.warnMinutes", "15").cacheBand.warnMinutes).toBe(15); + expect(() => set("mods.cacheBand.warnMinutes", "75")).toThrow("1 to 59"); + expect(set("mods.hooks.dedupe", "false").hooks).toEqual({ tokens: true, dedupe: false }); + expect(() => set("mods.nonsense.enabled", "true")).toThrow("Unknown mods key"); + expect(() => set("mods.links.colour", "red")).toThrow("Unknown mods key"); }); it("reads the mods plugin's row from claude plugin list --json", () => { diff --git a/src/agents/lib/hooks/mods-plugin.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts index 0684b29b08..70311c4513 100644 --- a/src/agents/lib/hooks/mods-plugin.test.ts +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -20,6 +20,8 @@ import { sectionsOf, sectionsOfCalls, } from "../../../../plugins/genesis-tools-mods/hooks/sections"; +import { MODS_DEFAULTS, modsSettings } from "../../../../plugins/genesis-tools-mods/hooks/settings"; +import { DEFAULT_HOOKS_CONFIG } from "./config"; import { overflowPart } from "./diff/full"; import { MOD_MAX_PATCH_CHARS, MODS_SESSION_VAR } from "./diff/run"; @@ -217,7 +219,7 @@ describe("idle compaction", () => { 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" + "mods.idleCompact.enabled 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"); @@ -240,3 +242,33 @@ describe("idle compaction", () => { expect(idleNotice({ minutes: 56, tokensBefore: undefined, tokensAfter: 1 })).not.toContain("tokens)"); }); }); + +describe("the mods section", () => { + it("the plugin's defaults are the CLI's, field for field", () => { + expect(MODS_DEFAULTS).toEqual(DEFAULT_HOOKS_CONFIG.mods); + }); + + it("the plugin reads the nested keys, and the flat keys of before where the object leaves a field out", () => { + const read = modsSettings( + SafeJSON.stringify({ + mods: { + fullDiff: false, + idleCompact: false, + idleCompactMinutes: 40, + diff: { collapseGitSync: false }, + agentModel: { format: "short" }, + cacheBand: { warnMinutes: 99 }, + hooks: { dedupe: false }, + }, + }) + ); + + expect(read.diff).toEqual({ fullDiff: false, collapseGitSync: false }); + expect(read.idleCompact).toEqual({ enabled: false, minutes: 40, minTokens: 200_000 }); + expect(read.agentModel).toEqual({ enabled: true, format: "short" }); + // Out of range keeps the default, as the CLI does. + expect(read.cacheBand.warnMinutes).toBe(10); + expect(read.hooks).toEqual({ tokens: true, dedupe: false }); + expect(modsSettings("{ not json")).toEqual(MODS_DEFAULTS); + }); +}); diff --git a/src/agents/lib/hooks/set-config.ts b/src/agents/lib/hooks/set-config.ts index 1009f1e1e1..28a44f2934 100644 --- a/src/agents/lib/hooks/set-config.ts +++ b/src/agents/lib/hooks/set-config.ts @@ -1,6 +1,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { SHELL_RULES } from "@genesiscz/utils/shell/rules"; import { + DEFAULT_HOOKS_CONFIG, FILL_MODES, type HarnessName, HIGHLIGHT_MODES, @@ -11,6 +12,8 @@ import { isCount, isMegabytes, loadHooksConfigForWrite, + type ModsConfig, + mergeMods, OTHERS_SUMMARIES, } from "./config"; import { FULL_DIFF_MODES } from "./diff/full"; @@ -68,10 +71,73 @@ export const SETTABLE_KEYS = [ "decisions.injectAnswers", "decisions.staleness.", "agentsTalk.hint", - "mods.", - "mods.", + "mods.diff.", + "mods.idleCompact.", + "mods.agentModel.", + "mods..enabled", + "mods.cacheBand.", + "mods.tmpUsage.", + "mods.agentsBand.", + "mods.hooks.", ] as const; +/** The flat keys of before 2026-10-23, still taken; `mods.` alone means its `enabled`. */ +const MOD_ALIASES: Record = { + "mods.fullDiff": "mods.diff.fullDiff", + "mods.collapseGitSync": "mods.diff.collapseGitSync", + "mods.idleCompactMinutes": "mods.idleCompact.minutes", + "mods.idleCompactMinTokens": "mods.idleCompact.minTokens", +}; + +/** The highest whole number each numeric mods field takes, and the lowest when it is not 1. */ +const MOD_NUMBER_LIMITS: Record = { + "idleCompact.minutes": { max: 59, why: "at 60 the 1-hour prompt cache has expired" }, + "idleCompact.minTokens": { max: Number.MAX_SAFE_INTEGER, min: 0, why: "" }, + "cacheBand.warnMinutes": { max: 59, why: "the cache lives 60 minutes" }, + "tmpUsage.minutes": { max: 120, why: "a reading older than 2 hours says little" }, + "agentsBand.warnMinutes": { max: 24 * 60, why: "a day" }, +}; + +function isFields(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** One `mods..` key, checked against the type of the field's default. */ +function withMods(mods: ModsConfig, key: string, value: string): ModsConfig { + const path = MOD_ALIASES[key] ?? key; + const [, feature = "", field = "enabled"] = path.split("."); + const base = new Map(Object.entries(DEFAULT_HOOKS_CONFIG.mods)).get(feature); + const held = new Map(Object.entries(mods)).get(feature); + + if (!isFields(base) || !isFields(held) || !(field in base)) { + throw new Error(`Unknown mods key ${SafeJSON.stringify(key)}; see the mods.* keys in --help`); + } + + const part = { ...held }; + const sample = base[field]; + + if (typeof sample === "boolean") { + part[field] = asBoolean(key, value); + } else if (typeof sample === "number") { + const limit = MOD_NUMBER_LIMITS[`${feature}.${field}`]; + const count = asCount(key, value, limit?.min ?? 1); + + if (limit && count > limit.max) { + throw new Error(`${key} takes ${limit.min ?? 1} to ${limit.max}: ${limit.why}`); + } + + part[field] = count; + } else if (feature === "agentModel" && field === "format") { + if (value !== "label" && value !== "short") { + throw new Error(`${key} takes label or short, not ${SafeJSON.stringify(value)}`); + } + + part[field] = value; + } + + return mergeMods({ ...mods, [feature]: part }); +} + /** One `decisions.*` key. The stop hook takes off|warn|block; `harnesses` a comma list. */ function withDecisions(next: HooksConfig, key: string, value: string): HooksConfig { const decisions = { ...next.decisions, staleness: { ...next.decisions.staleness } }; @@ -422,24 +488,8 @@ export function applySetting( return next; } - if (key === "mods.fullDiff" || key === "mods.collapseGitSync" || key === "mods.idleCompact") { - next.mods = { ...config.mods, [key.slice("mods.".length)]: asBoolean(key, value) }; - return next; - } - - if (key === "mods.idleCompactMinutes") { - const minutes = asCount(key, value); - - if (minutes > 59) { - throw new Error(`${key} takes 1 to 59: at 60 the 1-hour prompt cache has expired`); - } - - next.mods = { ...config.mods, idleCompactMinutes: minutes }; - return next; - } - - if (key === "mods.idleCompactMinTokens") { - next.mods = { ...config.mods, idleCompactMinTokens: asCount(key, value, 0) }; + if (key.startsWith("mods.")) { + next.mods = withMods(config.mods, key, value); return next; } From 432543de43d680936b27156f26b04c0033143db3 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 14:38:46 +0200 Subject: [PATCH 014/251] fix(codex, babysit): #507 followup - the review gate keeps underscores and spaces in paths and fails a named unread file, records each check for review-depth, which reads only the current run, an import fails loudly and redoes a partial one, and the review spawn line quotes every value --- .../skills/babysit/scripts/review-brief.ts | 18 +- .../skills/babysit/scripts/scripts.test.ts | 28 ++- src/codex/commands/reviews.ts | 46 +--- src/codex/lib/review/controller.ts | 9 + src/codex/lib/review/gate.ts | 43 +++- src/codex/lib/review/import.ts | 123 +++++++--- src/codex/lib/review/ledger.ts | 30 ++- src/codex/lib/review/report.ts | 72 +++++- src/codex/lib/review/review.test.ts | 223 +++++++++++++++++- 9 files changed, 497 insertions(+), 95 deletions(-) diff --git a/.claude/skills/babysit/scripts/review-brief.ts b/.claude/skills/babysit/scripts/review-brief.ts index a8173ad733..4eab3e0988 100644 --- a/.claude/skills/babysit/scripts/review-brief.ts +++ b/.claude/skills/babysit/scripts/review-brief.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { shellWord } from "@genesiscz/utils/shell/quote"; import { flag, has, loadRun, reviewScratch } from "./run"; export interface ReviewBriefInput { @@ -75,13 +76,22 @@ export interface ReviewSpawnInput { postMerge?: { mergeCommit: string; files: string[] }; } -/** The reviewer's spawn line: `--mode review` with the base, so the harness computes the manifest and gates the depth. */ +/** + * The reviewer's spawn line: `--mode review` with the base, so the harness computes the manifest and gates the depth. + * Every value is one shell word: a file name, branch or path from the repository may hold a space or `$(...)`, and + * the lead runs this line as printed. + */ export function reviewSpawnCommand({ pr, wt, base, briefPath, postMerge }: ReviewSpawnInput): string { const target = postMerge - ? `--base ${postMerge.mergeCommit}^ --paths ${postMerge.files.join(" ")}` - : `--base origin/${base}`; + ? ["--base", `${postMerge.mergeCommit}^`, "--paths", ...postMerge.files] + : ["--base", `origin/${base}`]; + const argv = [ + ...["tools", "codex", "spawn", "--mode", "review", ...target], + ...["--name", `bs-${pr}-review`, "--model", "gpt-6.1-sol", "--write", "ask", "--cwd", wt], + ...["--label", `babysit pr${pr} review`, "--prompt-file", briefPath], + ]; - return `tools codex spawn --mode review ${target} --name bs-${pr}-review --model gpt-6.1-sol --write ask --cwd ${wt} --label "babysit pr${pr} review" --prompt-file ${briefPath}`; + return argv.map(shellWord).join(" "); } interface PrView { diff --git a/.claude/skills/babysit/scripts/scripts.test.ts b/.claude/skills/babysit/scripts/scripts.test.ts index fc5636a6e1..9268c26e18 100644 --- a/.claude/skills/babysit/scripts/scripts.test.ts +++ b/.claude/skills/babysit/scripts/scripts.test.ts @@ -673,7 +673,7 @@ describe("independent review brief", () => { expect( reviewSpawnCommand({ pr: 498, wt: "/w", base: "feat/parent", briefPath: "/run/steer-review-498.md" }) ).toBe( - 'tools codex spawn --mode review --base origin/feat/parent --name bs-498-review --model gpt-6.1-sol --write ask --cwd /w --label "babysit pr498 review" --prompt-file /run/steer-review-498.md' + "tools codex spawn --mode review --base origin/feat/parent --name bs-498-review --model gpt-6.1-sol --write ask --cwd /w --label 'babysit pr498 review' --prompt-file /run/steer-review-498.md" ); expect( reviewSpawnCommand({ @@ -683,7 +683,31 @@ describe("independent review brief", () => { briefPath: "/b.md", postMerge: { mergeCommit: "abc123", files: ["src/a.ts", "src/b.ts"] }, }) - ).toContain("--mode review --base abc123^ --paths src/a.ts src/b.ts --name bs-498-review"); + ).toContain("--mode review --base 'abc123^' --paths src/a.ts src/b.ts --name bs-498-review"); + }); + + test("every value of the spawn line stays one literal shell word: spaces and $(...) in names do not split or run", () => { + const command = reviewSpawnCommand({ + pr: 498, + wt: "/tmp/my wt", + base: "feat/$(id)", + briefPath: "/run/brief file.md", + postMerge: { mergeCommit: "abc123", files: ["src/my file.ts", "$(touch /tmp/review-probe).ts"] }, + }); + expect(command).toContain("--paths 'src/my file.ts' '$(touch /tmp/review-probe).ts' --name"); + expect(command).toContain("--cwd '/tmp/my wt'"); + expect(command).toContain("--prompt-file '/run/brief file.md'"); + + // The shell itself reads it back as the intended argv, without running the substitution. + const parsed = Bun.spawnSync(["sh", "-c", `printf '%s\\n' ${command}`], { env: process.env, stdout: "pipe" }); + const argv = parsed.stdout.toString().trimEnd().split("\n"); + expect(argv).toContain("src/my file.ts"); + expect(argv).toContain("$(touch /tmp/review-probe).ts"); + expect(argv).toContain("/tmp/my wt"); + expect(argv[argv.indexOf("--label") + 1]).toBe("babysit pr498 review"); + expect(reviewSpawnCommand({ pr: 498, wt: "/w", base: "feat/$(id)", briefPath: "/b.md" })).toContain( + "--base 'origin/feat/$(id)'" + ); }); }); describe("planted-regression summary", () => { diff --git a/src/codex/commands/reviews.ts b/src/codex/commands/reviews.ts index a8cce6488f..009d3512f3 100644 --- a/src/codex/commands/reviews.ts +++ b/src/codex/commands/reviews.ts @@ -15,15 +15,12 @@ import type { Command } from "commander"; import pc from "picocolors"; import { checkReview, type ReviewGateChecks } from "../lib/review/gate"; import { importReviewSessions, lastAgentMessage } from "../lib/review/import"; -import { - type ReviewGateRecord, - type ReviewLedgerRecord, - readReviewRecords, - reviewLedgerPath, -} from "../lib/review/ledger"; +import { type ReviewGateRecord, readReviewRecords, reviewLedgerPath } from "../lib/review/ledger"; import { buildManifest, defaultReviewGitReaders } from "../lib/review/prepare"; import { + currentReviewRecords, filterReviews, + gateCheckRows, parseReviewSince, type ReviewSummary, reviewTotals, @@ -184,13 +181,19 @@ interface ImportOptions { json?: boolean; } -async function runImport(names: string[], options: ImportOptions): Promise { +/** Exported for the test of both output formats; the CLI reaches it through `reviews import`. */ +export async function runImport(names: string[], options: ImportOptions): Promise { const results = await importReviewSessions({ names, ...(options.base ? { base: options.base } : {}), cwdFromSession: options.cwdFromSession, }); + // Before either output: automation reading --json needs the same exit status as a person reading the table. + if (results.some((result) => result.status === "missing" || result.status === "failed")) { + process.exitCode = 1; + } + if (options.json) { out.result({ ledger: reviewLedgerPath(), results }); return; @@ -213,10 +216,6 @@ async function runImport(names: string[], options: ImportOptions): Promise out.println(table.toString()); const imported = results.filter((result) => result.status === "imported").length; out.println(`${imported} imported, ${results.length - imported} not, into ${reviewLedgerPath()}`); - - if (results.some((result) => result.status === "missing")) { - process.exitCode = 1; - } } interface DepthOptions { @@ -229,26 +228,7 @@ function checkLine(label: string, ok: boolean, detail: string): string { } function gateLines(gate: ReviewGateRecord): string[] { - const coverageOk = gate.coverage.reviewed !== null && gate.coverage.reviewed >= gate.coverage.total; - const reasoningOk = gate.reasoningPer100Lines === null || gate.reasoningPer100Lines >= gate.floor; - - return [ - checkLine( - "coverage", - coverageOk, - `${gate.coverage.reviewed ?? "no line"} of ${gate.coverage.total} files${gate.unread.length > 0 ? `; unread: ${gate.unread.slice(0, 8).join(", ")}${gate.unread.length > 8 ? ", …" : ""}` : ""}` - ), - checkLine( - "reasoning", - reasoningOk, - `${gate.reasoningPer100Lines ?? "unknown"} tokens per 100 changed lines (floor ${gate.floor})` - ), - checkLine( - "shape", - gate.malformedFindings === 0, - `${gate.findings} findings, ${gate.malformedFindings} malformed` - ), - ]; + return gateCheckRows(gate).map((row) => checkLine(row.label, row.ok, row.detail)); } function checksLines(checks: ReviewGateChecks): string[] { @@ -319,9 +299,7 @@ async function runReviewDepth(name: string, options: DepthOptions): Promise record.name === name && record.chain === review.chain - ); + const records = currentReviewRecords(readReviewRecords().records, name, review.chain); const gates = records.filter((record): record is ReviewGateRecord => record.kind === "gate"); const lastGate = gates.at(-1); diff --git a/src/codex/lib/review/controller.ts b/src/codex/lib/review/controller.ts index 5f77f3022a..9f09b87a4e 100644 --- a/src/codex/lib/review/controller.ts +++ b/src/codex/lib/review/controller.ts @@ -163,6 +163,15 @@ function writeGateRecord(options: { findings: checks?.shape.findings ?? 0, malformedFindings: checks?.shape.malformed.length ?? 0, unread: checks?.coverage.unread.slice(0, UNREAD_RECORDED) ?? [], + ...(checks + ? { + checks: { + coverage: { ok: checks.coverage.ok, detail: checks.coverage.detail }, + reasoning: { ok: checks.reasoning.ok, detail: checks.reasoning.detail }, + shape: { ok: checks.shape.ok, detail: checks.shape.detail }, + }, + } + : {}), ...(options.escalatedTo ? { escalatedTo: options.escalatedTo } : {}), spent: review.spent ?? { turns: 0, diff --git a/src/codex/lib/review/gate.ts b/src/codex/lib/review/gate.ts index ca6b2fcdd4..8e9222c164 100644 --- a/src/codex/lib/review/gate.ts +++ b/src/codex/lib/review/gate.ts @@ -35,13 +35,27 @@ export interface ParsedReviewReport { ran: boolean; } -/** Markdown emphasis and code ticks a model wraps around the fixed lines; they carry no meaning here. */ +/** + * A pair of `*`, `**`, `_` or `__` around a phrase: opened after a space or the line start, closed before a space, + * punctuation or the line end. An underscore inside a word (`app_icon.png`, `__init__.py`) is not emphasis. + */ +const EMPHASIS = /(^|[\s([])(\*{1,2}|_{1,2})(?=\S)(.*?\S)\2(?=$|[\s)\]:,;!?]|\.(?:\s|$))/g; + +/** Markdown emphasis, code ticks and a bullet a model wraps around the fixed lines; they carry no meaning here. */ function plain(line: string): string { - return line.replace(/[*_`]/g, "").trim(); + let text = line.replace(/`/g, "").replace(/^\s*[*-]\s+/, ""); + + // Twice at most: `***both***` is one pair inside another. + for (let pass = 0; pass < 2; pass++) { + text = text.replace(EMPHASIS, "$1$3"); + } + + return text.trim(); } const FINDING_START = /^(\d+)[.)]\s+(.*)$/; -const FINDING_SHAPE = /^\[(critical|high|medium|low)\]\s+\S+:\d+/i; +/** `[Severity] path:line`: the path runs up to its `:line`, so a path with spaces in it (`src/my file.ts:12`) parses. */ +const FINDING_SHAPE = /^\[(critical|high|medium|low)\]\s+\S(?:[^:]*?\S)?:\d+(?=$|[\s:,;)\-\u2013\u2014])/i; const SECTION_END = /^(ran|files reviewed|not reviewed|result|at|changed|verify|open|findings):/i; function parseNotReviewed(value: string): ParsedReviewReport["notReviewed"] { @@ -137,7 +151,12 @@ export function reasoningPer100Lines(reasoningTokens: number | null, manifest: R return Math.round((reasoningTokens * 100) / Math.max(1, manifestChangedLines(manifest))); } -function checkCoverage( +/** + * Coverage against the manifest: the reported total is the manifest's, the reviewed count plus the excused binary + * files reach it, and the Not reviewed line names no text file. Exported for `reviews import`, which reaches the + * same verdict from a backfilled final message. + */ +export function checkCoverage( parsed: ParsedReviewReport, manifest: ReviewManifest, report: string @@ -156,15 +175,19 @@ function checkCoverage( const { reviewed } = parsed.coverage; const binary = new Set(manifest.files.filter((file) => file.binary).map((file) => file.path)); - // A binary file has no text to read: named with a reason, it does not count as unread. - const excused = parsed.notReviewed.filter((entry) => entry.reason && binary.has(entry.path)).length; - const ok = parsed.coverage.total === total && reviewed + excused >= total; + // A binary file has no text to read: named with a reason, it does not count as unread. Each one counts once, + // so naming the same binary twice cannot make up for a text file left unread. + const excused = new Set( + parsed.notReviewed.filter((entry) => entry.reason && binary.has(entry.path)).map((entry) => entry.path) + ).size; + // A text file on the Not reviewed line is an admitted gap, whatever the count says. + const named = [...new Set(parsed.notReviewed.map((entry) => entry.path).filter((path) => !binary.has(path)))]; + const ok = parsed.coverage.total === total && reviewed + excused >= total && named.length === 0; if (ok) { return { ok, detail: `${reviewed} of ${total} files reviewed`, reviewed, total, unread: [] }; } - const named = parsed.notReviewed.map((entry) => entry.path).filter((path) => !binary.has(path)); const missing = total - reviewed - excused; // The worker named fewer files than it skipped: the manifest files its report never mentions are the best guess. const unmentioned = @@ -174,7 +197,9 @@ function checkCoverage( const detail = parsed.coverage.total !== total ? `reported ${reviewed} of ${parsed.coverage.total}, but the manifest has ${total} files` - : `${reviewed} of ${total} files reviewed`; + : reviewed + excused >= total + ? `${reviewed} of ${total} files reviewed, but Not reviewed names ${named.length} text file(s)` + : `${reviewed} of ${total} files reviewed`; return { ok, detail, reviewed, total, unread: [...new Set([...named, ...unmentioned])] }; } diff --git a/src/codex/lib/review/import.ts b/src/codex/lib/review/import.ts index 1f4084d00e..c19cbcd98f 100644 --- a/src/codex/lib/review/import.ts +++ b/src/codex/lib/review/import.ts @@ -4,9 +4,15 @@ import { logger } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; import { isReviewEffort, manifestChangedLines, type ReviewManifest } from "@genesiscz/utils/worker/contract"; import { type CodexSessionMeta, CodexSessionStore } from "../store"; -import { parseReviewReport, REASONING_FLOOR_PER_100_LINES, reasoningPer100Lines } from "./gate"; import { - appendReviewRecord, + checkCoverage, + type ParsedReviewReport, + parseReviewReport, + REASONING_FLOOR_PER_100_LINES, + reasoningPer100Lines, +} from "./gate"; +import { + appendReviewRecords, type ReviewLedgerBase, type ReviewLedgerRecord, readReviewRecords, @@ -85,7 +91,8 @@ export interface ReviewImportManifest { export interface ReviewImportResult { name: string; - status: "imported" | "skipped" | "missing"; + /** `failed`: the ledger write failed, nothing of this review is in the ledger, and a rerun imports it. */ + status: "imported" | "skipped" | "missing" | "failed"; verdict?: ReviewVerdict; manifest: ReviewImportManifest | null; /** Why something is missing or approximate, in one sentence per fact. */ @@ -158,11 +165,18 @@ function spentOf(rows: readonly WorkerCostRow[]): CodexReviewSpent { }; } -/** The verdict the final message's coverage line implies: n of n passes, fewer is shallow, none is `imported`. */ +/** + * The verdict the final message's coverage line implies: fewer files than its own total is `shallow`; a pass needs + * the gate's coverage check against the rebuilt manifest. A full count whose scope cannot be confirmed (no manifest, + * another total, an unread text file named) stays `imported`, and so does a message with no coverage line. + */ function impliedVerdict( - coverage: { reviewed: number; total: number } | null, - manifest: ReviewManifest | null + parsed: ParsedReviewReport, + manifest: ReviewManifest | null, + report: string ): { verdict: ReviewVerdict; reason: string } { + const { coverage } = parsed; + if (!coverage) { return { verdict: "imported", @@ -175,17 +189,39 @@ function impliedVerdict( ? `; the reviewed manifest has ${manifest.files.length} files` : ""; - if (coverage.reviewed >= coverage.total) { + if (coverage.reviewed < coverage.total) { return { - verdict: "pass", - reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}${mismatch}`, + verdict: "shallow", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, ${coverage.total - coverage.reviewed} unread${mismatch}`, }; } - return { - verdict: "shallow", - reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, ${coverage.total - coverage.reviewed} unread${mismatch}`, - }; + if (!manifest) { + return { + verdict: "imported", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, but no manifest to confirm the scope`, + }; + } + + const check = checkCoverage(parsed, manifest, report); + + return check.ok + ? { verdict: "pass", reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}` } + : { verdict: "imported", reason: `imported: ${check.detail}; the scope is not confirmed` }; +} + +/** + * Whether the ledger already holds this name: a live review line, or an import that reached its gate line. An + * imported start without a gate is a partial import (an older run whose later writes failed): it is redone. + */ +function ledgerState(records: readonly ReviewLedgerRecord[], name: string): "absent" | "present" | "partial" { + const mine = records.filter((record) => record.name === name); + + if (mine.length === 0) { + return "absent"; + } + + return mine.some((record) => !record.imported || record.kind === "gate") ? "present" : "partial"; } async function importOne(name: string, input: ReviewImportInput, readers: ReviewImportReaders, ledgerPath: string) { @@ -200,7 +236,9 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review } satisfies ReviewImportResult; } - if (readers.ledgerRecords().some((record) => record.name === name)) { + const state = ledgerState(readers.ledgerRecords(), name); + + if (state === "present") { return { name, status: "skipped", @@ -224,6 +262,10 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review const parsed = parseReviewReport(report); const notes = [...outcome.notes]; + if (state === "partial") { + notes.push("an earlier import of this name has no gate line; this import replaces it"); + } + if (rows.length === 0) { notes.push("no booked turns in the worker cost ledger"); } @@ -248,18 +290,14 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review imported: true, ...(outcome.head ? { head: outcome.head } : {}), }; - const at = (ts: string) => ({ ...base, ts }); - - appendReviewRecord( - base, - { kind: "start", cwd: meta.cwd, effortSource: "imported", allowShallow: false }, - ledgerPath - ); - - rows.forEach((row, index) => { - appendReviewRecord( - at(row.ts), - { + const spent = spentOf(rows); + const { verdict, reason } = impliedVerdict(parsed, manifest, report); + const records: ReviewLedgerRecord[] = [ + { ...base, kind: "start", cwd: meta.cwd, effortSource: "imported", allowShallow: false }, + ...rows.map( + (row, index): ReviewLedgerRecord => ({ + ...base, + ts: row.ts, kind: "turn", turn: row.turn ?? index + 1, outcome: row.outcome ?? "completed", @@ -271,16 +309,11 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review costUsd: row.cost.apiEquivalentUsd, // Only the final message survives, so only the last turn carries a coverage line. coverage: index === rows.length - 1 ? parsed.coverage : null, - }, - ledgerPath - ); - }); - - const spent = spentOf(rows); - const { verdict, reason } = impliedVerdict(parsed.coverage, manifest); - appendReviewRecord( - at(rows.at(-1)?.ts ?? meta.lastEventAt), + }) + ), { + ...base, + ts: rows.at(-1)?.ts ?? meta.lastEventAt, kind: "gate", step: 0, verdict, @@ -296,8 +329,19 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review unread: parsed.notReviewed.map((entry) => entry.path).slice(0, 40), spent, }, - ledgerPath - ); + ]; + // One write for the whole review: a failure leaves nothing behind that the already-imported check would trust. + const written = appendReviewRecords(records, ledgerPath); + + if (!written.ok) { + return { + name, + status: "failed", + manifest: null, + notes: [...notes, `the review ledger write failed (${written.error}); nothing was imported`], + } satisfies ReviewImportResult; + } + log.debug({ name, verdict, files: base.files, base: base.base, head: base.head }, "review imported"); return { @@ -319,8 +363,9 @@ async function importOne(name: string, input: ReviewImportInput, readers: Review /** * Backfills the review ledger from codex sessions that ran before it existed: one start line, one turn line per - * booked turn, and one gate line whose verdict the final message's coverage line implies. A session already in the - * ledger is skipped, so a second import of the same names adds nothing. + * booked turn, and one gate line whose verdict the final message's coverage line implies, written together. A + * session already in the ledger is skipped, so a second import of the same names adds nothing; one whose earlier + * import never reached its gate line is imported again. */ export async function importReviewSessions(input: ReviewImportInput): Promise { const ledgerPath = input.ledgerPath ?? reviewLedgerPath(); diff --git a/src/codex/lib/review/ledger.ts b/src/codex/lib/review/ledger.ts index 5ff7fd99d2..a94d538caa 100644 --- a/src/codex/lib/review/ledger.ts +++ b/src/codex/lib/review/ledger.ts @@ -1,10 +1,13 @@ -import { join } from "node:path"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; import { codexRoot } from "@genesiscz/utils/codex/worker-paths"; +import { SafeJSON } from "@genesiscz/utils/json"; import { readJsonlRows } from "@genesiscz/utils/jsonl"; import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; import { logger } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; import { manifestChangedLines } from "@genesiscz/utils/worker/contract"; +import type { GateCheck } from "./gate"; import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; const log = logger.child({ component: "codex:review-ledger" }); @@ -68,6 +71,11 @@ export interface ReviewGateRecord extends ReviewLedgerBase { malformedFindings: number; /** The unread files the gate named, first 40. */ unread: string[]; + /** + * Each check's own result and sentence, as the gate decided it. Absent on a gate line written before this field, + * on an imported one and on a `markReviewShallow` one; `review-depth` then rebuilds an approximation. + */ + checks?: { coverage: GateCheck; reasoning: GateCheck; shape: GateCheck }; escalatedTo?: string; spent: CodexReviewSpent; } @@ -116,6 +124,26 @@ export function appendReviewRecord( } } +/** + * Appends several records in one write, so they land together or not at all (a torn last line is skipped by the + * reader). Unlike {@link appendReviewRecord} it reports the failure: `reviews import` must not call a review + * imported when its lines never reached the ledger. + */ +export function appendReviewRecords( + records: readonly ReviewLedgerRecord[], + path = reviewLedgerPath() +): { ok: true } | { ok: false; error: string } { + try { + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, records.map((record) => `${SafeJSON.stringify(record, { jsonl: true })}\n`).join("")); + log.debug({ path, records: records.length }, "review ledger records written"); + return { ok: true }; + } catch (err) { + log.warn({ err, path, records: records.length }, "review ledger write failed"); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + function isLedgerRecord(value: unknown): value is ReviewLedgerRecord { return ( isObject(value) && diff --git a/src/codex/lib/review/report.ts b/src/codex/lib/review/report.ts index e5b4ab5a80..a9f13205df 100644 --- a/src/codex/lib/review/report.ts +++ b/src/codex/lib/review/report.ts @@ -1,6 +1,6 @@ import { parseSince } from "@genesiscz/utils/date"; import { parseDuration } from "@genesiscz/utils/format"; -import type { ReviewLedgerRecord } from "./ledger"; +import type { ReviewGateRecord, ReviewLedgerRecord } from "./ledger"; import type { ReviewVerdict } from "./types"; /** One review session, folded from its ledger lines: what `tools codex reviews` prints as a row. */ @@ -82,7 +82,8 @@ function emptySummary(record: ReviewLedgerRecord): ReviewSummary { /** * Folds the ledger into one summary per review session. A `start` line opens a new review, so a name that was - * reused later shows twice; turn and gate lines without a start (a ledger cut by hand) open one too. + * reused later shows twice; turn and gate lines without a start (a ledger cut by hand) open one too. An imported + * review that never got its gate line is a partial import: the next import of that name replaces its row. */ export function summarizeReviews(records: readonly ReviewLedgerRecord[]): ReviewSummary[] { const open = new Map(); @@ -92,6 +93,16 @@ export function summarizeReviews(records: readonly ReviewLedgerRecord[]): Review let summary = open.get(record.name); if (!summary || record.kind === "start") { + if ( + summary && + record.kind === "start" && + record.imported && + summary.imported && + summary.verdict === "open" + ) { + all.splice(all.indexOf(summary), 1); + } + summary = emptySummary(record); open.set(record.name, summary); all.push(summary); @@ -141,6 +152,63 @@ export function summarizeReviews(records: readonly ReviewLedgerRecord[]): Review return all; } +/** + * The ledger lines of one review session's current run. A reused name (and a fresh spawn, whose chain is its name) + * matches every earlier run too, so only the lines from its last `start` on count; with no start line, all of them. + */ +export function currentReviewRecords( + records: readonly ReviewLedgerRecord[], + name: string, + chain: string +): ReviewLedgerRecord[] { + const mine = records.filter((record) => record.name === name && record.chain === chain); + const start = mine.findLastIndex((record) => record.kind === "start"); + return start < 0 ? mine : mine.slice(start); +} + +export interface GateCheckRow { + label: "coverage" | "reasoning" | "shape"; + ok: boolean; + detail: string; +} + +/** + * The three checks of one gate line as `review-depth` prints them: the result and sentence the gate recorded, or + * for an older line without them an approximation from its counts. + */ +export function gateCheckRows(gate: ReviewGateRecord): GateCheckRow[] { + const unread = + gate.unread.length > 0 + ? `; unread: ${gate.unread.slice(0, 8).join(", ")}${gate.unread.length > 8 ? ", …" : ""}` + : ""; + + if (gate.checks) { + return [ + { label: "coverage", ...gate.checks.coverage, detail: `${gate.checks.coverage.detail}${unread}` }, + { label: "reasoning", ...gate.checks.reasoning }, + { label: "shape", ...gate.checks.shape }, + ]; + } + + return [ + { + label: "coverage", + ok: gate.coverage.reviewed !== null && gate.coverage.reviewed >= gate.coverage.total, + detail: `${gate.coverage.reviewed ?? "no line"} of ${gate.coverage.total} files${unread}`, + }, + { + label: "reasoning", + ok: gate.reasoningPer100Lines === null || gate.reasoningPer100Lines >= gate.floor, + detail: `${gate.reasoningPer100Lines ?? "unknown"} tokens per 100 changed lines (floor ${gate.floor})`, + }, + { + label: "shape", + ok: gate.malformedFindings === 0, + detail: `${gate.findings} findings, ${gate.malformedFindings} malformed`, + }, + ]; +} + /** `24h`, `90m`, `7d` back from now; otherwise the `--since` grammar of the other reports (`HH:MM`, a date). */ export function parseReviewSince(value: string, now = new Date()): Date | null { const text = value.trim(); diff --git a/src/codex/lib/review/review.test.ts b/src/codex/lib/review/review.test.ts index d6e9174932..5049155615 100644 --- a/src/codex/lib/review/review.test.ts +++ b/src/codex/lib/review/review.test.ts @@ -1,21 +1,31 @@ -import { describe, expect, test } from "bun:test"; -import { appendFileSync, mkdtempSync, readFileSync } from "node:fs"; +import { describe, expect, spyOn, test } from "bun:test"; +import { appendFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; +import { env } from "@genesiscz/utils/env"; import type { NumstatEntry, PrInfo } from "@genesiscz/utils/git"; import { SafeJSON } from "@genesiscz/utils/json"; +import { out } from "@genesiscz/utils/logger"; import type { ReviewManifest } from "@genesiscz/utils/worker/contract"; +import { runImport } from "../../commands/reviews"; import { codexDriver } from "../driver"; import { spawnCodexSession } from "../spawn"; import type { CodexSessionMeta } from "../store"; import { escalatedReviewName, markReviewShallow, recordReviewTurn } from "./controller"; import { checkReview, decideGate, parseReviewReport } from "./gate"; import { importReviewSessions, type ReviewImportReaders } from "./import"; -import { type ReviewLedgerRecord, readReviewRecords } from "./ledger"; +import { type ReviewGateRecord, type ReviewLedgerRecord, readReviewRecords } from "./ledger"; import { prepareReview, type ReviewGitReaders } from "./prepare"; -import { filterReviews, parseReviewSince, reviewTotals, summarizeReviews } from "./report"; +import { + currentReviewRecords, + filterReviews, + gateCheckRows, + parseReviewSince, + reviewTotals, + summarizeReviews, +} from "./report"; import type { CodexReviewConfig } from "./types"; function entry(path: string, insertions: number, deletions = 0, binary = false): NumstatEntry { @@ -247,6 +257,67 @@ describe("the depth gate", () => { expect(decideGate(miss, { effort: "medium", step: 0, allowShallow: true }).verdict).toBe("shallow"); expect(decideGate(miss, { effort: "low", step: 0, allowShallow: true }).verdict).toBe("shallow"); }); + + test("a text file on the Not reviewed line fails coverage even when the count is full; a repeated binary counts once", () => { + const admitted = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 4 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: src/c.ts (ran out of time)" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(admitted.coverage).toMatchObject({ ok: false, unread: ["src/c.ts"] }); + expect(admitted.coverage.detail).toContain("Not reviewed names 1 text file(s)"); + + const twoBinaries: ReviewManifest = { + ...MANIFEST, + files: [...MANIFEST.files, { path: "assets/icon.png", added: 0, deleted: 0, binary: true }], + }; + const repeated = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 3 of 5").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: assets/logo.png (binary image), assets/logo.png (binary image)" + ), + manifest: twoBinaries, + effort: "high", + reasoningTokens: 400, + }); + expect(repeated.coverage.ok).toBe(false); + }); + + test("markdown is stripped around words, never inside a path: underscores and spaces in paths survive", () => { + const manifest: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/my_mod.ts", added: 60, deleted: 10 }, + { path: "src/my file.ts", added: 30, deleted: 0 }, + { path: "assets/app_icon.png", added: 0, deleted: 0, binary: true }, + ], + added: 90, + deleted: 10, + }; + const report = [ + "1. **[High]** src/my_mod.ts:12 - the lock leaks. Scenario: a throw. Fix: release in finally.", + "2. [Medium] src/my file.ts:7 - stale read. Scenario: a retry. Fix: reload.", + "__Ran:__ `bun test src/my_mod.test.ts`", + "**Files reviewed:** 2 of 3", + "_Not reviewed:_ `assets/app_icon.png` (binary image)", + ].join("\n"); + const parsed = parseReviewReport(report); + + expect(parsed.notReviewed).toEqual([{ path: "assets/app_icon.png", reason: "binary image" }]); + expect(parsed.findings.map((finding) => [finding.ok, finding.text.split(" - ")[0]])).toEqual([ + [true, "[High] src/my_mod.ts:12"], + [true, "[Medium] src/my file.ts:7"], + ]); + expect(parsed.ran).toBe(true); + + const checks = checkReview({ report, manifest, effort: "high", reasoningTokens: 400 }); + expect(checks.coverage).toMatchObject({ ok: true, unread: [] }); + expect(checks.shape.ok).toBe(true); + }); }); function finishedTurn(over: Partial = {}): CodexFinishedTurn { @@ -319,6 +390,11 @@ describe("recordReviewTurn", () => { reasoningTokens: 50, }); expect(records[1]).toMatchObject({ verdict: "steered", step: 1, reasoningPer100Lines: 50, floor: 100 }); + expect((records[1] as ReviewGateRecord).checks).toEqual({ + coverage: { ok: false, detail: "1 of 4 files reviewed" }, + reasoning: { ok: false, detail: "50 reasoning tokens per 100 changed lines, high floor 100" }, + shape: { ok: true, detail: "0 findings parse" }, + }); const second = recordReviewTurn({ name: "rv", @@ -517,6 +593,42 @@ describe("the review ledger report", () => { ]); }); + test("review-depth reads only the current run of a reused name", () => { + const [start, turn, gate] = ledgerFixture(); + const again = { ...start, ts: "2026-10-23T09:00:00Z" }; + + expect(currentReviewRecords([start, turn, gate, again], "rv", "rv")).toEqual([again]); + expect(currentReviewRecords([turn, gate], "rv", "rv")).toEqual([turn, gate]); + }); + + test("review-depth prints the checks the gate recorded, and approximates only for a line without them", () => { + const gate = ledgerFixture()[2] as ReviewGateRecord; + // 3 of 4 with an excused binary passed the gate; a missing Ran line failed shape with no malformed finding. + const recorded: ReviewGateRecord = { + ...gate, + coverage: { reviewed: 3, total: 4 }, + unread: [], + checks: { + coverage: { ok: true, detail: "3 of 4 files reviewed" }, + reasoning: { ok: true, detail: "400 reasoning tokens per 100 changed lines, high floor 100" }, + shape: { ok: false, detail: "no `Ran:` line" }, + }, + }; + expect(gateCheckRows(recorded).map((row) => [row.label, row.ok, row.detail])).toEqual([ + ["coverage", true, "3 of 4 files reviewed"], + ["reasoning", true, "400 reasoning tokens per 100 changed lines, high floor 100"], + ["shape", false, "no `Ran:` line"], + ]); + + // Without the recorded checks the counts are all there is: coverage reads as a miss, shape as clean. + const { checks: _dropped, ...older } = recorded; + expect(gateCheckRows(older).map((row) => [row.label, row.ok])).toEqual([ + ["coverage", false], + ["reasoning", false], + ["shape", true], + ]); + }); + test("filters by PR, label, tier, verdict, name and time", () => { const reviews = summarizeReviews(ledgerFixture()); const names = (filters: Parameters[1]) => @@ -739,3 +851,106 @@ describe("reviews import", () => { expect(readReviewRecords(ledgerPath).records.filter((record) => record.kind === "gate")).toHaveLength(2); }); }); + +describe("reviews import: verdicts and failed writes", () => { + test("a full count whose total differs from the rebuilt manifest stays imported, never pass", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const mismatched: ReviewImportReaders = { + ...fake, + lastAgentMessage: async () => + "1. [High] src/a.ts:3 - bug. Scenario: x. Fix: y.\nRan: bun test\nFiles reviewed: 1 of 1\nNot reviewed: none", + }; + const [result] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: mismatched, + ledgerPath, + }); + + expect(result.verdict).toBe("imported"); + expect(summarizeReviews(readReviewRecords(ledgerPath).records)[0].verdict).toBe("imported"); + expect(readReviewRecords(ledgerPath).records.at(-1)).toMatchObject({ + kind: "gate", + reason: "imported: reported 1 of 1, but the manifest has 2 files; the scope is not confirmed", + }); + }); + + test("a failed ledger write is reported, and a partial earlier import is redone instead of skipped", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-review-import-")); + const blocker = join(dir, "not-a-folder"); + writeFileSync(blocker, ""); + const unwritable = join(blocker, "reviews.jsonl"); + const { fake: failing } = importReaders(unwritable); + const [failed] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: { ...failing, ledgerRecords: () => [] }, + ledgerPath: unwritable, + }); + expect(failed.status).toBe("failed"); + expect(failed.notes.at(-1)).toContain("the review ledger write failed"); + + // An older import wrote its start line and then failed: the rerun completes it and its row replaces the stub. + const ledgerPath = join(dir, "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const stub = { + v: 1, + ts: "2026-10-22T19:00:00.000Z", + name: "rv-full", + chain: "rv-full", + base: "origin/master", + files: 2, + lines: 45, + effort: "medium", + imported: true, + kind: "start", + cwd: "/wt/rv-full", + effortSource: "imported", + allowShallow: false, + }; + appendFileSync(ledgerPath, `${SafeJSON.stringify(stub)}\n`); + const [redone] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(redone).toMatchObject({ status: "imported", verdict: "pass" }); + expect(redone.notes).toContain("an earlier import of this name has no gate line; this import replaces it"); + expect( + summarizeReviews(readReviewRecords(ledgerPath).records).map((review) => [review.name, review.verdict]) + ).toEqual([["rv-full", "pass"]]); + + const [again] = await importReviewSessions({ names: ["rv-full"], readers: fake, ledgerPath }); + expect(again.status).toBe("skipped"); + }); + + test("tools codex reviews import exits 1 for a missing session in table and --json output alike", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-review-import-cli-")); + const previousExitCode = process.exitCode; + const printed: unknown[] = []; + const result = spyOn(out, "result").mockImplementation((value) => { + printed.push(value); + }); + const println = spyOn(out, "println").mockImplementation(() => {}); + // Read through a call: after the reset below, TypeScript would narrow a direct read to undefined. + const exitCode = (): typeof process.exitCode => process.exitCode; + + try { + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + for (const json of [true, false]) { + process.exitCode = undefined; + await runImport(["absent-review"], { cwdFromSession: true, json }); + expect(exitCode()).toBe(1); + } + }); + expect(printed).toHaveLength(1); + expect(printed[0]).toMatchObject({ results: [{ name: "absent-review", status: "missing" }] }); + } finally { + result.mockRestore(); + println.mockRestore(); + process.exitCode = previousExitCode; + } + }); +}); From f8fd3fb902317c578663e0d71520fe172d7aee07 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 14:46:33 +0200 Subject: [PATCH 015/251] feat(agents, hooks): the mods band gets sidebars like the /diff panel: PRs, the branch and the hooks chip open them, agents fold and open an Agents sidebar with actions, and the hooks count survives a reload --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- .../genesis-tools-mods/hooks/agents-band.tsx | 442 ++++++++++++++++-- .../genesis-tools-mods/hooks/decisions.tsx | 4 + plugins/genesis-tools-mods/hooks/git-band.tsx | 214 +++++++-- .../genesis-tools-mods/hooks/hook-ledger.tsx | 257 ++++++++-- plugins/genesis-tools-mods/hooks/register.tsx | 80 +++- plugins/genesis-tools-mods/hooks/shared.ts | 9 + plugins/genesis-tools-mods/hooks/sidebar.tsx | 142 ++++++ .../tests/features.test.tsx | 43 +- plugins/genesis-tools-mods/types/index.d.ts | 7 + src/agents/README.md | 17 +- src/agents/lib/hooks/config.ts | 2 +- 13 files changed, 1106 insertions(+), 119 deletions(-) create mode 100644 plugins/genesis-tools-mods/hooks/sidebar.tsx diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 563a2e939d..201db6ae5a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "name": "genesis-tools-mods", - "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe. Install with: tools agents hooks mods install --write.", - "version": "1.7.0", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Install with: tools agents hooks mods install --write.", + "version": "1.8.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index e91d7c3aa5..fa13964e9d 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.7.0", - "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe. Configured with: tools agents hooks config set mods.. --write.", + "version": "1.8.0", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/hooks/agents-band.tsx b/plugins/genesis-tools-mods/hooks/agents-band.tsx index 16e1df6611..d27a5a9550 100644 --- a/plugins/genesis-tools-mods/hooks/agents-band.tsx +++ b/plugins/genesis-tools-mods/hooks/agents-band.tsx @@ -1,16 +1,50 @@ -// The live agents band: one row per working subagent with its model, what it is for, how long it has run, how long -// since its last tool call, and how many tool calls it made. The engine's agents panel shows none of the first -// three, and a subagent past `mods.agentsBand.warnMinutes` turns red. The model and start come from `agent.spawn`'s -// answer (kept in `spawned`, which survives a reload); activity from the subagent's own `tool.call`s. -import type { AgentSpawnInput, AgentSpawnResult, ElementTable, RenderElement } from "claude-code"; +// The live agents band: the working subagents with their model, what they are for, how long they have run, how long +// since their last tool call, and how many tool calls they made. The engine's agents panel shows none of the first +// three, and a subagent past `mods.agentsBand.warnMinutes` turns red. Up to three agents show as rows; more fold +// into one `▸ 8 agents` line that a press opens. A press on an agent opens the Agents sidebar on it: its recent tool +// calls, what it said last, and actions (message it, ask about it, stop it, copy its id or transcript path). +// +// The model and start come from `agent.spawn`'s answer; for an agent spawned before this module loaded (a reload, or +// a resumed session) from the `agent-.meta.json` Claude Code writes beside its transcript. Activity comes from +// the subagent's own `tool.call`s. +import type { AgentInfo, AgentSpawnInput, AgentSpawnResult, ElementTable, RenderElement } from "claude-code"; import type { SpawnedAgents } from "../types"; import { modelLabel } from "./agent-model"; -import { type Feature, type Host, settingsOf, shortDuration } from "./shared"; +import { type Feature, type Host, isRecord, settingsOf, shortDuration } from "./shared"; +import { + actionRow, + dimNote, + divider, + headerRow, + type Kit, + listRow, + plural, + sectionTitle, + sidebarFrame, +} from "./sidebar"; +import { toolLabel } from "./spinner"; + +export const AGENTS_PANE = "mods-agents"; let spawned: SpawnedAgents = {}; -const activity = new Map(); +const activity = new Map(); +/** What `agent-.meta.json` said; null once read and found without a model. */ +const metas = new Map(); +/** The end of each agent's transcript: its last words, read at most every 5 s. */ +const tails = new Map(); const BUSY = new Set(["pending", "running", "waiting"]); const MAX_ROWS = 6; +const FOLD_OVER = 3; +/** The session's transcript folder, `//`, from a classic hook's `transcript_path`. */ +let sessionDir: string | undefined; +/** The person's fold of the band; null folds it past `FOLD_OVER` agents. */ +let expanded: boolean | null = null; +let selected: string | undefined; + +interface Recent { + label: string; + at: number; +} async function beat(host: Host): Promise { if (!(await settingsOf(host)).agentsBand.enabled) { @@ -33,11 +67,29 @@ export const agentsBand: Feature = { beforeTool(_host, e) { if (e.agentId !== undefined) { const before = activity.get(e.agentId); - activity.set(e.agentId, { lastAt: Date.now(), lastTool: e.tool, tools: (before?.tools ?? 0) + 1 }); + const now = Date.now(); + activity.set(e.agentId, { + lastAt: now, + lastTool: e.tool, + tools: (before?.tools ?? 0) + 1, + recent: [...(before?.recent ?? []), { label: toolLabel(e.tool, e), at: now }].slice(-12), + }); } return undefined; }, + sidebars: [{ id: AGENTS_PANE, title: "Agents", draw: agentsPane }], + commands: [ + { + name: "mod-agents", + description: "The Agents sidebar: every subagent with its model, recent tools, last words and actions", + async run(host) { + await host.togglePane(AGENTS_PANE, "Agents"); + + return {}; + }, + }, + ], }; /** Keeps the model and start of a spawned subagent, as `agent.spawn` answered. */ @@ -49,16 +101,183 @@ export function recordSpawn(host: Host, e: AgentSpawnInput, result: AgentSpawnRe } const model = result.model || e.model || e.parentModel; - // The agent-model mod may have put the model in front already; the row prints it once, itself. - const description = e.description.replace(/^\([A-Z]\) /, "").replace(`${modelLabel(model, "label")} · `, ""); - spawned = { ...spawned, [agentId]: { model, description, at: Date.now() } }; + spawned = { ...spawned, [agentId]: { model, description: bareDescription(e.description), at: Date.now() } }; host.redraw(); } -/** The band's agents rows, or null when no subagent works. */ -export async function agentsRow(host: Host, ui: ElementTable): Promise { +/** A description without the model the agent-model mod put in front (`(H) `, `haiku-5-5 · `): the row prints it once. */ +export function bareDescription(description: string): string { + return description.replace(/^\([A-Z]\) /, "").replace(/^[a-z]+(?:-[\w.]+)* · /, ""); +} + +/** Remembers where the session's transcripts are, from any classic hook input. */ +export function noteTranscript(input: unknown): void { + if (isRecord(input) && typeof input.transcript_path === "string" && !input.agent_id) { + sessionDir = input.transcript_path.replace(/\.jsonl$/, ""); + } +} + +async function sessionDirOf(host: Host): Promise { + if (sessionDir) { + return sessionDir; + } + + const home = await host.env("HOME"); + + if (!home) { + return undefined; + } + + const cwd = await host.cwd(); + + return `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, "-")}/${await host.sessionId()}`; +} + +async function transcriptOf(host: Host, agentId: string): Promise { + const dir = await sessionDirOf(host); + + return dir ? `${dir}/subagents/agent-${agentId}.jsonl` : undefined; +} + +/** Reads an agent's meta file once, in the background; the band draws again when it knows more. */ +function loadMeta(host: Host, agentId: string): void { + if (metas.has(agentId)) { + return; + } + + metas.set(agentId, null); + void (async () => { + const transcript = await transcriptOf(host, agentId); + + if (!transcript) { + return; + } + + const path = transcript.replace(/\.jsonl$/, ".meta.json"); + const text = await host.read(path).catch(() => ""); + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.model === "string") { + const stat = await host.stat(path).catch(() => undefined); + metas.set(agentId, { model: parsed.model, at: stat?.mtimeMs }); + host.redraw(); + } + })().catch(() => undefined); +} + +/** The text of the last assistant message in a transcript's end, or "" without one. */ +export function lastSaidOf(tail: string): string { + const lines = tail.split("\n").reverse(); + + for (const line of lines) { + if (!line.includes('"assistant"')) { + continue; + } + + try { + const row: unknown = JSON.parse(line); + const message = isRecord(row) && isRecord(row.message) ? row.message : undefined; + const content = message && Array.isArray(message.content) ? message.content : []; + const text = content + .flatMap((part) => + isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] + ) + .join("\n") + .trim(); + + if (text) { + return text; + } + } catch { + // The first line of a byte-cut tail is half a row. + } + } + + return ""; +} + +function loadTail(host: Host, agentId: string): void { + const held = tails.get(agentId); + + if (held && Date.now() - held.at < 5_000) { + return; + } + + tails.set(agentId, { at: Date.now(), said: held?.said ?? "" }); + void (async () => { + const path = await transcriptOf(host, agentId); + const done = path ? await host.run(["tail", "-c", "131072", path], { timeoutMs: 5_000 }) : undefined; + const said = done?.exitCode === 0 ? lastSaidOf(done.stdout) : ""; + + if (said !== held?.said) { + tails.set(agentId, { at: Date.now(), said }); + host.redraw(); + } + })().catch(() => undefined); +} + +interface AgentFacts { + agent: AgentInfo; + model: string | undefined; + description: string; + age: number | undefined; + late: boolean; + seen: ReturnType; +} + +async function factsOf(host: Host, list: readonly AgentInfo[]): Promise { const all = await settingsOf(host); - const settings = all.agentsBand; + const now = Date.now(); + + return list.map((agent) => { + const known = spawned[agent.id]; + + if (!known) { + loadMeta(host, agent.id); + } + + const meta = metas.get(agent.id); + const model = known?.model ?? meta?.model; + const at = known?.at ?? meta?.at; + const age = at === undefined ? undefined : now - at; + + return { + agent, + model: model ? modelLabel(model, all.agentModel.format) : undefined, + description: known?.description ?? bareDescription(agent.description), + age, + late: BUSY.has(agent.status) && age !== undefined && age > all.agentsBand.warnMinutes * 60_000, + seen: activity.get(agent.id), + }; + }); +} + +function nameOf(facts: AgentFacts): string { + return `${facts.model ? `(${facts.model}) ` : ""}${facts.description}`; +} + +function tailOf(facts: AgentFacts, now: number): string { + const parts = [facts.age === undefined ? "" : shortDuration(facts.age)]; + + if (facts.seen) { + parts.push(`idle ${shortDuration(now - facts.seen.lastAt)}`, plural(facts.seen.tools, "tool")); + } + + return parts.filter(Boolean).join(" · "); +} + +function cut(text: string, width: number): string { + return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text; +} + +function openOn(host: Host, agentId: string | undefined): void { + selected = agentId; + void host.openPane(AGENTS_PANE, "Agents"); +} + +/** The band's agents rows, or null when no subagent works. */ +export async function agentsRow(host: Host, ui: ElementTable, columns: number): Promise { + const settings = (await settingsOf(host)).agentsBand; if (!settings.enabled) { return null; @@ -70,30 +289,193 @@ export async function agentsRow(host: Host, ui: ElementTable): Promise { - const known = held[agent.id]; - const seen = activity.get(agent.id); - const age = known ? now - known.at : undefined; - const late = age !== undefined && age > settings.warnMinutes * 60_000; - const model = known ? modelLabel(known.model, all.agentModel.format) : "?"; - const idle = seen - ? ` · idle ${shortDuration(now - seen.lastAt)} · ${seen.tools} tools, last ${seen.lastTool}` - : ""; + const all = await factsOf(host, list); + const isOpen = expanded ?? list.length <= FOLD_OVER; + const late = all.filter((facts) => facts.late).length; + const toggle = () => { + expanded = !isOpen; + host.redraw(); + }; + const row = (facts: AgentFacts) => { + const tail = tailOf(facts, now); + const label = cut(`${facts.late ? "⚠" : "●"} ${nameOf(facts)}`, Math.max(20, columns - tail.length - 3)); return ( - - {`${late ? "⚠ " : "● "}(${model}) ${known?.description ?? agent.description}${age === undefined ? "" : ` · ${shortDuration(age)}`}${idle}`} + + + + {tail} + + ); + }; + const header = + list.length > FOLD_OVER || expanded === true ? ( + + + {late ? {`${late} past ${settings.warnMinutes}m`} : null} + {isOpen ? null : ( + + {all.map(nameOf).join(" · ")} + + )} + + + + ) : null; + const shown = isOpen ? all.slice(0, MAX_ROWS) : []; + + return ( + + {header} + {shown.map(row)} + {isOpen && all.length > MAX_ROWS ? ( + + ) : null} + + ); +} + +/** The Agents sidebar: every agent of the session, the working ones first, and the chosen one in detail. */ +async function agentsPane(host: Host, kit: Kit): Promise { + const { Box, Text } = kit.ui; + const list = (await host.agents().catch(() => [])).filter((agent) => !agent.teammateId); + const ordered = [...list.filter((agent) => BUSY.has(agent.status)), ...list.filter((a) => !BUSY.has(a.status))]; + const all = await factsOf(host, ordered); + const now = Date.now(); + const chosen = all.find((facts) => facts.agent.id === selected) ?? all[0]; + const busy = all.filter((facts) => BUSY.has(facts.agent.status)).length; + const refresh = { key: "agents-refresh", label: "refresh", isDim: true, onPress: () => host.redraw() }; + + if (all.length === 0) { + return sidebarFrame(kit, dimNote(kit, "No agents in this session yet.")); + } + + const rows = all.map((facts) => { + const isChosen = facts === chosen; + const status = BUSY.has(facts.agent.status) ? tailOf(facts, now) : facts.agent.status; + + return listRow( + kit, + { + key: `pick-${facts.agent.id}`, + label: cut( + `${isChosen ? "▸" : facts.late ? "⚠" : " "} ${nameOf(facts)}`, + kit.columns - status.length - 2 + ), + isDim: !isChosen, + onPress: () => { + selected = facts.agent.id; + host.redraw(); + }, + }, + + {status} ); }); + return sidebarFrame( + kit, + + {headerRow( + kit, + + {`${all.length} agents`} + {` · ${busy} working`} + , + [refresh] + )} + + {rows} + + {chosen ? await detailOf(host, kit, chosen, now) : null} + + ); +} + +async function detailOf(host: Host, kit: Kit, facts: AgentFacts, now: number): Promise { + const { Box, Text } = kit.ui; + const { agent, seen } = facts; + const transcript = (await transcriptOf(host, agent.id)) ?? ""; + loadTail(host, agent.id); + const said = tails.get(agent.id)?.said ?? ""; + const address = agent.name ?? agent.id; + const quoted = `"${facts.description}" (${agent.id})`; + const facts1 = [agent.type, agent.status, agent.name, agent.id].filter(Boolean).join(" · "); + const facts2 = [ + facts.age === undefined ? "" : `ran ${shortDuration(facts.age)}`, + seen ? plural(seen.tools, "tool call") : "", + seen ? `last ${seen.lastTool} ${shortDuration(now - seen.lastAt)} ago` : "", + ] + .filter(Boolean) + .join(" · "); + const fill = (text: string) => void host.fill({ text, mode: "replace" }); + return ( - - {rows} - {list.length > MAX_ROWS ? {`+${list.length - MAX_ROWS} more agents`} : null} + + {divider(kit)} + {sectionTitle(kit, nameOf(facts))} + {dimNote(kit, facts1, "facts1")} + {facts2 ? dimNote(kit, facts2, "facts2") : null} + + {actionRow(kit, [ + { + key: "agent-message", + label: "message", + isPrimary: true, + onPress: () => fill(`SendMessage to ${address}: `), + }, + { + key: "agent-ask", + label: "ask about it", + onPress: () => fill(`What is the agent ${quoted} doing now? Its transcript: ${transcript}`), + }, + { + key: "agent-stop", + label: "stop", + onPress: () => fill(`Stop the agent ${quoted} with TaskStop, then tell me what it finished.`), + }, + { key: "agent-copy-id", label: "copy id", isDim: true, onPress: () => void host.copy(agent.id) }, + { + key: "agent-copy-path", + label: "copy transcript path", + isDim: true, + onPress: () => void host.copy(transcript), + }, + ])} + + {seen?.recent.length ? ( + + {sectionTitle(kit, "Recent tools")} + {[...seen.recent].reverse().map((call, index) => ( + + + {call.label} + + + {shortDuration(now - call.at)} + + ))} + + ) : null} + {said ? ( + + {sectionTitle(kit, "Said last")} + + {said.split("\n").slice(-8).join("\n").slice(-900)} + + + ) : null} ); } diff --git a/plugins/genesis-tools-mods/hooks/decisions.tsx b/plugins/genesis-tools-mods/hooks/decisions.tsx index 5feade1f7d..0589617b00 100644 --- a/plugins/genesis-tools-mods/hooks/decisions.tsx +++ b/plugins/genesis-tools-mods/hooks/decisions.tsx @@ -8,6 +8,7 @@ import type { ElementTable, RenderElement } from "claude-code"; import type { DecisionOption, OpenDecision, OpenDecisions, StagedDecisions } from "../types"; import { type Feature, type Host, logMod, settingsOf } from "./shared"; +import { sidebarFrame } from "./sidebar"; export const DECISIONS_PANE = "genesis-decisions"; let open: OpenDecisions = []; @@ -134,6 +135,9 @@ async function refresh(host: Host): Promise { } export const decisions: Feature = { + sidebars: [ + { id: DECISIONS_PANE, title: "Decisions", draw: (host, kit) => sidebarFrame(kit, decisionsPane(host, kit.ui)) }, + ], commands: [ { name: "decisions", diff --git a/plugins/genesis-tools-mods/hooks/git-band.tsx b/plugins/genesis-tools-mods/hooks/git-band.tsx index 9c7e07f040..4430ec848b 100644 --- a/plugins/genesis-tools-mods/hooks/git-band.tsx +++ b/plugins/genesis-tools-mods/hooks/git-band.tsx @@ -4,8 +4,11 @@ // `/mod-git refresh`; `gh` answers are reused for 2 minutes. `/mod-git on|off|toggle` switches it for this // session, `mods.git.enabled` for every session. Each refresh and command goes to `mods-log/git.jsonl`. import type { ElementTable, RenderElement } from "claude-code"; -import type { BandPr, GitBand, GitBandLine, GitBandSwitch } from "../types"; -import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; +import type { BandCheck, BandPr, GitBand, GitBandLine, GitBandSwitch } from "../types"; +import { type Feature, type Host, isRecord, logMod, settingsOf, shortDuration } from "./shared"; +import { actionRow, dimNote, divider, headerRow, type Kit, sidebarFrame } from "./sidebar"; + +export const GIT_PANE = "mods-git"; /** The last refresh; null until one finished. */ let line: GitBandLine = null; @@ -37,29 +40,30 @@ export async function trackedPrs(host: Host, root: string): Promise { return Array.isArray(held) ? held.filter((n): n is number => typeof n === "number") : []; } -/** CI counts out of `statusCheckRollup`: a check run has `conclusion`, a commit status has `state`. */ -export function checkCounts(rollup: unknown): { passed: number; failed: number; pending: number } { - const counts = { passed: 0, failed: 0, pending: 0 }; - - for (const check of Array.isArray(rollup) ? rollup : []) { - if (!isRecord(check)) { - continue; - } +const PASSED = ["SUCCESS", "NEUTRAL", "SKIPPED"]; +const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]; +/** Each check of `statusCheckRollup`: a check run has `name` and `conclusion`, a commit status `context` and `state`. */ +export function checksOf(rollup: unknown): BandCheck[] { + return (Array.isArray(rollup) ? rollup : []).filter(isRecord).map((check) => { const verdict = String(check.conclusion || check.state || "").toUpperCase(); - if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(verdict)) { - counts.passed++; - } else if ( - ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(verdict) - ) { - counts.failed++; - } else { - counts.pending++; - } - } + return { + name: String(check.name || check.context || "check"), + verdict: PASSED.includes(verdict) ? "pass" : FAILED.includes(verdict) ? "fail" : "pending", + }; + }); +} - return counts; +/** CI counts out of `statusCheckRollup`. */ +export function checkCounts(rollup: unknown): { passed: number; failed: number; pending: number } { + const checks = checksOf(rollup); + + return { + passed: checks.filter((check) => check.verdict === "pass").length, + failed: checks.filter((check) => check.verdict === "fail").length, + pending: checks.filter((check) => check.verdict === "pending").length, + }; } async function prOf(host: Host, root: string, which: string, tracked: boolean): Promise { @@ -93,6 +97,7 @@ async function prOf(host: Host, root: string, which: string, tracked: boolean): draft: parsed.isDraft === true, tracked, ...checkCounts(parsed.statusCheckRollup), + checks: checksOf(parsed.statusCheckRollup), }; } } catch { @@ -173,11 +178,17 @@ export function refresh(host: Host, why: string): Promise { return refreshing; } -const USAGE = "/mod-git on | off | toggle | refresh | status | pr add | pr rm | pr list"; +const USAGE = "/mod-git [show] | on | off | toggle | refresh | status | pr add | pr rm | pr list"; /** The answer to `/mod-git `. */ async function command(host: Host, args: string): Promise { - const [verb = "status", sub, ...rest] = args.trim().split(/\s+/).filter(Boolean); + const [verb = "show", sub, ...rest] = args.trim().split(/\s+/).filter(Boolean); + + if (verb === "show") { + await host.togglePane(GIT_PANE, "Git"); + + return ""; + } if (verb === "on" || verb === "off" || verb === "toggle") { const now = verb === "toggle" ? !(await isOn(host)) : verb === "on"; @@ -246,16 +257,18 @@ export const gitBand: Feature = { commands: [ { name: "mod-git", - description: "The git band: on, off, toggle, refresh, status, pr add , pr rm , pr list", + description: + "The git band: no argument opens its sidebar; on, off, toggle, refresh, status, pr add , pr rm , pr list", argumentHint: "on|off|toggle|refresh|status|pr add ", async run(host, args) { const text = await command(host, args); await logMod(host, "git", { event: "command", args, answer: text }); - return { text }; + return text ? { text } : {}; }, }, ], + sidebars: [{ id: GIT_PANE, title: "Git", draw: gitPane }], async afterTool(host, e) { if (e.tool === "Bash" && /(^|[\s;&|(])(git|gh)\s/.test(e.command) && (await isOn(host))) { void refresh(host, "tool"); @@ -273,7 +286,25 @@ function ciText(pr: BandPr): string { return parts.filter(Boolean).join(" ") || "no CI"; } -/** The band's git row, or null while the band is off or has nothing yet. */ +function prColor(pr: BandPr): string { + return pr.failed ? "red" : pr.pending ? "yellow" : pr.state === "MERGED" ? "magenta" : "green"; +} + +function prNote(pr: BandPr): string { + return `${pr.draft ? " draft" : ""}${pr.state === "OPEN" ? "" : ` ${pr.state.toLowerCase()}`} ${ciText(pr)}`; +} + +/** `https://genesis.tools/hub?pr=owner%2Frepo%23507` out of the PR's GitHub URL; undefined for another forge. */ +export function hubUrlOf(pr: Pick): string | undefined { + const slug = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(pr.url)?.[1]; + + return slug ? `https://genesis.tools/hub?pr=${encodeURIComponent(`${slug}#${pr.number}`)}` : undefined; +} + +/** + * The band's git row, or null while the band is off or has nothing yet. Each PR is a Button (it inverts under the + * pointer, so it reads as one) that opens the Git sidebar; the sidebar opens the PR in the browser or the hub. + */ export async function gitRow(host: Host, ui: ElementTable): Promise { const band = line; @@ -281,26 +312,133 @@ export async function gitRow(host: Host, ui: ElementTable): Promise void host.togglePane(GIT_PANE, "Git"); return ( - ⎇ - {band.branch} + {sync ? {sync} : null} {band.dirty ? ●{band.dirty} : clean} {band.prs.map((pr) => ( - // A Link, so a click on `#507` opens the PR (asked for on 2026-10-23: "the PR is again not clickable"). - - - {`· #${pr.number}${pr.draft ? " draft" : ""}${pr.state === "OPEN" ? "" : ` ${pr.state.toLowerCase()}`} ${ciText(pr)}`} - - + ))} ); } + +/** The Git sidebar: the branch, then each PR with its title, its failing and pending checks, and its actions. */ +async function gitPane(host: Host, kit: Kit): Promise { + const { Box, Text } = kit.ui; + const band = line; + const reload = { + key: "git-refresh", + label: "refresh", + isDim: true, + onPress: () => { + ghCache.clear(); + void refresh(host, "sidebar"); + }, + }; + + if (!band) { + return sidebarFrame(kit, headerRow(kit, dimNote(kit, "No git reading yet."), [reload])); + } + + const root = (await rootOf(host)) ?? ""; + const tracked = root ? await trackedPrs(host, root) : []; + const setTracked = async (n: number, on: boolean) => { + const next = on ? [...new Set([...tracked, n])] : tracked.filter((held) => held !== n); + await host.storeSet(storeKey(root), next); + await logMod(host, "git", { event: on ? "track" : "untrack", n, from: "sidebar" }); + await refresh(host, "sidebar"); + }; + const sync = [`↑${band.ahead}`, `↓${band.behind}`, band.dirty ? `${band.dirty} dirty` : "clean"].join(" · "); + + return sidebarFrame( + kit, + + {headerRow( + kit, + + {band.branch} + {` ${sync} · read ${shortDuration(Date.now() - band.at)} ago`} + , + [reload] + )} + {band.prs.length === 0 ? dimNote(kit, "No pull request here. /mod-git pr add tracks one.") : null} + {band.prs.map((pr) => { + const hub = hubUrlOf(pr); + const failing = pr.checks.filter((check) => check.verdict === "fail"); + const pending = pr.checks.filter((check) => check.verdict === "pending"); + + return ( + + {divider(kit)} + + {`#${pr.number} `} + {pr.title} + + {dimNote(kit, `${pr.state.toLowerCase()}${pr.draft ? " draft" : ""} · ${ciText(pr)}`)} + {failing.map((check) => ( + + {`✗ ${check.name}`} + + ))} + {pending.slice(0, 6).map((check) => ( + + {`… ${check.name}`} + + ))} + {pending.length > 6 ? dimNote(kit, `… ${pending.length - 6} more pending`) : null} + + {actionRow(kit, [ + { + key: `pr-${pr.number}-open`, + label: "open", + isPrimary: true, + onPress: () => void host.openUrl(pr.url), + }, + ...(hub + ? [ + { + key: `pr-${pr.number}-hub`, + label: "hub", + onPress: () => void host.openUrl(hub), + }, + ] + : []), + { + key: `pr-${pr.number}-copy`, + label: "copy link", + isDim: true, + onPress: () => void host.copy(`[#${pr.number} (${pr.title})](${pr.url})`), + }, + tracked.includes(pr.number) + ? { + key: `pr-${pr.number}-untrack`, + label: "untrack", + isDim: true, + onPress: () => void setTracked(pr.number, false), + } + : { + key: `pr-${pr.number}-track`, + label: "track", + isDim: true, + onPress: () => void setTracked(pr.number, true), + }, + ])} + + + ); + })} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx index 64a5417ffd..5e7506ce4e 100644 --- a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx +++ b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx @@ -1,20 +1,80 @@ // The hook ledger. Every classic (settings and plugin) hook's answer passes through `classic.*`, so this counts what -// the hooks hand the model: `additionalContext` entries and `block` reasons (a Stop nudge is one), per event, for -// the main loop and for subagents. `mods.hooks.tokens` pins the total as this plugin's status line -// (`hooks ~38.2k tok · 41 fires`; tokens are characters / 4, an estimate). `mods.hooks.dedupe` drops a repeat: -// an entry, or an area-rule block inside one, already handed to the same loop since its last compaction becomes a +// the hooks hand the model: `additionalContext` entries and `block` reasons (a Stop nudge is one), per event, for the +// main loop and for subagents. `mods.hooks.tokens` shows the total as a band chip (`hooks ~38.2k · 41`; tokens are +// characters / 4, an estimate), and a press on it opens the Hooks sidebar: the count per event, the latest injections +// with their text, and the dedupe switch for this session. The status line cannot take a click, so the count left it +// on 2026-10-23. The count is per session and survives a reload of the plugin. `mods.hooks.dedupe` drops a repeat: an +// entry, or an area-rule block inside one, already handed to the same loop since its last compaction becomes a // one-line receipt. Each injection and each drop goes to `mods-log/hooks.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; import type { HookCount, HookLedger } from "../types"; -import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; +import { agentsDir, type Feature, type Host, isRecord, logMod, settingsOf, shortDuration } from "./shared"; +import { barText, dimNote, divider, headerRow, type Kit, listRow, plural, sectionTitle, sidebarFrame } from "./sidebar"; -/** Counted since this module loaded: a hot reload of the plugin starts the count again. */ -let ledger: HookLedger = { +export const HOOKS_PANE = "mods-hooks"; + +const EMPTY: HookLedger = { main: { fires: 0, chars: 0 }, agents: { fires: 0, chars: 0 }, byEvent: {}, dropped: { fires: 0, chars: 0 }, }; +/** Counted for this session. Kept in the plugin store under `hook-ledger:`, so a reload keeps the count. */ +let ledger: HookLedger = EMPTY; +let storeKey: string | undefined; + +function countOf(value: unknown): HookCount | undefined { + return isRecord(value) && typeof value.fires === "number" && typeof value.chars === "number" + ? { fires: value.fires, chars: value.chars } + : undefined; +} + +/** A ledger read back from the store, or undefined when it is not one. */ +export function ledgerOf(value: unknown): HookLedger | undefined { + if (!isRecord(value) || !isRecord(value.byEvent)) { + return undefined; + } + + const main = countOf(value.main); + const agents = countOf(value.agents); + const dropped = countOf(value.dropped); + const byEvent = Object.fromEntries( + Object.entries(value.byEvent).flatMap(([event, count]) => { + const held = countOf(count); + + return held ? [[event, held]] : []; + }) + ); + + return main && agents && dropped ? { main, agents, dropped, byEvent } : undefined; +} + +function save(host: Host): void { + if (storeKey) { + void host.storeSet(storeKey, ledger).catch(() => undefined); + } +} + +/** The latest injections, newest last, for the sidebar: the text cut at `ENTRY_TEXT` characters. */ +interface Injection { + at: number; + event: string; + agent: string; + tool?: string; + chars: number; + text: string; + dropped: string[]; +} + +let recent: Injection[] = []; +const RECENT = 40; +const ENTRY_TEXT = 4_000; +/** The sidebar's dedupe switch for this session; null follows `mods.hooks.dedupe`. */ +let dedupeHere: boolean | null = null; +/** The injection the sidebar shows in full, by its `at`. */ +let opened: number | undefined; + /** Shorter entries are never dropped: a receipt would save nothing. */ const MIN_DEDUPE_CHARS = 200; const AREA_BLOCK = /(?=Contents of \S+ \(area rules\):)/; @@ -96,26 +156,6 @@ function add(count: HookCount, chars: number): HookCount { return { fires: count.fires + 1, chars: count.chars + chars }; } -async function showStatus(host: Host): Promise { - if (!(await settingsOf(host)).hooks.tokens) { - host.status(undefined); - return; - } - - const held = ledger; - const parts = [`hooks ${tokensText(held.main.chars)} tok · ${held.main.fires} fires`]; - - if (held.agents.fires > 0) { - parts.push(`agents ${tokensText(held.agents.chars)}`); - } - - if (held.dropped.fires > 0) { - parts.push(`deduped ${tokensText(held.dropped.chars)}`); - } - - host.status(parts.join(" · ")); -} - /** * Counts what one classic hook event handed the model and takes out the repeats. Answers the `additionalContext` to * pass on in place of the hooks' own, or undefined to pass their answer on unchanged. @@ -143,13 +183,14 @@ export async function ledgerClassic(host: Host, input: unknown, answer: unknown) } const settings = (await settingsOf(host)).hooks; + const dedupe = dedupeHere ?? settings.dedupe; const held = seen.get(agent) ?? new Map(); seen.set(agent, held); const now = Date.now(); let droppedChars = 0; const dropped: string[] = []; const kept = entries.map((entry) => { - if (!settings.dedupe) { + if (!dedupe) { return entry; } @@ -178,7 +219,20 @@ export async function ledgerClassic(host: Host, input: unknown, answer: unknown) dropped: dropped.length ? dropped : undefined, droppedChars: droppedChars || undefined, }); - await showStatus(host); + save(host); + recent = [ + ...recent, + { + at: now, + event, + agent, + tool: typeof input.tool_name === "string" ? input.tool_name : undefined, + chars, + text: [...kept, block].filter(Boolean).join("\n\n").slice(0, ENTRY_TEXT), + dropped, + }, + ].slice(-RECENT); + host.redraw(); return dropped.length ? kept : undefined; } @@ -186,9 +240,152 @@ export async function ledgerClassic(host: Host, input: unknown, answer: unknown) export const hookLedger: Feature = { async start(host) { - await showStatus(host); + // Sessions that ran 1.7.0 pinned the count as a status line; the chip replaces it. + host.status(undefined); + const key = `hook-ledger:${await host.sessionId()}`; + + if (key !== storeKey) { + storeKey = key; + ledger = ledgerOf(await host.storeGet(key).catch(() => undefined)) ?? EMPTY; + host.redraw(); + } }, compacted(agentId) { seen.delete(agentId ?? "main"); }, + sidebars: [{ id: HOOKS_PANE, title: "Hooks", draw: hooksPane }], + commands: [ + { + name: "mod-hooks", + description: "The Hooks sidebar: what the hooks handed the model, per event, with the latest injections", + async run(host) { + await host.togglePane(HOOKS_PANE, "Hooks"); + + return {}; + }, + }, + ], }; + +/** The band chip, `hooks ~38.2k · 41`, or null while `mods.hooks.tokens` is off. */ +export async function hooksChip(host: Host, ui: ElementTable): Promise { + if (!(await settingsOf(host)).hooks.tokens) { + return null; + } + + const { Button, Text } = ui; + const held = ledger; + const chars = held.main.chars + held.agents.chars; + + return ( + + ); +} + +function ago(at: number, now: number): string { + return shortDuration(now - at); +} + +/** The Hooks sidebar: the totals, the count per event as bars, and the latest injections, each opening its text. */ +async function hooksPane(host: Host, kit: Kit): Promise { + const { Box, Text } = kit.ui; + const held = ledger; + const now = Date.now(); + const settings = (await settingsOf(host)).hooks; + const dedupe = dedupeHere ?? settings.dedupe; + const total = held.main.chars + held.agents.chars; + const events = Object.entries(held.byEvent).sort((a, b) => b[1].chars - a[1].chars); + const most = events[0]?.[1].chars ?? 1; + const log = `${(await agentsDir(host)) ?? "~/.genesis-tools/agents"}/mods-log/hooks.jsonl`; + const shown = recent.find((entry) => entry.at === opened); + const reset = () => { + ledger = EMPTY; + save(host); + recent = []; + host.redraw(); + }; + + return sidebarFrame( + kit, + + {headerRow( + kit, + + {`${tokensText(total)} tokens`} + {` from hooks · ${plural(held.main.fires + held.agents.fires, "fire")}`} + , + [ + { + key: "hooks-dedupe", + label: `dedupe: ${dedupe ? "on" : "off"}`, + onPress: () => { + dedupeHere = !dedupe; + void logMod(host, "hooks", { event: "dedupe-switch", on: dedupeHere }); + host.redraw(); + }, + }, + { + key: "hooks-log", + label: "show log", + isDim: true, + onPress: () => + void host.openUrl(`https://genesis.tools/finder/reveal?target=${encodeURI(log)}`), + }, + { key: "hooks-reset", label: "reset", isDim: true, onPress: reset }, + ] + )} + {dimNote( + kit, + `main ${tokensText(held.main.chars)} · ${held.main.fires} agents ${tokensText(held.agents.chars)} · ${held.agents.fires} deduped ${tokensText(held.dropped.chars)} · ${held.dropped.fires}` + )} + {dimNote(kit, "Counted for this session; tokens are characters / 4.")} + + {sectionTitle(kit, "By event")} + {events.length === 0 ? dimNote(kit, "No hook added context yet.") : null} + {events.map(([event, count]) => ( + + {event} + + {`${count.fires}×`} + {barText(count.chars / most, 10)} + {tokensText(count.chars).padStart(6)} + + ))} + + + {sectionTitle(kit, "Latest", "(press one to read it)")} + {[...recent] + .reverse() + .slice(0, 15) + .map((entry) => + listRow( + kit, + { + key: `entry-${entry.at}`, + label: `${entry.at === opened ? "▾" : "▸"} ${entry.event}${entry.tool ? ` ${entry.tool}` : ""}${entry.agent === "main" ? "" : " (agent)"}`, + isDim: entry.at !== opened, + onPress: () => { + opened = entry.at === opened ? undefined : entry.at; + host.redraw(); + }, + }, + {`${entry.dropped.length ? "deduped · " : ""}${tokensText(entry.chars)} · ${ago(entry.at, now)}`} + ) + )} + + {shown ? ( + + {divider(kit)} + + {shown.text.split("\n").slice(0, 30).join("\n")} + + + ) : null} + + ); +} diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 621d4182d4..aa1aa657a6 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -11,13 +11,13 @@ import type { import { atom, read, update } from "claude-code"; import type { FlippedFiles, ShownCalls } from "../types"; import { agentModelStep, spawnWithModel } from "./agent-model"; -import { agentsBand, agentsRow, recordSpawn } from "./agents-band"; +import { agentsBand, agentsRow, noteTranscript, recordSpawn } from "./agents-band"; import { askOnRow } from "./ask-row"; import { bandTree } from "./band"; import { cacheBand, cacheRow, markEnded } from "./cache-band"; -import { DECISIONS_PANE, decisions, decisionsPane } from "./decisions"; +import { decisions } from "./decisions"; import { gitBand, gitRow } from "./git-band"; -import { hookLedger, ledgerClassic } from "./hook-ledger"; +import { hookLedger, hooksChip, ledgerClassic } from "./hook-ledger"; import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; import { linksStep, rememberToolPaths } from "./links"; import { @@ -347,12 +347,33 @@ function hostOf($: EngineInterface): Host { status: (text) => $.ui.status(text), fill: (args) => $.prompt.fill(args), submit: (text) => $.prompt.submit({ text }), - openPane: (id, title) => $.ui.open({ id, title, focus: true }), + openPane: (id, title) => openSidebar($, id, title), + togglePane: async (id, title) => { + const isShown = (await $.ui.panes()).some((pane) => pane.id === id && pane.isShown); + + return isShown ? $.ui.close({ id }) : openSidebar($, id, title); + }, closePane: (id) => $.ui.close({ id }), + copy: (text) => $.ui.copy({ text }), + openUrl: (url) => $.process.run(["open", url], { timeoutMs: 10_000 }), redraw: () => $.ui.invalidate("ui.render"), }; } +/** Whether the terminal docks panes (fullscreen), and its height, as the band's last drawing said. */ +let docksPanes: boolean | undefined; +let terminalRows = 30; + +/** + * Docked beside the transcript, a sidebar leaves the keyboard on the prompt; as a dialog above the prompt it takes the + * keyboard and closes on Escape, as the diff mod's dialog does. + */ +function openSidebar($: EngineInterface, id: string, title: string) { + const rows = Math.max(12, Math.floor(terminalRows * 0.6)); + + return $.ui.open(docksPanes === true ? { id, title } : { id, title, focus: true, closeOnEscape: true, rows }); +} + // The features that share events. The engine takes one unmatched hook per event from a plugin, so each event // below has one hook that calls every feature's part in this order. const FEATURES: Feature[] = [decisions, gitBand, spinner, cacheBand, tmpUsage, agentsBand, hookLedger]; @@ -435,6 +456,7 @@ export const register: Register = (on) => { }); on("classic.*", async ($, e, next) => { + noteTranscript(e); const result = await next(e); const kept = await ledgerClassic(hostOf($), e, result); @@ -493,15 +515,38 @@ export const register: Register = (on) => { return next(e); } + docksPanes = e.viewport?.isFullscreen ?? docksPanes; + terminalRows = e.viewport?.rows ?? terminalRows; const host = hostOf($); const ui = $.ui.resolve(e); - const rows = await Promise.all([gitRow(host, ui), cacheRow(host, ui), agentsRow(host, ui), tmpRow(host, ui)]); - - return bandTree(ui, rows, await next(e)); + const columns = e.props.bodyColumns; + const [git, hooks, ...rest] = await Promise.all([ + gitRow(host, ui), + hooksChip(host, ui), + cacheRow(host, ui), + agentsRow(host, ui, columns), + tmpRow(host, ui), + ]); + + return bandTree(ui, [topRow(ui, git, hooks), ...rest], await next(e)); }); - on("ui.render", { component: "Pane", requestId: DECISIONS_PANE }, async ($, e) => { - return decisionsPane(hostOf($), $.ui.resolve(e)); + // Every feature's sidebar (`Feature.sidebars`), by the pane's id; another plugin's panes pass on. + on("ui.render", { component: "Pane" }, async ($, e, next) => { + const sidebar = FEATURES.flatMap((feature) => feature.sidebars ?? []).find((held) => held.id === e.requestId); + + if (!sidebar) { + return next(e); + } + + const kit = { + ui: $.ui.resolve(e), + columns: Math.max(1, e.props.bodyColumns - (e.props.placement === "dock" ? 1 : 0)), + rows: e.props.scroll.bodyRows, + placement: e.props.placement, + }; + + return sidebar.draw(hostOf($), kit); }); on("ui.render", { component: "Spinner" }, async ($, e, next) => { @@ -532,6 +577,23 @@ export const register: Register = (on) => { return askOnRow(hostOf($), ui, drawn, { id: tool_use_id, isRunning, calls: [{ tool, input, output }] }); }); }; +/** The band's first row: the git row at the left, the hooks chip at the right edge; either may be missing. */ +function topRow(ui: ElementTable, git: RenderElement | null, hooks: RenderElement | null): RenderElement | null { + if (!hooks) { + return git; + } + + const { Box } = ui; + + return ( + + {git} + + {hooks} + + ); +} + async function withGroupDiffs( $: EngineInterface, props: { diff --git a/plugins/genesis-tools-mods/hooks/shared.ts b/plugins/genesis-tools-mods/hooks/shared.ts index d1e81d6d57..4551fd224c 100644 --- a/plugins/genesis-tools-mods/hooks/shared.ts +++ b/plugins/genesis-tools-mods/hooks/shared.ts @@ -18,6 +18,7 @@ import type { TurnCompleteInput, } from "claude-code"; import { type ModsSettings, modsSettings } from "./settings"; +import type { Sidebar } from "./sidebar"; export { isRecord, MODS_DEFAULTS, type ModsSettings, modsSettings } from "./settings"; @@ -40,8 +41,14 @@ export interface Host { status: (text: string | undefined) => void; fill: (args: PromptFillArgs) => Promise; submit: (text: string) => Promise; + /** Opens a sidebar: docked beside the transcript in fullscreen, a dialog with the keyboard (Esc closes) otherwise. */ openPane: (id: string, title: string) => Promise; + /** Closes the sidebar when it shows, else opens it: what a press on its band chip does. */ + togglePane: (id: string, title: string) => Promise; closePane: (id: string) => Promise; + copy: (text: string) => Promise; + /** Opens a URL with macOS `open`: a browser page, or a genesis.tools route. */ + openUrl: (url: string) => Promise; /** Draws this plugin's `ui.render` hooks again, so a band or pane shows the state just changed. */ redraw: () => void; } @@ -131,4 +138,6 @@ export interface Feature { afterTurn?: (host: Host, e: TurnCompleteInput) => Promise; /** After a loop compacted (the main loop when `agentId` is undefined). */ compacted?: (agentId: string | undefined) => void; + /** The sidebars it opens from the band (`sidebar.tsx`). */ + sidebars?: Sidebar[]; } diff --git a/plugins/genesis-tools-mods/hooks/sidebar.tsx b/plugins/genesis-tools-mods/hooks/sidebar.tsx new file mode 100644 index 0000000000..565ee6eb7b --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/sidebar.tsx @@ -0,0 +1,142 @@ +// The sidebar every feature of this plugin opens from the band: a pane the surface docks beside the transcript in +// fullscreen and shows as a dialog above the prompt otherwise, as Claude Code's own `/diff` mod does +// (github.com/anthropics/claude-code, mods/diff/hooks/views). A feature lists its sidebars in `Feature.sidebars`; +// `register.tsx` holds the one `Pane` hook that draws them. The parts below copy the diff mod's look: a header line +// with its controls at the right edge, list rows that are plain dim Buttons with their counts at the right edge, +// dim rules and dim notes. +import type { ElementTable, RenderElement } from "claude-code"; +import type { Host } from "./shared"; + +/** What a sidebar draws with: the surface's elements and the body's size and seat. */ +export interface Kit { + ui: ElementTable; + columns: number; + rows: number; + placement: "dock" | "inline"; +} + +export interface Sidebar { + id: string; + title: string; + draw: (host: Host, kit: Kit) => RenderElement | Promise; +} + +/** One action or row: its address, its text, and what a press does. */ +export interface Pressable { + key: string; + label: string; + onPress: () => void; + isDim?: boolean; + isPrimary?: boolean; +} + +/** The sidebar's body; docked, one column of room at the right, as the diff mod leaves. */ +export function sidebarFrame(kit: Kit, body: RenderElement): RenderElement { + const { Box } = kit.ui; + + return ( + + {body} + + ); +} + +/** The first line: what the sidebar counts, a spacer, its controls at the right edge. */ +export function headerRow(kit: Kit, left: RenderElement, controls: readonly Pressable[] = []): RenderElement { + const { Box } = kit.ui; + + return ( + + {left} + + + {controls.map((control) => actionButton(kit, control))} + + + ); +} + +/** One list row: a plain Button as wide as its label, a spacer, then the tail at the right edge. */ +export function listRow(kit: Kit, row: Pressable, tail: RenderElement | null = null): RenderElement { + const { Box, Button } = kit.ui; + + return ( + + + + {tail} + + ); +} + +export function divider(kit: Kit): RenderElement { + const { Text } = kit.ui; + + return ( + + {"─".repeat(Math.max(1, kit.columns))} + + ); +} + +export function dimNote(kit: Kit, text: string, key?: string): RenderElement { + const { Text } = kit.ui; + + return ( + + {text} + + ); +} + +/** A heading inside the body: bold, with a dim note after it. */ +export function sectionTitle(kit: Kit, title: string, note = ""): RenderElement { + const { Text } = kit.ui; + + return ( + + {title} + {note ? ` ${note}` : ""} + + ); +} + +export function actionButton(kit: Kit, action: Pressable): RenderElement { + const { Button } = kit.ui; + + return ( + + ); +} + +/** A row of action Buttons, wrapped when the body is narrow. */ +export function actionRow(kit: Kit, actions: readonly Pressable[]): RenderElement { + const { Box } = kit.ui; + + return ( + + {actions.map((action) => actionButton(kit, action))} + + ); +} + +/** `1 tool`, `3 tools`. */ +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** A bar of `width` cells, full to `share` (0 to 1), as the diff mod's todo bar draws one. */ +export function barText(share: number, width: number): string { + const full = Math.round(Math.min(1, Math.max(0, share)) * width); + + return `${"█".repeat(full)}${"░".repeat(width - full)}`; +} diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx index dfe5bd6dc7..dfe626f372 100644 --- a/plugins/genesis-tools-mods/tests/features.test.tsx +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -1,10 +1,11 @@ import type { On, TurnStepChunk } from "claude-code"; import { expect, mock, test } from "claude-code/testing"; +import { bareDescription, lastSaidOf } from "../hooks/agents-band"; import { quoteOf } from "../hooks/ask-row"; import { cacheRowsOf } from "../hooks/cache-band"; import { openDecisions, stagedMessage } from "../hooks/decisions"; -import { checkCounts, parseStatus } from "../hooks/git-band"; -import { dedupeEntry, tokensText } from "../hooks/hook-ledger"; +import { checkCounts, checksOf, hubUrlOf, parseStatus } from "../hooks/git-band"; +import { dedupeEntry, ledgerOf, tokensText } from "../hooks/hook-ledger"; import { targetFor } from "../hooks/links"; import { toolLabel } from "../hooks/spinner"; @@ -47,6 +48,37 @@ test("git band: status line and CI counts", async () => { { state: "SUCCESS" }, ]) ).toEqual({ passed: 2, failed: 1, pending: 2 }); + expect( + checksOf([ + { name: "lint", conclusion: "FAILURE" }, + { context: "ci/deploy", state: "PENDING" }, + ]) + ).toEqual([ + { name: "lint", verdict: "fail" }, + { name: "ci/deploy", verdict: "pending" }, + ]); + expect(hubUrlOf({ url: "https://github.com/acme/widgets/pull/42", number: 42 })).toBe( + "https://genesis.tools/hub?pr=acme%2Fwidgets%2342" + ); + expect(hubUrlOf({ url: "https://gitlab.example.com/a/b/-/merge_requests/3", number: 3 })).toBeUndefined(); +}); + +test("agents sidebar: an agent's last words from its transcript's end, past a byte-cut first line", async () => { + const row = (text: string) => + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text }, { type: "tool_use" }] } }); + const tail = [ + 'cut": "half a row"}', + row("Reading the tests."), + JSON.stringify({ type: "user", message: { content: [{ type: "tool_result" }] } }), + row("Found it: the timeout is 5 s."), + JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use" }] } }), + ].join("\n"); + + expect(lastSaidOf(tail)).toBe("Found it: the timeout is 5 s."); + expect(lastSaidOf("")).toBe(""); + expect(bareDescription("(H) Sleeper one")).toBe("Sleeper one"); + expect(bareDescription("haiku-5-5 · Sleeper one")).toBe("Sleeper one"); + expect(bareDescription("Fix the (B) case")).toBe("Fix the (B) case"); }); test("hook ledger: a repeated entry and a repeated area block become receipts", async () => { @@ -66,6 +98,13 @@ test("hook ledger: a repeated entry and a repeated area block become receipts", expect(whole.text.startsWith("[genesis-tools-mods:")).toBe(true); expect(dedupeEntry("short", new Map([["5:0", 1]]), 4).text).toBe("short"); expect(tokensText(38_200 * 4)).toBe("~38.2k"); + + const stored = { main: { fires: 2, chars: 90 }, agents: { fires: 0, chars: 0 }, dropped: { fires: 0, chars: 0 } }; + expect(ledgerOf({ ...stored, byEvent: { PostToolUse: { fires: 2, chars: 90 }, Bad: 3 } })).toEqual({ + ...stored, + byEvent: { PostToolUse: { fires: 2, chars: 90 } }, + }); + expect(ledgerOf({ main: 1 })).toBeUndefined(); }); test("cache band: own row first, others only inside the warning window", async () => { diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts index 61b7300c32..31564427e9 100644 --- a/plugins/genesis-tools-mods/types/index.d.ts +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -23,6 +23,12 @@ export type OpenDecisions = OpenDecision[]; /** What the person staged per decision number: `a)` for an option, or their own words. */ export type StagedDecisions = Record; +/** One CI check of a pull request. */ +export interface BandCheck { + name: string; + verdict: "pass" | "fail" | "pending"; +} + /** One pull request the git band shows. */ export interface BandPr { number: number; @@ -33,6 +39,7 @@ export interface BandPr { passed: number; failed: number; pending: number; + checks: BandCheck[]; tracked: boolean; } diff --git a/src/agents/README.md b/src/agents/README.md index 190e17c107..885de3bb2c 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -246,16 +246,23 @@ checked against real use. an optional `:line` or `:line-line`, a commit SHA that `git cat-file` knows, and a repeated `❓ DECISION N` become links; code fences, inline code that does not resolve, existing links and URLs stay as they are. - `mods.git`: a band line above the prompt with the branch, unpushed and unpulled commits, the dirty count and the - PRs (the branch's own and the ones `/mod-git pr add 123` tracks) with CI counts. `/mod-git on|off|toggle|refresh`. + PRs (the branch's own and the ones `/mod-git pr add 123` tracks) with CI counts. A press on the branch or a PR, or a + bare `/mod-git`, opens the Git sidebar: each PR's title, failing and pending checks, and `open`, `hub`, `copy link` + and `track` buttons. `/mod-git on|off|toggle|refresh`. - `mods.askRow`: `[ask]` beside a finished tool row puts the row, quoted, into the prompt. - `mods.spinner`: the spinner line names the running tool, its time and the working subagents. - `mods.cacheBand` (`warnMinutes` 10, `others`): the prompt cache countdown, red under `warnMinutes`, and the other sessions whose cache runs out within it. - `mods.tmpUsage` (`minutes` 5): the size of the session's `/tmp/cc` folder and the free disk. -- `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls. -- `mods.hooks` (`tokens`, `dedupe`): the status line counts what the settings and plugin hooks handed the model - (`hooks ~38.2k tok · 41 fires`, characters / 4), and a block already handed to the same loop since its last - compaction is replaced with a one-line receipt. +- `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls; + more than three fold into one `▸ 8 agents` line. A press on an agent, or `/mod-agents`, opens the Agents sidebar: + its recent tools, what it said last, and `message`, `ask about it`, `stop`, `copy id` and `copy transcript path`. +- `mods.hooks` (`tokens`, `dedupe`): a band chip counts what the settings and plugin hooks handed the model in this + session (`hooks ~38.2k · 41`, characters / 4); a press on it, or `/mod-hooks`, opens the Hooks sidebar with the + count per event, the latest injections and a dedupe switch for the session. A block already handed to the same + loop since its last compaction is replaced with a one-line receipt. +- The sidebars dock beside the transcript in fullscreen and open as a dialog above the prompt otherwise, as Claude + Code's own `/diff` panel does; a second press on the chip, or the same command again, closes one. A file or a whole folder moved with `mv` or `git mv` is one line, `old/ → new/ (284 files)`, when the new copies match the old ones byte for byte diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index 38d58693be..57a35f4084 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -308,7 +308,7 @@ export interface ModsConfig { /** One band row per working subagent: model, age, idle time, tool calls; red past `warnMinutes`. */ agentsBand: { enabled: boolean; warnMinutes: number }; /** - * What the classic hooks hand the model: `tokens` pins the estimate as the plugin's status line, `dedupe` + * What the classic hooks hand the model: `tokens` shows the estimate as a band chip that opens a sidebar, `dedupe` * replaces a context block already given to the same loop since its last compaction with a one-line receipt. */ hooks: { tokens: boolean; dedupe: boolean }; From d43e118cb603a8469c8ba88ea594434b64706cc9 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 14:49:42 +0200 Subject: [PATCH 016/251] fix(codex): #507 followup - a review ledger append after a torn last line starts on its own line, so a redone import never folds the old batch's turns into its row --- src/codex/lib/review/ledger.ts | 31 +++++++++++++++++++++++++++-- src/codex/lib/review/review.test.ts | 31 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/codex/lib/review/ledger.ts b/src/codex/lib/review/ledger.ts index a94d538caa..190e094909 100644 --- a/src/codex/lib/review/ledger.ts +++ b/src/codex/lib/review/ledger.ts @@ -1,4 +1,4 @@ -import { appendFileSync, mkdirSync } from "node:fs"; +import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs"; import { dirname, join } from "node:path"; import { codexRoot } from "@genesiscz/utils/codex/worker-paths"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -135,7 +135,13 @@ export function appendReviewRecords( ): { ok: true } | { ok: false; error: string } { try { mkdirSync(dirname(path), { recursive: true }); - appendFileSync(path, records.map((record) => `${SafeJSON.stringify(record, { jsonl: true })}\n`).join("")); + // A torn last line (a full disk or a killed writer) must stay its own line: appended onto, it would swallow + // this batch's first record, and a redone import's turns would fold into the old start's row. + const lead = endsMidLine(path) ? "\n" : ""; + appendFileSync( + path, + lead + records.map((record) => `${SafeJSON.stringify(record, { jsonl: true })}\n`).join("") + ); log.debug({ path, records: records.length }, "review ledger records written"); return { ok: true }; } catch (err) { @@ -144,6 +150,27 @@ export function appendReviewRecords( } } +/** True when the file exists, is not empty and its last byte is not a newline. */ +function endsMidLine(path: string): boolean { + if (!existsSync(path)) { + return false; + } + + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size === 0) { + return false; + } + + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, size - 1); + return last[0] !== 0x0a; + } finally { + closeSync(fd); + } +} + function isLedgerRecord(value: unknown): value is ReviewLedgerRecord { return ( isObject(value) && diff --git a/src/codex/lib/review/review.test.ts b/src/codex/lib/review/review.test.ts index 5049155615..3d078616bb 100644 --- a/src/codex/lib/review/review.test.ts +++ b/src/codex/lib/review/review.test.ts @@ -926,6 +926,37 @@ describe("reviews import: verdicts and failed writes", () => { expect(again.status).toBe("skipped"); }); + test("a redo after a torn last line keeps the line boundary, so the old batch's turns never fold into the new row", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-torn-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const [first] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(first.status).toBe("imported"); + const clean = summarizeReviews(readReviewRecords(ledgerPath).records); + + // The same batch again, torn inside its gate line: complete start and turn lines, then an unterminated fragment. + const text = readFileSync(ledgerPath, "utf8"); + const gateAt = text.lastIndexOf('{"v":1'); + writeFileSync(ledgerPath, text.slice(0, gateAt + 20)); + const [redone] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(redone.status).toBe("imported"); + + const { records, skipped } = readReviewRecords(ledgerPath); + expect(skipped).toBe(1); + expect(summarizeReviews(records).map((row) => [row.name, row.verdict, row.turns, row.costUsd])).toEqual( + clean.map((row) => [row.name, row.verdict, row.turns, row.costUsd]) + ); + }); + test("tools codex reviews import exits 1 for a missing session in table and --json output alike", async () => { const home = mkdtempSync(join(tmpdir(), "gt-review-import-cli-")); const previousExitCode = process.exitCode; From 8467cfaddf2d9540e738fa6fff9f87cfa6e10d65 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:19:43 +0200 Subject: [PATCH 017/251] fix(du): identify the index volume by its APFS UUID, so a reboot that renumbers the device no longer fails every update with volume_changed (176 in a row since 2026-10-17); the diff row's after side is optional, and the reader filters scans by status and kind so failed updates cannot hide the last finished one --- src/du/lib/index/build.ts | 9 +++- src/du/lib/index/update.ts | 28 ++++++++-- src/du/lib/index/volume.test.ts | 33 ++++++++++++ src/du/lib/index/volume.ts | 54 +++++++++++++++++++ .../GenesisKitDisk/Disk/DuIndexModels.swift | 7 ++- .../GenesisKitDisk/Disk/DuIndexReader.swift | 13 +++-- .../Disk/contract-du-index-diff.json | 9 ++++ .../GenesisKitTests/Disk/DuIndexTests.swift | 19 ++++++- 8 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/du/lib/index/volume.test.ts create mode 100644 src/du/lib/index/volume.ts diff --git a/src/du/lib/index/build.ts b/src/du/lib/index/build.ts index 3e782cb318..2099182b6a 100644 --- a/src/du/lib/index/build.ts +++ b/src/du/lib/index/build.ts @@ -21,6 +21,7 @@ import { } from "./store"; import type { BuildResult, IndexEvent, ProgressEvent } from "./types"; import { IndexError } from "./types"; +import { sameVolume, volumeUuidAt } from "./volume"; import { runIndexWalk, type WalkNode, type WalkOutput, type WalkProgress } from "./walk"; const log = logger.child({ component: "du:index:build" }); @@ -307,7 +308,12 @@ export async function buildIndex(opts: BuildOptions): Promise { // Once the rows are in, a cancel no longer helps anyone: the commit is about a second. let reset: BuildResult["reset"] = opts.reset ? "requested" : null; const knownFsid = getMeta(db, "volume_fsid"); - if (!reset && knownFsid !== null && walk.end.fsid !== "" && knownFsid !== walk.end.fsid) { + const knownUuid = getMeta(db, "volume_uuid"); + const volumeUuid = volumeUuidAt(root); + if ( + !reset && + !sameVolume({ fsid: knownFsid, uuid: knownUuid }, { fsid: walk.end.fsid || null, uuid: volumeUuid }) + ) { reset = "volume_changed"; log.warn({ knownFsid, fsid: walk.end.fsid, root }, "volume changed; rebuilding from empty"); } @@ -366,6 +372,7 @@ export async function buildIndex(opts: BuildOptions): Promise { const meta: Record = { root, volume_fsid: walk.end.fsid, + ...(volumeUuid ? { volume_uuid: volumeUuid } : {}), private: withPrivate ? "1" : "0", min_dir_bytes: String(minDirBytes), big_file_floor: String(bigFileFloor), diff --git a/src/du/lib/index/update.ts b/src/du/lib/index/update.ts index d6e701d485..f0df5a9433 100644 --- a/src/du/lib/index/update.ts +++ b/src/du/lib/index/update.ts @@ -28,6 +28,7 @@ import { } from "./store"; import type { DirRow, IndexEvent, RegionResult, UpdateResult } from "./types"; import { IndexError } from "./types"; +import { sameVolume, volumeUuidAt } from "./volume"; import { runIndexWalk, type WalkOutput } from "./walk"; const log = logger.child({ component: "du:index:update" }); @@ -372,6 +373,10 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb const bigFileFloor = Number(getMeta(db, "big_file_floor") ?? INDEX_DEFAULTS.bigFileFloor); const bigFileLimit = Number(getMeta(db, "big_file_limit") ?? INDEX_DEFAULTS.bigFileLimit); const fsid = getMeta(db, "volume_fsid"); + const knownUuid = getMeta(db, "volume_uuid"); + // Read once, and only when needed: a moved fsid, or an index that has no UUID stored yet. + let currentUuid: string | null | undefined = knownUuid === null ? volumeUuidAt(root.name) : undefined; + let movedFsid: string | null = null; const queueReadAt = now(); const queued = opts.fromQueue @@ -484,10 +489,18 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb } if (fsid !== null && walk.end.fsid !== "" && walk.end.fsid !== fsid) { - throw new IndexError( - "volume_changed", - `${owner.path} is on another volume than the index (${walk.end.fsid}, built on ${fsid}). Run a build.` - ); + if (currentUuid === undefined) { + currentUuid = volumeUuidAt(root.name); + } + + if (!sameVolume({ fsid, uuid: knownUuid }, { fsid: walk.end.fsid, uuid: currentUuid })) { + throw new IndexError( + "volume_changed", + `${owner.path} is on another volume than the index (${walk.end.fsid}, built on ${fsid}). Run a build.` + ); + } + + movedFsid = walk.end.fsid; } walks.push({ owner, walk }); @@ -535,6 +548,13 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb assertConsistent(db, touched); setMeta(db, "last_update_scan_id", String(scanId)); setMeta(db, "blocked_dirs", String(countBlocked(db))); + if (movedFsid !== null) { + setMeta(db, "volume_fsid", movedFsid); + } + + if (knownUuid === null && currentUuid) { + setMeta(db, "volume_uuid", currentUuid); + } if (queued.length > 0) { db.run("DELETE FROM dirty_queue WHERE path IN (SELECT value FROM json_each(?)) AND queued_at_ms <= ?", [ SafeJSON.stringify(queued.map((q) => q.path)), diff --git a/src/du/lib/index/volume.test.ts b/src/du/lib/index/volume.test.ts new file mode 100644 index 0000000000..5d6e94eba8 --- /dev/null +++ b/src/du/lib/index/volume.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { sameVolume, volumeUuidAt } from "./volume"; + +describe("sameVolume", () => { + it("trusts the UUID over a device number that moved on remount", () => { + expect(sameVolume({ fsid: "0100000e0000001a", uuid: "A" }, { fsid: "010000100000001a", uuid: "A" })).toBe(true); + }); + + it("reports another volume when the UUIDs differ", () => { + expect(sameVolume({ fsid: "01", uuid: "A" }, { fsid: "01", uuid: "B" })).toBe(false); + }); + + it("accepts a moved fsid once for an index written before the UUID was stored", () => { + expect(sameVolume({ fsid: "0100000e0000001a", uuid: null }, { fsid: "010000100000001a", uuid: "A" })).toBe( + true + ); + }); + + it("falls back to the fsid when no UUID is readable", () => { + expect(sameVolume({ fsid: "01", uuid: null }, { fsid: "02", uuid: null })).toBe(false); + expect(sameVolume({ fsid: "01", uuid: null }, { fsid: "01", uuid: null })).toBe(true); + }); + + it("has nothing to compare without a known fsid", () => { + expect(sameVolume({ fsid: null, uuid: null }, { fsid: "02", uuid: null })).toBe(true); + }); +}); + +describe.if(process.platform === "darwin")("volumeUuidAt", () => { + it("names the volume UUID of the home folder", () => { + expect(volumeUuidAt(process.env.HOME ?? "/")).toMatch(/^[0-9A-F-]{36}$/); + }); +}); diff --git a/src/du/lib/index/volume.ts b/src/du/lib/index/volume.ts new file mode 100644 index 0000000000..2c064672d7 --- /dev/null +++ b/src/du/lib/index/volume.ts @@ -0,0 +1,54 @@ +import { logger } from "@genesiscz/utils/logger"; + +const log = logger.child({ module: "du-index-volume" }); + +/** What identifies the volume an index was built on: the walker's `statfs` fsid and the APFS volume UUID. */ +export interface VolumeIdentity { + fsid: string | null; + uuid: string | null; +} + +/** + * Whether a walk is on the volume the index was built on. + * + * The fsid alone is not stable: macOS hands a volume a new device number when it mounts in another + * order, so after a reboot the boot volume's fsid changed (0100000e… vs 01000010…) and every update + * failed with `volume_changed` for six days (2026-10-17 to 2026-10-23). The volume UUID survives a + * remount, so it decides whenever both sides have one. An index written before the UUID was stored + * keeps the path as its only other evidence, so a moved fsid with a readable UUID is accepted once + * and the caller stores that UUID. + */ +export function sameVolume(known: VolumeIdentity, current: VolumeIdentity): boolean { + if (known.uuid && current.uuid) { + return known.uuid === current.uuid; + } + + if (!known.fsid || !current.fsid || known.fsid === current.fsid) { + return true; + } + + return known.uuid === null && current.uuid !== null; +} + +/** The APFS/HFS volume UUID of the volume holding `path`, or null when macOS does not report one. */ +export function volumeUuidAt(path: string): string | null { + if (process.platform !== "darwin") { + return null; + } + + const df = Bun.spawnSync(["/bin/df", "-P", path], { stderr: "pipe" }); + const mount = df.stdout.toString().trim().split("\n").at(-1)?.split(/\s+/).slice(5).join(" "); + if (df.exitCode !== 0 || !mount) { + log.warn({ path, stderr: df.stderr.toString().trim() }, "df could not name the mount point"); + return null; + } + + const info = Bun.spawnSync(["/usr/sbin/diskutil", "info", "-plist", mount], { stderr: "pipe" }); + const match = info.stdout.toString().match(/VolumeUUID<\/key>\s*([^<]+)<\/string>/); + if (info.exitCode !== 0 || !match) { + log.warn({ path, mount, stderr: info.stderr.toString().trim() }, "diskutil reported no volume UUID"); + return null; + } + + return match[1]; +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift index 319c63de60..6af5b3a3f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift @@ -374,7 +374,10 @@ public struct DuIndexDiffRow: Codable, Equatable, Identifiable, Sendable { public var path: String /// nil when the old side is not known exactly (`approx`). public var before: Int64? - public var after: Int64 + /// nil when the folder fell below the storage floor or out of the index (`approx`): the CLI keeps + /// that side unknown rather than inventing zero bytes. Required before 2026-10-23, so one such row + /// failed the whole Radar "Fast-growing folders" card ("no value for Int64 at rows.Index 21.after"). + public var after: Int64? public var delta: Int64 /// `grown`, `shrunk`, `new` or `gone`. public var status: String @@ -383,7 +386,7 @@ public struct DuIndexDiffRow: Codable, Equatable, Identifiable, Sendable { public var id: String { path } - public init(path: String, before: Int64?, after: Int64, delta: Int64, status: String, filesDelta: Int64? = nil, + public init(path: String, before: Int64?, after: Int64?, delta: Int64, status: String, filesDelta: Int64? = nil, approx: Bool? = nil) { self.path = path self.before = before diff --git a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift index 696a35feda..737d9685ae 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift @@ -334,13 +334,18 @@ public final class DuIndexReader: @unchecked Sendable { } /// The most recent scans, newest first. - public func scans(limit: Int = 10) throws -> [DuIndexScan] { + /// The newest scans first. `status` and `kind` filter in SQL, so "the last finished build" is found + /// however many failed updates came after it (a volume-id mismatch failed 176 in a row by 2026-10-23, + /// and a 20-row window then showed "no index" over a good one). + public func scans(limit: Int = 10, status: String? = nil, kind: String? = nil) throws -> [DuIndexScan] { try locked { var result: [DuIndexScan] = [] try query(""" SELECT id, kind, status, started_at_ms, finished_at_ms, files, dirs, alloc_bytes, walk_ms, threads - FROM scans ORDER BY id DESC LIMIT ?1 - """, [.int(Int64(max(0, limit)))]) { row in + FROM scans WHERE (?2 IS NULL OR status = ?2) AND (?3 IS NULL OR kind = ?3) + ORDER BY id DESC LIMIT ?1 + """, [.int(Int64(max(0, limit))), status.map { .text($0) } ?? .null, + kind.map { .text($0) } ?? .null]) { row in result.append(DuIndexScan(id: row.int(0), kind: row.text(1), status: row.text(2), startedAtMs: row.int(3), finishedAtMs: row.optionalInt(4), files: row.optionalInt(5), dirs: row.optionalInt(6), allocBytes: row.optionalInt(7), @@ -360,6 +365,7 @@ public final class DuIndexReader: @unchecked Sendable { enum Value { case int(Int64) case text(String) + case null } struct Row { @@ -440,6 +446,7 @@ public final class DuIndexReader: @unchecked Sendable { switch value { case .int(let number): sqlite3_bind_int64(statement, position, number) case .text(let text): sqlite3_bind_text(statement, position, text, -1, Self.transient) + case .null: sqlite3_bind_null(statement, position) } } while true { diff --git a/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json index 2172e6c0f2..46ae389b4a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json +++ b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json @@ -63,6 +63,15 @@ "status": "shrunk", "files_delta": -300, "approx": false + }, + { + "path": "/Users/example/Library/Caches/Below-Floor", + "before": 268435456, + "after": null, + "delta": -251658240, + "status": "shrunk", + "files_delta": -41, + "approx": true } ], "truncated": false diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift index eba8e5abe8..e70e8656c1 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift @@ -60,7 +60,9 @@ final class DuIndexContractTests: XCTestCase { XCTAssertEqual(info.dbBytes, 1_835_008) let diff = try decodeIndex(DuIndexDiff.self, "contract-du-index-diff") - XCTAssertEqual(diff.rows.map(\.status), ["grown", "new", "grown", "gone", "shrunk"]) + XCTAssertEqual(diff.rows.map(\.status), ["grown", "new", "grown", "gone", "shrunk", "shrunk"]) + XCTAssertNil(diff.rows[5].after, "a folder below the floor keeps its new size unknown (diff.ts writes null)") + XCTAssertEqual(diff.rows[5].approx, true) XCTAssertNil(diff.rows[2].before, "an approximate old side") XCTAssertEqual(diff.rows[2].approx, true) XCTAssertEqual(diff.to.live, true) @@ -291,6 +293,21 @@ final class DuIndexReaderTests: XCTestCase { XCTAssertEqual(try reader.node(id: 22)?.path, "/Users/example/Library/Logs") } + func testTheLastFinishedScanIsFoundBehindARunOfFailedUpdates() throws { + let failed = (44 ..< 69).map { + "(\($0), 'update', '/Users/example', \(1760390000000 + Int64($0) * 1_200_000), NULL, 'failed', NULL, NULL, NULL, NULL, NULL, '{}')" + } + try exec(""" + INSERT INTO scans (id, kind, root, started_at_ms, finished_at_ms, status, files, dirs, alloc_bytes, walk_ms, threads, options_json) + VALUES \(failed.joined(separator: ",\n")); + """) + let reader = try DuIndexReader(path: dbPath) + XCTAssertEqual(try reader.scans(limit: 20).filter { $0.status == "done" }.count, 0, "the old window saw only failures") + XCTAssertEqual(try reader.scans(limit: 1, status: "done").first?.id, 43) + XCTAssertEqual(try reader.scans(limit: 1, status: "done", kind: "build").first?.id, 42) + XCTAssertEqual(try reader.scans(limit: 1).first?.status, "failed") + } + func testChildrenLocalTopFilesAndScansMatchTheContract() throws { let reader = try DuIndexReader(path: dbPath) guard case .found(let library) = try reader.lookup("/Users/example/Library") else { return XCTFail("found") } From 81e72bf9711b2713fdd808247551c11a8dff8a60 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:19:50 +0200 Subject: [PATCH 018/251] feat(notifications): one ledger of every notification at ~/.genesis-tools/notifications/ledger.jsonl, written by postNotification and the telegram, webhook and say channels in TypeScript and by GenesisKit's NotificationLedger in Swift (hang, focus and clicky posts), so what pinged and why is one read --- .../GenesisKit/Clicky/ClickyModel.swift | 1 + .../Focus/Pomodoro/FocusChime.swift | 1 + .../Notifications/NotificationLedger.swift | 85 +++++++++++++++++++ .../GenesisKitBase/Perf/HangWatch.swift | 2 + .../NotificationLedgerTests.swift | 46 ++++++++++ src/utils/macos/notifications.ts | 17 ++++ src/utils/notifications/dispatch.ts | 22 +++++ src/utils/notifications/ledger.test.ts | 41 +++++++++ src/utils/notifications/ledger.ts | 56 ++++++++++++ 9 files changed, 271 insertions(+) create mode 100644 src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift create mode 100644 src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift create mode 100644 src/utils/notifications/ledger.test.ts create mode 100644 src/utils/notifications/ledger.ts diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift index fee7be179a..448ec64222 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift @@ -623,5 +623,6 @@ public final class ClickyModel: ObservableObject { } } } + NotificationLedger.record(source: "clicky", channel: "macos", title: title, body: body) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift index d64ee2d739..ec2d3f0e78 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift @@ -93,5 +93,6 @@ public enum FocusChime { FlowFocusLog.focus.debug("focus phase notification failed: \(error.localizedDescription)") } } + NotificationLedger.record(source: "focus", channel: "macos", title: content.title, body: content.body) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift new file mode 100644 index 0000000000..4ad2bcf9b8 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift @@ -0,0 +1,85 @@ +import Foundation + +/// The one record of every notification a GenesisTools or Genesis surface sent, or decided not to send: +/// `~/.genesis-tools/notifications/ledger.jsonl` (under `GENESIS_TOOLS_HOME` when set), one JSON object per line. +/// The TypeScript side writes the same file with the same fields (`src/utils/notifications/ledger.ts`), so "what +/// pinged me in the last 40 hours, and why" is one read (Martin, 2026-10-23). Before this, Genesis.app's cache +/// banners were logged nowhere, and the macOS unified log kept about 14 hours of them without titles. +/// +/// Fields every line has: `at`, `source` (`monitor.cache`, `disk`, `hang`, ...), `channel` (`macos`, `in-app`), +/// `outcome` (`posted`, `failed`, `suppressed`), `writer`. A suppressed line names its `reason`. +public enum NotificationLedger { + public enum Outcome: String, Sendable { + case posted + case failed + case suppressed + } + + public static var path: String { + let override = ProcessInfo.processInfo.environment["GENESIS_TOOLS_HOME"] + let home = override?.isEmpty == false ? override! : NSHomeDirectory() + return (home as NSString).appendingPathComponent(".genesis-tools/notifications/ledger.jsonl") + } + + /// Past this size the file moves to `ledger.jsonl.1` (replacing the older one) and a new file starts. + public static let maxBytes: Int64 = 20 * 1024 * 1024 + + /// Writes happen off the caller's thread, in order. A poster on the main actor never waits on the disk. + private static let queue = DispatchQueue(label: "genesis.notification-ledger", qos: .utility) + + /// Appends one line. Values that are not JSON are dropped and logged, never thrown: a ledger write must not + /// stop a notification. + public static func record( + source: String, + channel: String, + outcome: Outcome = .posted, + title: String? = nil, + subtitle: String? = nil, + body: String? = nil, + reason: String? = nil, + fields: [String: Any] = [:], + file: String? = nil + ) { + var entry = fields.filter { JSONSerialization.isValidJSONObject(["value": $0.value]) } + if entry.count != fields.count { + KitHost.log("notification ledger \(source): dropped non-JSON fields \(Set(fields.keys).subtracting(entry.keys).sorted())") + } + entry["at"] = ISO8601DateFormatter().string(from: Date()) + entry["source"] = source + entry["channel"] = channel + entry["outcome"] = outcome.rawValue + if let title { entry["title"] = title } + if let subtitle { entry["subtitle"] = subtitle } + if let body { entry["body"] = body } + if let reason { entry["reason"] = reason } + entry["writer"] = [ + "app": Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName, + "pid": Int(getpid()), + ] + guard let data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { + KitHost.log("notification ledger \(source): the entry is not JSON") + return + } + // A test run must not write into the user's real ledger; a test that checks the ledger passes `file`. + if file == nil, NSClassFromString("XCTestCase") != nil { return } + let target = file ?? path + queue.async { + rotateIfFull(target) + GateAudit.appendLine(data, to: target) + } + } + + /// Waits for the lines queued so far. Tests only: posters never wait. + public static func flush() { + queue.sync {} + } + + private static func rotateIfFull(_ file: String) { + guard let size = (try? FileManager.default.attributesOfItem(atPath: file)[.size] as? NSNumber)?.int64Value, + size > maxBytes + else { return } + let older = file + ".1" + try? FileManager.default.removeItem(atPath: older) + try? FileManager.default.moveItem(atPath: file, toPath: older) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift index 1bc1ae036c..81ed3eb68c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift @@ -207,6 +207,8 @@ public enum HangWatch { UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: "genesis.hang.\(UUID().uuidString)", content: content, trigger: nil) ) + NotificationLedger.record(source: "hang", channel: "macos", title: content.title, body: content.body, + fields: ["stallSeconds": Int(stallSoFar)]) } /// Test seam: the reporter without a 20 s real stall in front of it. diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift new file mode 100644 index 0000000000..ef47c606ab --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import GenesisKit + +final class NotificationLedgerTests: XCTestCase { + private var directory: URL! + private var file: String { directory.appendingPathComponent("ledger.jsonl").path } + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent("ledger-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func lines() throws -> [[String: Any]] { + NotificationLedger.flush() + return try String(contentsOfFile: file, encoding: .utf8) + .split(separator: "\n") + .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + } + + func testEachRecordIsOneLineWithItsFieldsAndWriter() throws { + NotificationLedger.record(source: "monitor.cache", channel: "macos", title: "Cache cooling — 10 min left", + body: "0f61ecfa · col\n~/x", fields: ["sessionId": "0f61ecfa", "ctx": 169_282], file: file) + NotificationLedger.record(source: "monitor.cache", channel: "macos", outcome: .suppressed, + reason: "process exited", fields: ["bad": Date()], file: file) + let written = try lines() + XCTAssertEqual(written.count, 2) + XCTAssertEqual(written[0]["source"] as? String, "monitor.cache") + XCTAssertEqual(written[0]["outcome"] as? String, "posted") + XCTAssertEqual(written[0]["ctx"] as? Int, 169_282) + XCTAssertNotNil(written[0]["at"]) + XCTAssertNotNil((written[0]["writer"] as? [String: Any])?["pid"]) + XCTAssertEqual(written[1]["reason"] as? String, "process exited") + XCTAssertNil(written[1]["bad"], "a non-JSON value is dropped, the line is still written") + } + + func testAFullFileMovesAsideBeforeTheNextLine() throws { + try Data(count: Int(NotificationLedger.maxBytes) + 1).write(to: URL(fileURLWithPath: file)) + NotificationLedger.record(source: "disk", channel: "macos", file: file) + XCTAssertEqual(try lines().count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: file + ".1")) + } +} diff --git a/src/utils/macos/notifications.ts b/src/utils/macos/notifications.ts index a8efabbae8..4203ac4c07 100644 --- a/src/utils/macos/notifications.ts +++ b/src/utils/macos/notifications.ts @@ -5,6 +5,7 @@ import { withTimeout } from "@genesiscz/utils/async"; import { moduleDir, requireSibling } from "@genesiscz/utils/fs/module-dir"; import { logger } from "@genesiscz/utils/logger"; import { escapeJxa } from "@genesiscz/utils/macos/jxa"; +import { recordNotification } from "@genesiscz/utils/notifications/ledger"; import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; import { Storage } from "@genesiscz/utils/storage/storage"; @@ -442,6 +443,22 @@ export interface PostedNotification { * Prefer {@link sendNotification} unless you need the id back to retract it later. */ export async function postNotification(opts: NotificationOptions): Promise { + const posted = await postThroughChain(opts); + recordNotification({ + source: opts.group ?? "tools", + channel: "macos", + outcome: "posted", + title: opts.title, + subtitle: opts.subtitle, + body: opts.message, + backend: posted.backend, + confirmed: posted.confirmed, + id: posted.id ?? opts.id ?? null, + }); + return posted; +} + +async function postThroughChain(opts: NotificationOptions): Promise { const chain = backendChain(opts.preferred); for (const backend of chain) { diff --git a/src/utils/notifications/dispatch.ts b/src/utils/notifications/dispatch.ts index 0d8acbd2a8..b1f5a7d934 100644 --- a/src/utils/notifications/dispatch.ts +++ b/src/utils/notifications/dispatch.ts @@ -3,6 +3,7 @@ import { dispatchSystem } from "./channels/system"; import { dispatchTelegram } from "./channels/telegram"; import { dispatchWebhook } from "./channels/webhook"; import { notificationsConfig } from "./config"; +import { recordNotification } from "./ledger"; import type { ChannelName, NotificationEvent, SayChannelConfig } from "./types"; /** True when `tools say` spoke the message (or there was nothing to say). Never throws. */ @@ -49,6 +50,17 @@ export async function dispatchNotification(event: NotificationEvent): Promise only === undefined || only.includes(name); + if (allows("system") && !channels.system.enabled) { + recordNotification({ + source: event.app, + channel: "macos", + outcome: "suppressed", + reason: "system channel off in notifications config", + title: event.title, + body: event.message, + }); + } + const channelNames = ["system", "telegram", "webhook", "say"] as const; const results = await Promise.allSettled([ allows("system") ? dispatchSystem(event, channels.system) : Promise.resolve(true), @@ -60,6 +72,16 @@ export async function dispatchNotification(event: NotificationEvent): Promise { + const saved = process.env.GENESIS_TOOLS_HOME; + + beforeEach(() => { + process.env.GENESIS_TOOLS_HOME = mkdtempSync(join(tmpdir(), "ledger-")); + }); + + afterEach(() => { + process.env.GENESIS_TOOLS_HOME = saved; + }); + + it("appends one JSON line per notification with the writer and time", () => { + recordNotification({ source: "hub", channel: "macos", outcome: "posted", title: "Merged", body: "#561" }); + recordNotification({ source: "hub", channel: "telegram", outcome: "failed", title: "Merged" }); + const lines = readFileSync(notificationLedgerPath(), "utf8") + .trim() + .split("\n") + .map((l) => SafeJSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ source: "hub", channel: "macos", outcome: "posted", body: "#561" }); + expect(lines[1].writer.app).toBe("tools"); + expect(Number.isNaN(Date.parse(lines[0].at))).toBe(false); + }); + + it("moves a full file aside before writing", () => { + recordNotification({ source: "x", channel: "macos", outcome: "posted" }); + writeFileSync(notificationLedgerPath(), "x".repeat(NOTIFICATION_LEDGER_MAX_BYTES + 1)); + recordNotification({ source: "y", channel: "macos", outcome: "posted" }); + expect(readFileSync(notificationLedgerPath(), "utf8").trim().split("\n")).toHaveLength(1); + expect(readFileSync(`${notificationLedgerPath()}.1`, "utf8").length).toBeGreaterThan( + NOTIFICATION_LEDGER_MAX_BYTES + ); + }); +}); diff --git a/src/utils/notifications/ledger.ts b/src/utils/notifications/ledger.ts new file mode 100644 index 0000000000..5be13a04bc --- /dev/null +++ b/src/utils/notifications/ledger.ts @@ -0,0 +1,56 @@ +import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { genesisToolsDir } from "@genesiscz/utils/storage/root"; + +/** + * The one record of every notification any GenesisTools or Genesis surface sent, or decided not to send: + * `~/.genesis-tools/notifications/ledger.jsonl`, one JSON object per line. The Swift apps write the same + * file with the same fields (GenesisKit `NotificationLedger.swift`), so "what pinged me in the last 40 hours, + * and why" is one read (Martin, 2026-10-23: "make sure GenesisTools and GenesisPlayground are being logged + * for every of those notifications"). Before this, Genesis.app's cache banners were logged nowhere, and the + * macOS unified log kept about 14 hours of them without titles. + */ +export function notificationLedgerPath(): string { + return genesisToolsDir("notifications", "ledger.jsonl"); +} + +/** Past this size the file moves to `ledger.jsonl.1` (replacing the older one) and a new file starts. */ +export const NOTIFICATION_LEDGER_MAX_BYTES = 20 * 1024 * 1024; + +export interface NotificationLedgerEntry { + /** What produced it: `claude-usage`, `hub`, `monitor.cache`, `disk`, ... */ + source: string; + /** Where it went: `macos`, `in-app`, `telegram`, `webhook`, `say`. */ + channel: string; + /** `posted`, `failed`, or `suppressed` (a rule decided against it; `reason` says which). */ + outcome: "posted" | "failed" | "suppressed"; + title?: string; + subtitle?: string; + body?: string; + reason?: string; + [field: string]: unknown; +} + +/** Appends one line. Never throws: a ledger write must not stop a notification. */ +export function recordNotification(entry: NotificationLedgerEntry): void { + const path = notificationLedgerPath(); + + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + + if (existsSync(path) && statSync(path).size > NOTIFICATION_LEDGER_MAX_BYTES) { + renameSync(path, `${path}.1`); + } + + const line = { + at: new Date().toISOString(), + ...entry, + writer: { app: "tools", pid: process.pid, argv: process.argv.slice(2, 4) }, + }; + appendFileSync(path, `${SafeJSON.stringify(line)}\n`, { mode: 0o600 }); + } catch (error) { + logger.warn({ error, path, source: entry.source }, "notification ledger: could not write the line"); + } +} From 5e0a6e328d9c3d769759a09cfb1fe12b75766326 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:26:44 +0200 Subject: [PATCH 019/251] feat(GenesisKit, GenesisHub, widgets): the inbox button lists every session's entries, the hover list shows every session that needs you with a filter, an order and a settings shortcut, every session gets a cmux button, and the review commit picker picks a range with Shift --- .claude/skills/swift/references/gotchas.md | 16 ++ .claude/skills/ux/references/checklist.md | 18 ++ .claude/skills/ux/references/findings.md | 18 ++ scripts/native/widget-drive.ts | 1 + src/hub/lib/composer/outbox.test.ts | 20 +++ src/hub/lib/widget/snapshot.ts | 19 +- src/hub/lib/widget/types.ts | 4 + .../GenesisKit/Cmux/CmuxCommandRunner.swift | 120 +++++++++++++ .../GenesisKit/Controls/AgentRosterRow.swift | 13 +- .../Diff/Views/GitCommitPicker.swift | 106 ++++++++++-- .../Settings/NativeSettingsModel.swift | 65 ++++++- .../GenesisKit/Widgets/AgentWidgetViews.swift | 2 + .../GenesisKit/Widgets/LiveWidgetView.swift | 91 ++++++++-- .../Widgets/WidgetAgentPreview.swift | 129 ++++++++------ .../GenesisKit/Widgets/WidgetCmuxButton.swift | 138 +++++++++++++++ .../Widgets/WidgetCoordinator.swift | 1 + .../Widgets/WidgetFeatureSettings.swift | 32 ++++ .../GenesisKit/Widgets/WidgetHostView.swift | 14 +- .../Widgets/WidgetInboxOverview.swift | 162 ++++++++++++++++++ .../GenesisKit/Widgets/WidgetModel.swift | 108 +++++++++++- .../Widgets/WidgetSessionBrowser.swift | 130 ++++++++++---- .../GenesisKit/Widgets/WidgetWireModels.swift | 8 + .../GenesisKitTests/Diff/GitPickerTests.swift | 20 +++ .../WidgetInboxTasksPolishTests.swift | 33 ++++ .../Sources/Parts/Hub/Hub/HubLaunch.swift | 92 +--------- .../Sources/Parts/Hub/Hub/HubWorktrees.swift | 7 +- .../Parts/Hub/Review/ReviewWindow.swift | 31 +++- .../Sources/Parts/Widget/WidgetWindow.swift | 3 + .../Tests/HubMenuButtonTests.swift | 6 +- 29 files changed, 1176 insertions(+), 231 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index 0e8d1e0fa0..97878181d9 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -390,3 +390,19 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not - **Driving a background app's context menu with `tools control` is not safe yet.** `AXShowMenu` on a SwiftUI tab blocked 2 s and opened the parent's menu first; the menu rows were not in `see --scope menu`; a coordinate click into the open menu closed it without choosing. Trigger the code path from a test instead. + +## Row-only controls with popovers, key modifiers, shared builds — 2026-10-23 15:17 + +- **A control that exists only on the hovered row must keep its popover open after the pointer leaves.** The + popover's `isPresented` lives in the control; remove the control on hover-out and SwiftUI closes the popover. + Keep the `.popover` modifier on a same-size `Color.clear` placeholder while the row is not live, and treat "popover + open" as live (`WidgetCmuxButton(live:)`, GenesisKit Widgets). Hover is the row's, not the control's: a hover + sensor on the button covers only the button (`WidgetHoverRow { hovering in … }`). +- **A popover from an edge panel needs its own "dialog open" flag.** Setting the host's shared `dialogOpen` from the + popover's `onChange` can release a window the host opened when the popover closes. One flag per presenter, ORed + into the getter (`sessionChooserOpen`, `cmuxPopoverOpen`). +- **`.onKeyPress(.downArrow) { … }` has no key press to read.** Its closure takes no argument; to read Shift use + `.onKeyPress(.downArrow, phases: [.down, .repeat]) { press in press.modifiers.contains(.shift) }`. A click's + modifiers come from `NSEvent.modifierFlags` inside the button action. +- **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the + build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file. diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 28a2f092a6..5c4cbdcc55 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -45,6 +45,24 @@ Walk it for every view you change. Each line exists because it was missed at lea - One navigation per surface; no two controls that do the same thing side by side. - Distinct icons for distinct features (two modules once shared `waveform`). +## Widget and panel controls (Martin, 2026-10-23 14:40) +- A text field inside a padded box: the whole box focuses the field (`.contentShape` + `.onTapGesture { focus = true }` + on the box); a click beside or under the one-line field did nothing. +- A button that opened something closes it on a second click when what it opened is still on screen (a rail + session's inbox, the inbox overview). A second click that does nothing reads as a dead control. +- A count leads to the list of exactly what it counts. "35 need you" over 4 rows, and "99+ unread" that opened one + arbitrary session, are both defects: the count opens a list of every counted item (scrollable, filterable), and + each item says which session it belongs to (name, project, short id) and how to get there. +- Every session row offers a way to its terminal: the cmux button with GenesisKit `CmuxSessionPanel` (focus, open + in its last pane, choose a pane), one shared component in the hub and the widget. +- A list's filter and order live next to the list, with a settings shortcut that opens the page at the card and + pulses it (`#` + `.nativeSettingsAnchor(id)`, GenesisKit `AttentionPulse`). +- One row style for every top-level item of a list. Sessions with sub-agents drew as disclosure headers and sessions + without as plain rows, in one list: same row, a chevron only where there is something to fold, and selection, pin + and cmux inside the row's own box. +- A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it + otherwise keeps the old offset, and a short section drew its rows over the header. + ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. - Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 54640d8af8..8a5407d1d4 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,6 +3,24 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. +- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** + - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the + box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). + - *A second click on a rail session ("4") did nothing.* Every session button called `openInboxNotification` + again. Fix: `WidgetModel.toggleSession` / `toggleInboxOverview` collapse when that view is already open. + - *"35 need you" with four rows.* The preview took `prefix(4)` of the visible sessions. Fix: `previewRows`, every + session that needs you in a `FittedScroll`, an "Active too" filter, three orders, and a settings shortcut to + Projects / Sessions `#inbox-preview`, which pulses. + - *"99+ unread" opened one random session with no name or way to its terminal, and nothing else was reachable.* + The button picked the newest item of the whole inbox. Fix: an inbox overview over every session (the snapshot + now lists up to eight entries per session, `WidgetInboxSession.items`), each entry with its session and "Open in + its session", a session inbox filter (All / Inbox entries / Decisions), and a cmux button on every session. + - *A session opened from Sessions drew its inbox rows over the header.* The pane's one `ScrollView` kept the long + Sessions list's offset. Fix: `.id(section + "|" + selectedKey)`. + - *Sessions with and without sub-agents looked different.* Two row styles in one list. Fix: one top-level row. + - The first cut put the cmux button on every row of a list of hundreds; the swift skill's dense-list rule caught + it before commit (row-only controls, `WidgetHoverRow`). + - 2026-10-15 00:50 — **The vault approval window (GenesisTools.app `vault.unlock`).** - *"This can be probably nicer": one block of text with full paths, the raw command, two UUIDs and five buttons in a row.* It was an `NSAlert`. Now a SwiftUI panel (GenesisKit Permissions/ApprovalDialog.swift): the reason diff --git a/scripts/native/widget-drive.ts b/scripts/native/widget-drive.ts index faca5aafc2..6cb125e13b 100644 --- a/scripts/native/widget-drive.ts +++ b/scripts/native/widget-drive.ts @@ -13,6 +13,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; * * Commands (WidgetWindow.swift runTestCommand): `expand [group]`, `module [group]`, * `hover [group]`, `unhover [group]`, `collapse`, `select `, `settings [page]`, + * `inbox [group]` (the inbox button), `session [group]` (a rail session button), * `passive on|off`. Send `passive on` before `expand` on the user's own screen: an expanded panel otherwise takes the * keyboard from the app in front. * Edges: top, right, left. The face honours them only with staging on (`bun scripts/native/staging.ts on`) or in the diff --git a/src/hub/lib/composer/outbox.test.ts b/src/hub/lib/composer/outbox.test.ts index d6fba0282e..aac9840eb1 100644 --- a/src/hub/lib/composer/outbox.test.ts +++ b/src/hub/lib/composer/outbox.test.ts @@ -74,9 +74,11 @@ import { import { claudeAssistantText, discoverWidgetCatalog, + INBOX_ITEMS_PER_SESSION, inboxWindowCounts, lastAssistantTextOf, lastClaudeAssistantText, + listedInboxItems, readWidgetChanges, readWidgetDecisionEvents, realWidgetSources, @@ -1677,6 +1679,8 @@ test("decision cards carry recorded answers and supersede links; only open decis number: 3, title: widgetTitle(replacement.prompt), }); + // The overview lists the session's entries, the waiting one first. + expect(session?.items?.[0]).toMatchObject({ id: `decision:${replacement.id}`, needsAnswer: true }); expect( snapshot.notifications?.sessions.find((entry) => entry.pendingItem?.sourceId === elsewhere.id)?.pendingItem ).toMatchObject({ number: 1, title: "Other" }); @@ -1694,6 +1698,22 @@ test("decision cards carry recorded answers and supersede links; only open decis expect(moved.notifications?.sessions.find((entry) => entry.key === sessionKey)?.needsAnswer ?? 0).toBe(0); }); +test("a session lists its waiting entries first, then the newest, at most eight", () => { + const item = (id: string, at: number, needsAnswer = false) => ({ + id, + sourceId: id, + kind: needsAnswer ? ("decision" as const) : ("answer" as const), + key: "k", + at, + needsAnswer, + }); + const items = [item("old", 1), item("ask", 2, true), ...Array.from({ length: 9 }, (_, i) => item(`n${i}`, 10 + i))]; + const listed = listedInboxItems(items); + expect(listed).toHaveLength(INBOX_ITEMS_PER_SESSION); + expect(listed.map((entry) => entry.id).slice(0, 3)).toEqual(["ask", "n8", "n7"]); + expect(listed.map((entry) => entry.id)).not.toContain("old"); +}); + test("alert preferences default on with the Glass sound, load from an older state, and refuse a bad sound", async () => { const directory = await root(); await writeFile( diff --git a/src/hub/lib/widget/snapshot.ts b/src/hub/lib/widget/snapshot.ts index e7da1eca7d..2b4badba76 100644 --- a/src/hub/lib/widget/snapshot.ts +++ b/src/hub/lib/widget/snapshot.ts @@ -195,6 +195,8 @@ export interface WidgetInboxItem { title?: string; /** A decision's number. */ number?: number; + /** How many inbox items this entry stands for: an answers group lists only its newest item. */ + count?: number; } export interface WidgetInboxSession { key: string; @@ -203,6 +205,18 @@ export interface WidgetInboxSession { latest?: WidgetInboxItem; unreadItem?: WidgetInboxItem; pendingItem?: WidgetInboxItem; + /** The session's listed entries, the ones waiting for an answer first, then newest first; at most `INBOX_ITEMS_PER_SESSION`. */ + items?: WidgetInboxItem[]; +} + +/** The entries one session lists in the widget's inbox overview; its counts still cover every item. */ +export const INBOX_ITEMS_PER_SESSION = 8; + +/** A session's entries for the overview: the ones waiting for an answer first, then newest first, capped. */ +export function listedInboxItems(items: WidgetInboxItem[]): WidgetInboxItem[] { + return [...items] + .sort((a, b) => Number(b.needsAnswer) - Number(a.needsAnswer) || b.at - a.at) + .slice(0, INBOX_ITEMS_PER_SESSION); } export interface WidgetInboxSummary { unread: number; @@ -1000,6 +1014,8 @@ export async function widgetSnapshot({ if (!entry[target] || item.at > entry[target].at) { entry[target] = item; } + + entry.items = [...(entry.items ?? []), count > 1 ? { ...item, count } : item]; inboxSessions.set(session.key, entry); }; // Counted per item: each SQL group carries its session's newest item and how many of its items fall after the @@ -1125,7 +1141,8 @@ export async function widgetSnapshot({ truncated: inboxData.truncated || inboxSessions.size > 512, sessions: [...inboxSessions.values()] .sort((a, b) => b.needsAnswer - a.needsAnswer || b.unread - a.unread) - .slice(0, 512), + .slice(0, 512) + .map((entry) => ({ ...entry, items: listedInboxItems(entry.items ?? []) })), ...(prof.enabled ? { profile: { hostId: "local" as const } } : {}), }; const inboxMetadata: { notifications?: WidgetInboxSummary } = { notifications }; diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index 7f19052541..5b8716d922 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -67,6 +67,10 @@ export const widgetPreferencesSchema = z.object({ alertSound: z.string().trim().min(1).max(40).default("Glass"), /** Show the widget's own popup with an alert. */ alertPopup: z.boolean().default(true), + /** The agents hover list: only sessions that need you, or every active session too. */ + previewFilter: z.enum(["needs-you", "active"]).default("needs-you"), + /** The agents hover list's order: waiting for you first, newest activity first, or by project. */ + previewSort: z.enum(["attention", "recent", "project"]).default("attention"), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift new file mode 100644 index 0000000000..3f453ab541 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift @@ -0,0 +1,120 @@ +import Foundation + +/// The cmux CLI for every part that opens a session or a command in a pane: the hub's `CmuxHost` and the widget's cmux +/// button. One binary lookup, the new workspace / pane / tab / type-into-a-tab moves, and the reading of +/// `tools cmux focus --json`. Blocking (each call spawns a process): call it off the main thread. +public enum CmuxCommandRunner { + public static var binary: String? { + ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] + .map { ($0 as NSString).expandingTildeInPath } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + @discardableResult + public static func cmux(_ args: [String]) -> (ok: Bool, out: String) { + guard let binary else { return (false, "cmux is not installed") } + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = args + var environment = ProcessInfo.processInfo.environment + environment["CMUX_QUIET"] = "1" + process.environment = environment + guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } + let text = String(decoding: result.stdout, as: UTF8.self) + return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + } + + /// Runs `command` in `cwd` at `target`. An error message, or nil when it opened. Words are shell-quoted here because + /// cmux takes one command string. + public static func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { + let words = command.map(ShellWords.word).joined(separator: " ") + let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" + switch target { + case .newWorkspace(let window): + let result = cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + + (window.map { ["--window", $0] } ?? [])) + return result.ok ? nil : result.out.trimmed + case .newPane(let workspace): + return typeInto(cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, + "--focus", "true"]), workspace: workspace, line: line) + case .newTab(let workspace, let pane): + return typeInto(cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, + "--focus", "true"]), workspace: workspace, line: line) + case .surface(let workspace, let surface): + return send(line, workspace: workspace, surface: surface) + } + } + + /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. + private static func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { + guard created.ok else { return created.out.trimmed } + guard let range = created.out.range(of: #"surface:\d+"#, options: .regularExpression) else { + return "cmux did not report the new surface: \(created.out.trimmed)" + } + return send(line, workspace: workspace, surface: String(created.out[range])) + } + + private static func send(_ line: String, workspace: String, surface: String) -> String? { + let sent = cmux(["send", "--workspace", workspace, "--surface", surface, line]) + guard sent.ok else { return sent.out.trimmed } + let enter = cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) + return enter.ok ? nil : enter.out.trimmed + } + + /// The command that resumes an agent session in a terminal, or nil for an agent with none. + public static func resumeCommand(provider: String, sessionId: String) -> [String]? { + switch provider { + case "claude": return ["tools", "claude", "resume", sessionId] + case "codex": return ["codex", "resume", sessionId] + case "grok": return ["grok", "--resume", sessionId] + default: return nil + } + } + + /// The `tools ` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know their own + /// history (Grok has no journal and its own fallback); anything else goes through Claude's. + public static func tool(provider: String?) -> String { + switch provider { + case "codex", "grok": return provider ?? "claude" + default: return "claude" + } + } + + public static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + + /// What `tools cmux focus --json` said, in words: nil when it focused. A recorded tab that was closed + /// answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". + public static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Opaque: Decodable { + init(from decoder: Decoder) throws {} + } + struct Result: Decodable { + let gone: Bool? + let matches: [Opaque]? + } + if let result = try? JSONDecoder().decode(Result.self, from: stdout) { + if result.gone == true { + return paneGone + } + if result.matches?.isEmpty == true { + return "No cmux pane shows this session. Choose a pane to resume it in." + } + } + let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) + let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) } + .last { !$0.isEmpty } + return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + } + + /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. + public static func openSessionFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Failure: Decodable { let error: String } + if let failure = try? JSONDecoder().decode(Failure.self, from: stdout) { + return failure.error + } + let text = String(decoding: stderr, as: UTF8.self).trimmed + return text.isEmpty ? "tools claude cmux open-session exited \(status)" : String(text.suffix(300)) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift index 99831d3cba..c7d5783178 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift @@ -106,9 +106,12 @@ public struct AgentRosterGroupLabel: View { public let running: Int public var live: Bool public var lastAt: Date? + /// Inbox items waiting in the session, shown beside its name (the widget's session list). + public var unread: Int public init(title: String, provider: String, project: String? = nil, account: String? = nil, - total: Int, running: Int, live: Bool, lastAt: Date? = nil) { + total: Int, running: Int, live: Bool, lastAt: Date? = nil, unread: Int = 0) { + self.unread = unread self.title = title self.provider = provider self.project = project @@ -129,7 +132,13 @@ public struct AgentRosterGroupLabel: View { } }.padding(.top, 1) VStack(alignment: .leading, spacing: 3) { - Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + if unread > 0 { + Spacer(minLength: 0) + CountBadge(unread, tooltip: "\(Plural.count(unread, "inbox item"))", color: KitPalette.modified) + } + } HStack(spacing: 6) { if let project { Text(project).layoutPriority(-2) } if let account { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift index b641b0c0cb..1a1fde58af 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift @@ -10,6 +10,7 @@ // `git log --no-walk` for all of its commits. // +import AppKit import SwiftUI /// How much a commit changed: every file, and the chooser's own file when it names one. @@ -218,10 +219,19 @@ public extension GitCommitPicker where Label == GitPickerChip { public extension View { /// The commit list as a popover from this view. Its rows are built when it opens, never while it is closed. + /// With `onPickRange`, Shift with the arrows or a Shift-click picks several commits in a row: the callback gets + /// them in list order, every commit between the two ends included, even ones the filter hides. func gitCommitPopover(isPresented: Binding, choices: [GitCommitChoice], selection: String?, title: String? = nil, - details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void) -> some View { + details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void, + onPickRange: (([GitCommitChoice]) -> Void)? = nil) -> some View { popover(isPresented: isPresented, arrowEdge: .bottom) { - GitCommitPickerList(choices: choices, selection: selection, title: title, details: details) { choice in + GitCommitPickerList(choices: choices, selection: selection, title: title, details: details, + pickRange: onPickRange.map { pickRange in + { range in + isPresented.wrappedValue = false + pickRange(range) + } + }) { choice in isPresented.wrappedValue = false onPick(choice) } @@ -246,6 +256,8 @@ struct GitCommitPickerState { private(set) var rows: [GitCommitChoice] = [] /// The row the keyboard or the pointer is on: Return picks it. var highlighted: String? + /// Where a Shift-extended range started; nil while one commit is chosen. + var anchor: String? init(choices: [GitCommitChoice], selection: String?, query: String = "") { self.choices = choices @@ -254,8 +266,35 @@ struct GitCommitPickerState { highlighted = selection.flatMap { sha in rows.first { $0.sha == sha }?.id } ?? rows.first?.id } - /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. + /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. A plain move + /// ends a range. mutating func move(_ step: Int) { + anchor = nil + advance(step) + } + + /// Shift with an arrow: the range grows from where it started. + mutating func extend(_ step: Int) { + if anchor == nil { anchor = highlighted } + advance(step) + } + + /// Shift-click: the range runs from where it started (or the highlighted row) to `id`. + mutating func extend(to id: String) { + if anchor == nil { anchor = highlighted } + highlighted = id + } + + /// The commits of a range, in the list's order and with every commit between its ends (also the ones the filter + /// hides, so the diff never skips one); nil while one commit is chosen. + var range: [GitCommitChoice]? { + guard let anchor, let highlighted, anchor != highlighted, + let start = choices.firstIndex(where: { $0.id == anchor }), + let end = choices.firstIndex(where: { $0.id == highlighted }) else { return nil } + return Array(choices[min(start, end)...max(start, end)]) + } + + private mutating func advance(_ step: Int) { guard !rows.isEmpty else { return } let index = highlighted.flatMap { id in rows.firstIndex { $0.id == id } } ?? (step > 0 ? -1 : rows.count) highlighted = rows[min(max(index + step, 0), rows.count - 1)].id @@ -280,6 +319,8 @@ struct GitCommitPickerList: View { let selection: String? let title: String? let details: GitCommitDetailsLoader? + /// Several commits in a row; nil offers one commit only. + let pickRange: (([GitCommitChoice]) -> Void)? let pick: (GitCommitChoice) -> Void @State private var state: GitCommitPickerState @@ -296,10 +337,11 @@ struct GitCommitPickerList: View { static let page = 8 init(choices: [GitCommitChoice], selection: String?, title: String?, details: GitCommitDetailsLoader?, query: String = "", - pick: @escaping (GitCommitChoice) -> Void) { + pickRange: (([GitCommitChoice]) -> Void)? = nil, pick: @escaping (GitCommitChoice) -> Void) { self.selection = selection self.title = title self.details = details + self.pickRange = pickRange self.pick = pick _state = State(initialValue: GitCommitPickerState(choices: choices, selection: selection, query: query)) } @@ -344,11 +386,9 @@ struct GitCommitPickerList: View { .textFieldStyle(.plain) .font(.system(size: 12.5)) .focused($searchFocused) - .onSubmit { - if let choice = state.highlightedChoice { pick(choice) } - } - .onKeyPress(.upArrow) { move(-1) } - .onKeyPress(.downArrow) { move(1) } + .onSubmit { submit() } + .onKeyPress(.upArrow, phases: [.down, .repeat]) { press in move(-1, extending: press.modifiers.contains(.shift)) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { press in move(1, extending: press.modifiers.contains(.shift)) } .onKeyPress(.pageUp) { move(-Self.page) } .onKeyPress(.pageDown) { move(Self.page) } .accessibilityLabel("Filter commits") @@ -374,13 +414,17 @@ struct GitCommitPickerList: View { ScrollViewReader { proxy in FittedScroll(maxHeight: Self.maxListHeight) { LazyVStack(spacing: 1) { + let inRange = Set(state.range?.map(\.id) ?? []) ForEach(state.rows) { choice in - GitCommitPickerRow(choice: choice, current: choice.sha == selection, highlighted: choice.id == state.highlighted) { - pick(choice) + GitCommitPickerRow(choice: choice, current: choice.sha == selection, + highlighted: choice.id == state.highlighted || inRange.contains(choice.id)) { + click(choice) } .id(choice.id) .onHover { inside in - guard inside, state.highlighted != choice.id, Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } + // A range keeps its ends while the pointer crosses other rows. + guard inside, state.anchor == nil, state.highlighted != choice.id, + Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } state.highlighted = choice.id } } @@ -413,7 +457,7 @@ struct GitCommitPickerList: View { .accessibilityLabel("Reading the line counts") } Spacer(minLength: 8) - Text("↑↓ move · ↩ show · esc close") + Text(verbatim: keysHint) .accessibilityHidden(true) } .font(.system(size: 10.5)) @@ -422,8 +466,40 @@ struct GitCommitPickerList: View { .padding(.vertical, 6) } - private func move(_ step: Int) -> KeyPress.Result { - state.move(step) + /// The keys, and with a range how many commits Return shows. + private var keysHint: String { + if let range = state.range { + return "\(Plural.count(range.count, "commit")) chosen · ↩ show them · ↑↓ back to one" + } + return pickRange == nil ? "↑↓ move · ↩ show · esc close" + : "↑↓ move · ⇧↑↓ or ⇧-click more · ↩ show · esc close" + } + + private func submit() { + if pickRange != nil, let range = state.range { + pickRange?(range) + } else if let choice = state.highlightedChoice { + pick(choice) + } + } + + /// A click shows that one commit; with Shift (when ranges are offered) it extends the range instead. + private func click(_ choice: GitCommitChoice) { + if pickRange != nil, NSEvent.modifierFlags.contains(.shift) { + state.extend(to: choice.id) + keyMovedAt = Date() + return + } + + pick(choice) + } + + private func move(_ step: Int, extending: Bool = false) -> KeyPress.Result { + if extending, pickRange != nil { + state.extend(step) + } else { + state.move(step) + } scrollTarget = state.highlighted keyMovedAt = Date() return .handled diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index 065b17b104..b9d525958d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -56,6 +56,56 @@ public enum NativeSettingsCatalogError: Error, Equatable, CustomStringConvertibl } } +/// A settings link can name a card on its page: `widgets.sessions#inbox-preview` selects the page and pulses the card +/// that carries `.nativeSettingsAnchor("inbox-preview")`. One settings window per process, so one shared center. +@MainActor +public final class NativeSettingsHighlightCenter: ObservableObject { + public static let shared = NativeSettingsHighlightCenter() + @Published public private(set) var anchor: String? + /// Bumped on every request, so the same card asked for twice pulses twice. + @Published public private(set) var token = 0 + + public func request(_ anchor: String) { + self.anchor = anchor + token += 1 + } + + /// "page#anchor" into its page id and the anchor; a plain page id has no anchor. + public nonisolated static func split(_ requested: String) -> (page: String, anchor: String?) { + guard let mark = requested.firstIndex(of: "#") else { return (requested, nil) } + let anchor = String(requested[requested.index(after: mark)...]) + return (String(requested[.. some View { + content + .attentionPulse(trigger: pulses, style: .flash(color: .accentColor, cornerRadius: 12)) + // A window that opens on the page asks before the card exists: the card pulses once it is on screen. + .onAppear { if center.anchor == id { pulseSoon() } } + .onChange(of: center.token) { _, _ in if center.anchor == id { pulseSoon() } } + } + + private func pulseSoon() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(350)) + pulses += 1 + } + } +} + +extension View { + /// Names this card for a settings link (`#`): following the link pulses it. + public func nativeSettingsAnchor(_ id: String) -> some View { + modifier(NativeSettingsAnchor(id: id)) + } +} + @MainActor public final class NativeSettingsStore: ObservableObject { @Published public private(set) var sections: [NativeSettingsSection] @@ -78,25 +128,30 @@ public final class NativeSettingsStore: ObservableObject { catalogError = error let acceptedSections = error == nil ? sections.sorted { $0.order < $1.order } : [] self.sections = acceptedSections - let requested = initialPageID ?? defaults.string(forKey: selectionKey) + let link = initialPageID.map(NativeSettingsHighlightCenter.split) + let requested = link?.page ?? defaults.string(forKey: selectionKey) let pages = acceptedSections.flatMap(\.pages) selectedPageID = pages.first(where: { $0.id == requested })?.id ?? pages.first?.id pendingPageID = requested != selectedPageID ? requested : nil + if let anchor = link?.anchor { NativeSettingsHighlightCenter.shared.request(anchor) } } public var selectedPage: NativeSettingsPage? { sections.lazy.flatMap(\.pages).first { $0.id == selectedPageID } } + /// `pageID` may name a card too (`#`): the page opens and the card pulses. @discardableResult - public func select(pageID: String) -> Bool { + public func select(pageID requested: String) -> Bool { + let (pageID, anchor) = NativeSettingsHighlightCenter.split(requested) guard sections.contains(where: { $0.pages.contains(where: { $0.id == pageID }) }) else { - pendingPageID = pageID + pendingPageID = requested return false } pendingPageID = nil selectedPageID = pageID defaults.set(pageID, forKey: selectionKey) + if let anchor { NativeSettingsHighlightCenter.shared.request(anchor) } return true } @@ -115,7 +170,9 @@ public final class NativeSettingsStore: ObservableObject { catalogError = nil candidate.sort { $0.order < $1.order } sections = candidate - if let pendingPageID, candidate.contains(where: { $0.pages.contains(where: { $0.id == pendingPageID }) }) { + if let pendingPageID, candidate.contains(where: { + $0.pages.contains(where: { $0.id == NativeSettingsHighlightCenter.split(pendingPageID).page }) + }) { return select(pageID: pendingPageID) } if selectedPage == nil { selectedPageID = candidate.lazy.flatMap(\.pages).first?.id } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift index 84dfe48e62..1de5fc56d8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift @@ -405,6 +405,8 @@ public struct AgentWidgetView: View { action: actions.submit) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 12)) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { composerFocused = true } HStack(spacing: 6) { if sending { ProgressView().controlSize(.mini) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index c42b03d0ba..a1ffbfbf85 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -25,6 +25,8 @@ public struct LiveWidgetView: View { } @State private var retry: WidgetOutgoing? + /// The session inbox's filter: every entry, the inbox entries (answers, results, questions) or the decisions. + @State private var inboxFilter = "all" @State private var choosingSession = false @State private var sourcesExpanded = false @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @@ -147,14 +149,26 @@ public struct LiveWidgetView: View { .textSelection(.enabled) }.font(.caption).foregroundStyle(.orange) } - GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) - .accessibilityIdentifier("widget.sections") + if !model.inboxOverview { + GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) + .accessibilityIdentifier("widget.sections") + } if model.snapshot == nil || (model.selected == nil && model.snapshot?.rosterLoading == true) { VStack(spacing: 12) { ProgressView() Text("Connecting to your local agents…").font(.callout).foregroundStyle(.secondary) if model.error != nil || model.connectionLost { Button("Reconnect", action: model.start) } }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.inboxOverview { + ScrollView { + WidgetInboxOverview(model: model) + .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2) + .scrollOverflowContent() + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.document = $0; reportFit() } + } + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.viewport = $0; reportFit() } + .onDisappear { fit.viewport = nil; fit.document = nil; reportFit() } + .scrollOverflowHints() } else if model.selected == nil { if model.inbox.sessions.isEmpty { VStack(spacing: 10) { @@ -190,6 +204,9 @@ public struct LiveWidgetView: View { .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.document = $0; reportFit() } } .modifier(LatestScrollAnchor(enabled: section == "Conversation", identity: model.selectedKey)) + // A new view (section or session) starts at its own top: the Sessions list's deep offset, + // kept for the much shorter inbox, drew its rows over the header (Martin, 2026-10-23). + .id(section + "|" + model.selectedKey) .onChange(of: WidgetOutgoingPosition(session: model.selectedKey, messageID: model.outgoing.last?.id)) { previous, next in guard section == "Inbox", previous.session == next.session, let id = next.messageID else { return } ScrollViewPositioning.scroll(reader, to: "outgoing-" + id, anchor: .bottom) @@ -200,7 +217,9 @@ public struct LiveWidgetView: View { .scrollOverflowHints() } // While a decision card holds the composer for an own answer, the bottom one would edit the same draft. - if section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { composer } + if !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { + composer + } } } .mediaPreviewHost() @@ -232,7 +251,7 @@ public struct LiveWidgetView: View { fit.scheduled = false guard let outer = fit.outer else { return } // The same condition that chooses the list over the connecting / nothing-selected placeholders. - if model.snapshot != nil, model.selected != nil { + if model.snapshot != nil, model.selected != nil || model.inboxOverview { // The list is shown but not measured yet: wait instead of reporting the placeholder for a moment. guard let viewport = fit.viewport, let document = fit.document else { return } model.reportAgentFit(outer - viewport + document) @@ -248,12 +267,11 @@ public struct LiveWidgetView: View { choosingSession = true } label: { VStack(alignment: .leading, spacing: 3) { - Text(model.selected?.title ?? "Agent inbox").font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - Text( - [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } - .joined(separator: " · ") - ) + Text(model.inboxOverview ? "Inbox" : model.selected?.title ?? "Agent inbox") + .font(.system(size: 13, weight: .semibold)).lineLimit(1) + Text(model.inboxOverview ? "Every session" + : [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } + .joined(separator: " · ")) .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) } .padding(.horizontal, 6).padding(.vertical, 3) @@ -273,12 +291,29 @@ public struct LiveWidgetView: View { .onChange(of: choosingSession) { _, value in model.sessionChooserOpen = value } .onDisappear { model.sessionChooserOpen = false } Spacer(minLength: 0) - IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) - .accessibilityLabel("Unpin selected session") - IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { - model.openHub?(model.selected) + if model.inboxOverview { + if model.selected != nil { + IconButton(systemName: "chevron.left", tooltip: "Back to " + (model.selected?.title ?? "the session")) { + model.inboxOverview = false + }.accessibilityLabel("Back to the session") + } + } else { + IconButton(systemName: "tray.full", tooltip: "Inbox entries from every session") { + model.inboxOverview = true + }.accessibilityLabel("Inbox from every session") + if let selected = model.selected { + WidgetCmuxButton(model: model, session: selected, size: 12) + } + } + // Both act on one session, which the overview of every session does not show. + if !model.inboxOverview { + IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) + .accessibilityLabel("Unpin selected session") + IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { + model.openHub?(model.selected) + } + .accessibilityLabel("Open session in Hub") } - .accessibilityLabel("Open session in Hub") IconButton(systemName: "slider.horizontal.3", tooltip: "Widget settings and filters") { model.showSettings?() } @@ -356,11 +391,20 @@ public struct LiveWidgetView: View { @ViewBuilder private var inbox: some View { if !model.cards.isEmpty { HStack(spacing: 8) { - Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + GenSegmentedTabs( + "Inbox filter", + items: [.init("all", "All"), .init("inbox", "Inbox entries"), .init("decisions", "Decisions")], + selection: $inboxFilter, minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton } - ForEach(model.cards.reversed()) { card in + Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + let shown = Self.filtered(model.cards, by: inboxFilter) + if shown.isEmpty { + Text(inboxFilter == "decisions" ? "No decisions in this session." : "No inbox entries in this session.") + .font(.callout).foregroundStyle(.secondary).padding(.vertical, 6) + } + ForEach(shown.reversed()) { card in inboxRow(card).id("card-" + card.id) } } else if model.inboxLoading { @@ -560,6 +604,15 @@ public struct LiveWidgetView: View { } } + /// "inbox": answers, results and questions; "decisions": decisions and tasks; anything else: every card. + static func filtered(_ cards: [WidgetCard], by filter: String) -> [WidgetCard] { + switch filter { + case "inbox": return cards.filter { !["decision", "todo"].contains($0.kind) } + case "decisions": return cards.filter { ["decision", "todo"].contains($0.kind) } + default: return cards + } + } + static func kindSymbol(_ kind: String) -> String { switch kind { case "answer": return "text.bubble" @@ -914,6 +967,10 @@ public struct LiveWidgetView: View { }.font(.system(size: 12)).foregroundStyle(.secondary).buttonStyle(.genHoverIcon()) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 13)) + // The whole box is the field's target: a click beside the one-line field (or under it, in the padding) starts + // typing. The buttons inside keep their own clicks, because a child's gesture wins over this one. + .contentShape(RoundedRectangle(cornerRadius: 13)) + .onTapGesture { editing = true } } private func attachments(_ ids: [String], tile: CGSize, removable: Bool) -> some View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index fc245684e9..8a219b9752 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -63,7 +63,6 @@ struct WidgetSessionActivity: View { struct AgentWidgetPreview: View { @ObservedObject var model: WidgetModel let surface: WidgetSurfaceID - private var waiting: Int { model.waitingSessionCount } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -73,22 +72,22 @@ struct AgentWidgetPreview: View { Text("Inbox").font(.system(size: 12, weight: .semibold)) Spacer() if model.inbox.needsAnswer > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: true) } label: { + Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.needsAnswer, needsAnswer: true, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("need you").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open need you inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every inbox entry that waits for your answer") } if model.inbox.unread > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: false) } label: { + Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.unread, needsAnswer: false, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("unread").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open unread inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox entry, from all sessions") } if model.inboxCount == 0 { Text(model.inbox.complete ? "All caught up" : "Inbox status unavailable") @@ -98,75 +97,58 @@ struct AgentWidgetPreview: View { size: 11, action: model.markAllRead) .accessibilityLabel("Mark all inbox items read") } - }.padding(.horizontal, 5).padding(.bottom, 5) + }.padding(.horizontal, 5) - if model.previewSessions.isEmpty { + HStack(spacing: 6) { + GenSegmentedTabs( + "Show", items: WidgetPreviewChoices.filters.map { .init($0.value, $0.title) }, + selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), + minSegmentWidth: 70, identifier: "widget.preview.filter") + Spacer(minLength: 4) + IconButton(systemName: "arrow.up.arrow.down", tooltip: sortTooltip, size: 10.5) { + let sorts = WidgetPreviewChoices.sorts.map(\.value) + let index = sorts.firstIndex(of: model.previewSort) ?? 0 + model.setPreview(sort: sorts[(index + 1) % sorts.count]) + }.accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort)) + IconButton(systemName: "slider.horizontal.3", tooltip: "Choose what this list shows and its order", + size: 10.5) { + model.showSettingsPage?("widgets.sessions#inbox-preview") + }.accessibilityLabel("Inbox preview settings") + }.padding(.horizontal, 5).padding(.bottom, 3) + + if model.previewList.isEmpty { HStack(spacing: 12) { GenesisWidgetMark() VStack(alignment: .leading, spacing: 4) { - Text("Nothing needs you.").font(.system(size: 13, weight: .medium)) + Text(model.previewFilter == "active" ? "Nothing is running." : "Nothing needs you.") + .font(.system(size: 13, weight: .medium)) Text("New questions and finished work appear here.") .font(.system(size: 11)).foregroundStyle(.secondary) } - }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading).padding(.horizontal, 5) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) } else { - ForEach(model.previewSessions) { session in - Button { - model.openInboxNotification(on: surface, key: session.key) - } label: { - HStack(spacing: 9) { - AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) - VStack(alignment: .leading, spacing: 4) { - Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) - HStack(spacing: 5) { - Text( - session.project.isEmpty ? session.target.provider.capitalized : session.project - ) - .lineLimit(1) - Text("·") - Text(session.visualStatus.label).lineLimit(1) - }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) - } - Spacer(minLength: 2) - if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { - WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, - pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, - complete: model.inbox.complete) - } else { - WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) - .frame(width: 12, height: 12) - } - } - .padding(.horizontal, 9).padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), - in: RoundedRectangle(cornerRadius: 10)) + FittedScroll(maxHeight: 360) { + LazyVStack(spacing: 6) { + ForEach(model.previewList) { session in row(session) } } - .buttonStyle(.genHoverRow()) - .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) } } - Spacer(minLength: 0) Button { model.section = "Sessions" + model.inboxOverview = false model.openModule("agents", on: surface) } label: { HStack { Image(systemName: "rectangle.stack").font(.system(size: 10)) Text("Projects & sessions").font(.system(size: 10, weight: .medium)) Spacer() - if model.activeSessionCount > 0 { - Text("\(model.activeSessionCount) active").font(.system(size: 9.5)).monospacedDigit() - } + Text(Plural.count(model.previewList.count, "session")).font(.system(size: 9.5)).monospacedDigit() Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) } .foregroundStyle(.white.opacity(0.5)).padding(.horizontal, 5).padding(.vertical, 5) .contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()) - .instantTooltip(model.activeSessionCount > 0 - ? "\(model.activeSessionCount) sessions are working or wait for you. Browse every project and session." - : "Browse every project and session") + .instantTooltip("Browse every project and session") .accessibilityLabel("Browse all projects and sessions") } .padding(.horizontal, 13).padding(.vertical, 14) @@ -176,4 +158,51 @@ struct AgentWidgetPreview: View { .onGeometryChange(for: CGFloat.self, of: \.size.height) { model.reportPreviewFit($0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + private var sortTooltip: String { + let sorts = WidgetPreviewChoices.sorts + let index = sorts.firstIndex { $0.value == model.previewSort } ?? 0 + return "Order: \(sorts[index].title). Click for \(sorts[(index + 1) % sorts.count].title)." + } + + private func row(_ session: WidgetSession) -> some View { + WidgetHoverRow { hovering in HStack(spacing: 4) { + Button { + model.openInboxNotification(on: surface, key: session.key) + } label: { + HStack(spacing: 9) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) + VStack(alignment: .leading, spacing: 4) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + HStack(spacing: 5) { + Text(session.project.isEmpty ? session.target.provider.capitalized : session.project) + .lineLimit(1) + Text("·") + Text(session.visualStatus.label).lineLimit(1) + }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) + } + Spacer(minLength: 2) + if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { + WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, + pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, + complete: model.inbox.complete) + } else { + WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) + .frame(width: 12, height: 12) + } + } + .padding(.horizontal, 9).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) + WidgetCmuxButton(model: model, session: session, live: hovering) + } } + .padding(.trailing, 4) + .background( + model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" + ? Color.orange.opacity(0.065) : .white.opacity(0.025), + in: RoundedRectangle(cornerRadius: 10)) + } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift new file mode 100644 index 0000000000..2828eb0995 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// The cmux button every widget session carries: a popover with the shared `CmuxSessionPanel` (Focus, open in a pane, +/// choose a pane), the same block the hub's session sidebar shows. The widget drives it through its own `tools` +/// bridge; the pane moves go through GenesisKit's `CmuxCommandRunner`, which the hub's `CmuxHost` uses too. +struct WidgetCmuxButton: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + var size: CGFloat = 11 + /// In a list, false on every row but the hovered or selected one: the row then carries no button, tooltip or + /// hover sensor, because an accessibility client walks every control of every row per update (swift skill, + /// "Dense lists and accessibility clients"). The popover stays attached, so an open one survives the pointer leaving. + var live = true + @State private var open = false + + var body: some View { + Group { + if live || open { + IconButton(systemName: "terminal", tooltip: "cmux: focus this session, or resume it in a pane", size: size) { + open = true + } + .accessibilityLabel("cmux for " + session.title) + .accessibilityIdentifier("widget.cmux." + session.key) + } else { + Color.clear.frame(width: size + 13, height: size + 13).accessibilityHidden(true) + } + } + .popover(isPresented: $open, arrowEdge: .trailing) { + WidgetCmuxPanel(model: model, session: session).padding(14).frame(width: 380) + } + // The popover takes the pointer out of the edge panel; while it is open the panel must not collapse. Its own + // flag, as the session chooser has one: closing it must never release a window the host opened. + .onChange(of: open) { _, value in model.cmuxPopoverOpen = value } + .onDisappear { if open { model.cmuxPopoverOpen = false } } + } +} + +/// A list row that knows whether the pointer is on it, for `WidgetCmuxButton(live:)` and its other row-only controls. +struct WidgetHoverRow: View { + @ViewBuilder let content: (Bool) -> Content + @State private var hovering = false + + var body: some View { + content(hovering).onHover { hovering = $0 } + } +} + +struct WidgetCmuxPanel: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + @State private var tree: CmuxTree? + @State private var loading = false + @State private var busy = false + @State private var choosing = false + @State private var notice: CmuxSessionPanel.Notice? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + Text([AIProviders.meta(for: session.target.provider).displayName, session.project, + String(session.target.sessionId.prefix(8))].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10.5)).foregroundStyle(.secondary).lineLimit(1).textSelection(.enabled) + } + CmuxSessionPanel( + sessionId: session.target.sessionId, + window: nil, workspace: nil, pane: nil, surface: nil, + tree: tree, loading: loading, busy: busy, lastPane: nil, + choosing: $choosing, notice: $notice, + modeKey: "widget.cmux.pickerMode", + focus: { Task { await perform(focus) } }, + open: { target in Task { await perform { await open(at: target) } } }, + reload: load) + } + .task { load() } + } + + private func load() { + guard !loading else { return } + loading = true + Task { + // Not the exit status: with cmux unreachable the command prints {available: false, error} and exits 1. + let result = try? await model.bridge.run(subcommand: "ai", args: ["cmux", "tree", "--json"], timeoutSeconds: 20) + tree = result.flatMap { try? CmuxTree.decode(Data($0.stdout.utf8)) } + loading = false + } + } + + private func focus() async -> String? { + let tool = CmuxCommandRunner.tool(provider: session.target.provider) + do { + let result = try await model.bridge.run( + subcommand: tool, args: ["cmux", "focus", session.target.sessionId, "--first", "--json"], timeoutSeconds: 30) + return CmuxCommandRunner.focusFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + + /// Claude resumes through `tools claude cmux open-session`; another agent's resume command is typed into the pane. + private func open(at target: CmuxTarget) async -> String? { + let sessionId = session.target.sessionId + if session.target.provider == "claude", + let args = target.openSessionArgs ?? tree?.windows.first.map({ ["--window", $0.target] }) { + do { + let result = try await model.bridge.run( + subcommand: "claude", args: ["cmux", "open-session", sessionId] + args + ["--json"], timeoutSeconds: 60) + return CmuxCommandRunner.openSessionFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + guard let command = CmuxCommandRunner.resumeCommand(provider: session.target.provider, sessionId: sessionId) else { + return "No resume command for \(session.target.provider)" + } + let cwd = session.target.cwd + let name = session.title + return await Task.detached(priority: .userInitiated) { + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) + }.value + } + + private func perform(_ action: @escaping () async -> String?) async { + busy = true + let error = await action() + busy = false + if error == CmuxCommandRunner.paneGone { + // The tab it ran in is closed: say so in words and show where it can go instead. + notice = CmuxSessionPanel.Notice(text: error ?? "", isError: true) + choosing = true + } else { + notice = CmuxSessionPanel.Notice(text: error.map { "cmux: \($0)" } ?? "Done", isError: error != nil) + } + load() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index da49b5fb3f..31bd235a76 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -166,6 +166,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { motion.pointerInside = inside } model.showSettings = { [weak self] in self?.showSettings() } + model.showSettingsPage = { [weak self] in self?.showSettings(pageID: $0) } model.showMedia = { [weak self] selection in self?.showMedia(selection) } model.inboxArrived = { [weak self] items in self?.alert(items) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index ff311cf3dc..7e7eb00f7f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -303,6 +303,7 @@ private struct WidgetSessionSettings: View { var body: some View { VStack(alignment: .leading, spacing: 18) { + previewCard NativeSettingsCard("Your sessions", subtitle: "Pin a session to keep it in the widget's rail. Click one to open it in the widget.") { WidgetSessionBrowser(model: model, onOpen: openSession) } @@ -311,6 +312,22 @@ private struct WidgetSessionSettings: View { }.task { model.refreshSettings() } } + /// The agents hover list. Its settings button opens this page at `#inbox-preview`, and the card pulses. + private var previewCard: some View { + NativeSettingsCard("Inbox preview", subtitle: "The list that opens when the pointer rests on the agents button.") { + NativeSettingsSegmented( + "Show", identifier: "widget.preview.filter", + options: WidgetPreviewChoices.filters, + selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), + minSegmentWidth: 96) + NativeSettingsPicker( + "Order", identifier: "widget.preview.sort", + options: WidgetPreviewChoices.sorts, + selection: Binding(get: { model.previewSort }, set: { model.setPreview(sort: $0) })) + } + .nativeSettingsAnchor("inbox-preview") + } + private var filterCard: some View { let selected = prefs?.sessions ?? [] let projects = prefs?.projects ?? [] @@ -721,3 +738,18 @@ private struct WidgetSettingsError: View { } } } + +/// The hover list's filter and order choices, shared by the settings card and the list's own controls. +enum WidgetPreviewChoices { + static let filters: [NativeSettingsOption] = [ + .init("needs-you", "Needs you", detail: "Sessions with a question or decision waiting for your answer."), + .init("active", "Active too", detail: "Also the sessions that work now or hold unread answers."), + ] + static let sorts: [NativeSettingsOption] = [ + .init("attention", "Waiting first", detail: "Sessions that wait for you first, then working ones, newest first."), + .init("recent", "Newest activity", detail: "The session that did something last comes first."), + .init("project", "By project", detail: "Grouped by project name, newest first inside a project."), + ] + + static func title(sort: String) -> String { sorts.first { $0.value == sort }?.title ?? sort } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 04a118c664..d25584c6f9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -132,7 +132,7 @@ struct WidgetHostView: View { } }) { Button { - if selected?.id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if selected?.id == "agents" { model.toggleInboxOverview(on: surface) } else { expand() } } label: { HStack(spacing: 7) { @@ -162,7 +162,7 @@ struct WidgetHostView: View { HStack(spacing: 5) { ForEach(Array(model.railActivitySessions.prefix(3))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(minWidth: 24, minHeight: 30).fixedSize() @@ -270,7 +270,7 @@ struct WidgetHostView: View { VStack(spacing: WidgetSideStripMetrics.sessionSpacing) { ForEach(Array(model.railActivitySessions.prefix(sideMetrics.sessionCount))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) @@ -368,7 +368,9 @@ struct WidgetHostView: View { } } .frame(width: selected.previewSize.width) - .frame(maxHeight: .infinity) + // Pinned to the top: while the window still has the last measured height, an overflow hides the footer, never + // the header (the hover list opened on its middle rows, 2026-10-23). + .frame(maxHeight: .infinity, alignment: .top) } /// Inbox counts describe only the Agents module; every other module button names its module. @@ -378,7 +380,7 @@ struct WidgetHostView: View { private func moduleButton(_ id: String, size: CGFloat, location: String = "content") -> some View { Button { - if id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if id == "agents" { model.toggleInboxOverview(on: surface) } else { model.openModule(id, on: surface) } } label: { let layout = surface.edge == .top ? AnyLayout(HStackLayout(spacing: 3)) : AnyLayout(VStackLayout(spacing: 4)) @@ -422,7 +424,7 @@ struct WidgetHostView: View { if inbox.needsAnswer > 0 { lines.append("\(inbox.needsAnswer) waiting for your answer") } if inbox.unread > 0 { lines.append("\(inbox.unread) unread") } } - lines.append("Click to open its inbox") + lines.append("Click to open its inbox, again to hide it") return TooltipContent(title: session.title, bullets: lines) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift new file mode 100644 index 0000000000..8e30b237d7 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// The inbox button's view: inbox entries from every session in one list, the waiting ones first. A row opens in place +/// to show its session and the way to that session's whole inbox (with the entry open) and its cmux pane. +struct WidgetInboxOverview: View { + @ObservedObject var model: WidgetModel + @State private var filter = "all" + @State private var openID: String? + @GenReduceMotion private var reduceMotion + + private var items: [WidgetInboxItem] { + let all = model.inboxOverviewItems + switch filter { + case "needs": return all.filter(\.needsAnswer) + case "unread": return all.filter { !$0.needsAnswer } + default: return all + } + } + + var body: some View { + let items = items + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + GenSegmentedTabs( + "Inbox entries", + items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], + selection: $filter, minSegmentWidth: 70, identifier: "widget.overview.filter") + Spacer(minLength: 4) + if model.inboxCount > 0 { + Button("Mark all read", action: model.markAllRead) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .instantTooltip("Answers become read; older questions and decisions stop counting. Nothing is deleted.") + } + } + if items.isEmpty { + VStack(spacing: 10) { + GenesisWidgetMark() + Text(filter == "needs" ? "Nothing waits for your answer." : "Nothing new in the inbox.") + .font(.headline) + Text("Questions, screenshots and finished work from every session land here.") + .font(.caption).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 28) + } else { + Text(Self.summary(items)).font(.system(size: 11)).foregroundStyle(.secondary) + // Hundreds of entries: lazy, so only the rows on screen exist (swift skill, dense lists). + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(items, id: \.id) { item in row(item) } + } + if model.inbox.truncated { + Text("The newest entries are shown. Open a session for its whole inbox.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + } + + private static func summary(_ items: [WidgetInboxItem]) -> String { + let waiting = items.filter(\.needsAnswer).count + let sessions = Set(items.map(\.key)).count + return [Plural.count(items.count, "entry", "entries"), waiting > 0 ? "\(waiting) need your answer" : nil, + "from " + Plural.count(sessions, "session")].compactMap { $0 }.joined(separator: " · ") + } + + private func toggle(_ item: WidgetInboxItem) { + let opening = openID != item.id + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { openID = opening ? item.id : nil } + } + + private func row(_ item: WidgetInboxItem) -> some View { + let open = openID == item.id + let session = model.session(forKey: item.key) + return VStack(alignment: .leading, spacing: 0) { + Button { toggle(item) } label: { + HStack(alignment: .top, spacing: 9) { + Circle().fill(item.needsAnswer ? Color.orange : SessionPalette.blue) + .frame(width: 7, height: 7).padding(.top, 5) + Image(systemName: LiveWidgetView.kindSymbol(item.kind)).font(.system(size: 11)) + .foregroundStyle(.secondary).frame(width: 14).padding(.top, 1) + VStack(alignment: .leading, spacing: 3) { + Text(Self.title(item)).font(.system(size: 12.5, weight: open ? .semibold : .medium)) + .lineLimit(open ? 6 : 2).fixedSize(horizontal: false, vertical: true) + HStack(spacing: 4) { + Text(Self.kindLabel(item)) + Text("·") + Text(session?.title ?? "Unknown session").lineLimit(1) + Text("·") + LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) + }.font(.system(size: 10)).foregroundStyle(item.needsAnswer ? Color.orange : .secondary) + } + Spacer(minLength: 4) + if let count = item.count, count > 1 { + Text(verbatim: "×\(count)").font(.system(size: 10, weight: .semibold)).monospacedDigit() + .foregroundStyle(.secondary).padding(.top, 3) + .instantTooltip("\(count) entries like this one in this session") + } + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary).rotationEffect(.degrees(open ? 90 : 0)).padding(.top, 4) + } + .padding(.horizontal, 10).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 11)) + .accessibilityLabel(Self.kindLabel(item) + ": " + Self.title(item)) + .accessibilityValue(open ? "Expanded" : "Collapsed") + .accessibilityIdentifier("widget.overview.row." + item.id) + if open { + detail(item, session: session).padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 12) + .transition(.genReveal) + } + } + .clipShape(RoundedRectangle(cornerRadius: 11)) + .background(Color.white.opacity(open ? 0.055 : 0.03), in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11) + .strokeBorder(item.needsAnswer ? Color.orange.opacity(0.28) : .clear, lineWidth: 1)) + } + + private func detail(_ item: WidgetInboxItem, session: WidgetSession?) -> some View { + VStack(alignment: .leading, spacing: 10) { + if let session { + HStack(spacing: 8) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 18) + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + Text([session.project, session.visualStatus.label, String(session.target.sessionId.prefix(8))] + .filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + } + } + HStack(spacing: 8) { + Button(item.needsAnswer ? "Answer in its session" : "Open in its session") { model.openInboxItem(item) } + .buttonStyle(.genHover(accent: item.needsAnswer ? .orange : KitTheme.accent)) + .accessibilityIdentifier("widget.overview.open." + item.id) + if let session { + Button("Whole inbox") { + model.select(session.key) + model.section = "Inbox" + }.buttonStyle(.genHover()) + Spacer(minLength: 0) + WidgetCmuxButton(model: model, session: session, size: 12) + } + }.font(.caption) + } + } + + static func title(_ item: WidgetInboxItem) -> String { + if let title = item.title, !title.isEmpty { + return item.number.map { "DECISION \($0): " + title } ?? title + } + return kindLabel(item) + } + + static func kindLabel(_ item: WidgetInboxItem) -> String { + if item.needsAnswer { return item.kind == "decision" ? "Decision · needs your answer" : "Needs your answer" } + switch item.kind { + case "answer": return "New answer" + case "result": return "Agent result" + default: return item.kind.capitalized + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 5613ee104b..fa448a2499 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -30,6 +30,9 @@ public final class WidgetModel: ObservableObject { /// Rail sessions that are working or hold unread items. Stored, because every layout pass of every edge panel reads /// it: filtering ~1000 sessions there cost a quarter of the main thread while panels animated. public private(set) var railActivitySessions: [WidgetSession] = [] + /// The agents hover list (`previewRows`): every session that needs you, with the "active" filter also the + /// sessions that work now. Stored with the inbox, because every hover layout reads it. + public private(set) var previewList: [WidgetSession] = [] @Published public private(set) var inbox = WidgetInboxSummary.empty @Published public private(set) var inboxPulse = 0 private var inboxSessions: [String: WidgetInboxSession] = [:] @@ -40,6 +43,9 @@ public final class WidgetModel: ObservableObject { private var openingInbox: WidgetInboxItem? private var inboxReadRequests: Set = [] @Published public var selectedKey = "" + /// The expanded agents pane shows the inbox entries of every session instead of one session (the inbox button). + /// Opening a session ends it. + @Published public var inboxOverview = false { didSet { if inboxOverview != oldValue { presentationChanged?() } } } @Published public var selectedCardID: String? { didSet { presentationChanged?() } } @Published public var section = "Inbox" { didSet { @@ -109,9 +115,11 @@ public final class WidgetModel: ObservableObject { @Published private var hostDialogOpen = false /// Set by the session chooser alone, so closing it (or its view going away) cannot release a window still open. @Published public var sessionChooserOpen = false + /// Set by a session's cmux popover alone, for the same reason. + @Published public var cmuxPopoverOpen = false /// Hover previews and the outside-click collapse stay off while a window, picker or the chooser is open. public var dialogOpen: Bool { - get { hostDialogOpen || sessionChooserOpen } + get { hostDialogOpen || sessionChooserOpen || cmuxPopoverOpen } set { hostDialogOpen = newValue } } private var mediaPicker: NSOpenPanel? @@ -119,6 +127,8 @@ public final class WidgetModel: ObservableObject { /// The pointer entered or left an edge panel, before any hover delay. public var pointerChanged: ((WidgetSurfaceID, Bool) -> Void)? public var showSettings: (() -> Void)? + /// Opens Settings at a page, or at a card on it (`widgets.sessions#inbox-preview`), which then pulses. + public var showSettingsPage: ((String) -> Void)? public var showMedia: ((WidgetMediaSelection) -> Void)? public var openHub: ((WidgetSession?) -> Void)? public var recordVoiceNote: ((String) -> Void)? @@ -776,6 +786,53 @@ public final class WidgetModel: ObservableObject { let inbox = inboxSessions[session.key] return session.visualStatus == .working || (inbox?.unread ?? 0) + (inbox?.needsAnswer ?? 0) > 0 } + inboxOverviewItems = Self.overviewItems(value.sessions) + previewList = Self.previewRows( + sessions: snapshot?.sessions ?? [], inbox: inboxSessions, + filter: previewFilter, sort: previewSort) + } + + public var previewFilter: String { snapshot?.state.preferences.previewFilter ?? "needs-you" } + public var previewSort: String { snapshot?.state.preferences.previewSort ?? "attention" } + + /// The hover list. "Needs you": a session with an unanswered item, or a shown session that waits. With the + /// "active" filter, shown top-level sessions that work or hold unread items join it. Orders: "attention" (waiting + /// for you, then working, then newest), "recent" (newest activity) and "project" (by project, then newest). + nonisolated static func previewRows( + sessions: [WidgetSession], inbox: [String: WidgetInboxSession], filter: String, sort: String + ) -> [WidgetSession] { + func needs(_ session: WidgetSession) -> Bool { + (inbox[session.key]?.needsAnswer ?? 0) > 0 || (session.visible && session.status == "waiting") + } + let rows = sessions.filter { session in + if needs(session) { return true } + guard filter == "active", session.visible, session.parentKey == nil, session.agentId == nil else { return false } + return session.status == "working" || (inbox[session.key]?.unread ?? 0) > 0 + } + func rank(_ session: WidgetSession) -> Int { + needs(session) ? 0 : session.status == "working" ? 1 : 2 + } + return rows.sorted { lhs, rhs in + switch sort { + case "recent": + return lhs.activityAt > rhs.activityAt + case "project": + let order = lhs.project.localizedCaseInsensitiveCompare(rhs.project) + return order == .orderedSame ? lhs.activityAt > rhs.activityAt : order == .orderedAscending + default: + let (left, right) = (rank(lhs), rank(rhs)) + return left == right ? lhs.activityAt > rhs.activityAt : left < right + } + } + } + + /// Stores the hover list's filter or order. + public func setPreview(filter: String? = nil, sort: String? = nil) { + var patch: [String: WidgetJSON] = [:] + if let filter { patch["previewFilter"] = .string(filter) } + if let sort { patch["previewSort"] = .string(sort) } + guard !patch.isEmpty else { return } + updatePreferences(patch) } public func inboxFor(_ key: String) -> WidgetInboxSession? { inboxSessions[key] } @@ -847,6 +904,54 @@ public final class WidgetModel: ObservableObject { acknowledgeOpenedInbox() } + /// The overview's entries from every session: the waiting ones first, then newest first. A hub from before the + /// per-session list gives each session's pending and unread item. Stored when the inbox changes: up to 512 + /// sessions × 8 entries, sorted, is too much to derive in a body that reads it several times per pass. + public private(set) var inboxOverviewItems: [WidgetInboxItem] = [] + + nonisolated static func overviewItems(_ sessions: [WidgetInboxSession]) -> [WidgetInboxItem] { + sessions.flatMap { entry in entry.items ?? [entry.pendingItem, entry.unreadItem].compactMap { $0 } } + .sorted { lhs, rhs in + lhs.needsAnswer == rhs.needsAnswer ? (lhs.at == rhs.at ? lhs.id < rhs.id : lhs.at > rhs.at) : lhs.needsAnswer + } + } + + /// An overview entry's "Open in session": that session's inbox with the entry open. + public func openInboxItem(_ item: WidgetInboxItem) { + guard !stopping else { return } + openingInbox = item + select(item.key) + selectedCardID = item.id + foldedCardIDs.remove(item.id) + section = "Inbox" + acknowledgeOpenedInbox() + } + + /// The inbox button: every session's entries in one list. A second click while that list is open hides the panel. + public func toggleInboxOverview(on surface: WidgetSurfaceID) { + guard !stopping else { return } + if presentation(for: surface) == .expanded, activeModuleID == "agents", inboxOverview { + collapse() + return + } + + inboxOverview = true + section = "Inbox" + openModule("agents", on: surface) + } + + /// A session button on the rail or the top strip: opens that session's inbox, or hides the panel when the same + /// session is already open there (Martin, 2026-10-23: a second click did nothing). + public func toggleSession(_ key: String, on surface: WidgetSurfaceID) { + guard !stopping else { return } + if presentation(for: surface) == .expanded, activeModuleID == "agents", !inboxOverview, selectedKey == key { + collapse() + return + } + + openInboxNotification(on: surface, key: key) + } + private func acknowledgeOpenedInbox() { guard !stopping, let item = openingInbox, expanded != nil, activeModuleID == "agents", section == "Inbox", selectedKey == item.key, let displayed = cards.first(where: { $0.id == item.id }), displayed.at == item.at @@ -885,6 +990,7 @@ public final class WidgetModel: ObservableObject { public func select(_ key: String, edge: EdgePanelPlacement? = nil) { if openingInbox?.key != key { openingInbox = nil } + inboxOverview = false selectedKey = key lastSelected = snapshot?.sessions.first { $0.key == key } selectedCardID = nil diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 17004a8dbe..878b8a2384 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -8,6 +8,7 @@ public struct WidgetSessionBrowser: View { @State private var limit = 40 @State private var expanded = Set() @State private var collapsed = Set() + @GenReduceMotion private var reduceMotion public init(model: WidgetModel, onOpen: ((WidgetSession) -> Void)? = nil) { self.model = model @@ -52,29 +53,15 @@ public struct WidgetSessionBrowser: View { .frame(maxWidth: .infinity) } else { ForEach(Array(groups.prefix(limit))) { group in - if group.children.isEmpty { - row(group.parent, title: group.parent.title, depth: 0) - } else { - GenDisclosure(isExpanded: Binding(get: { - if !query.isEmpty { return true } - if collapsed.contains(group.id) { return false } - return expanded.contains(group.id) || group.id == model.selectedKey - || group.children.contains { $0.id == model.selectedKey } - }, set: { value in - if value { expanded.insert(group.id); collapsed.remove(group.id) } - else { expanded.remove(group.id); collapsed.insert(group.id) } - }), minHeight: 34, identifier: "widget.sessions.group." + group.id) { + // Every top-level session is the same row, with or without sub-agents; a chevron folds them. + VStack(alignment: .leading, spacing: 2) { + topRow(group) + if !group.children.isEmpty && isOpen(group) { LazyVStack(spacing: 2) { - row(group.parent, title: "Main", depth: 0) - ForEach(group.children) { child in row(child.session, title: child.session.title, depth: child.depth) } - }.padding(.top, 6) - } label: { - AgentRosterGroupLabel(title: group.parent.title, provider: group.parent.target.provider, - project: group.parent.project, account: group.parent.account, - total: group.children.count, running: group.running, - live: group.parent.status == "working" || group.running > 0, - lastAt: Date(timeIntervalSince1970: group.parent.activityAt / 1000)) - .padding(.vertical, 4) + ForEach(group.children) { child in + row(child.session, title: child.session.title, depth: child.depth + 1) + } + }.transition(.genReveal) } } } @@ -111,12 +98,71 @@ public struct WidgetSessionBrowser: View { .fixedSize() } + private func isOpen(_ group: WidgetAgentTree.Group) -> Bool { + if !query.isEmpty { return true } + if collapsed.contains(group.id) { return false } + return expanded.contains(group.id) || group.children.contains { $0.id == model.selectedKey } + } + + private func toggle(_ group: WidgetAgentTree.Group) { + let opening = !isOpen(group) + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { + if opening { expanded.insert(group.id); collapsed.remove(group.id) } + else { expanded.remove(group.id); collapsed.insert(group.id) } + } + } + + private func open(_ session: WidgetSession) { + if let onOpen { onOpen(session) } + else { model.select(session.key); model.section = "Inbox" } + } + + /// A top-level session: its name, project, account and sub-agent count, the same for every session. + private func topRow(_ group: WidgetAgentTree.Group) -> some View { + let session = group.parent + let selected = session.key == model.selectedKey + return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { + if group.children.isEmpty { + Color.clear.frame(width: 18, height: 18) + } else { + Button { toggle(group) } label: { + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary).rotationEffect(.degrees(isOpen(group) ? 90 : 0)) + .frame(width: 18, height: 26).contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip(isOpen(group) ? "Hide its sub-agents" : "Show its \(Plural.count(group.children.count, "sub-agent"))") + .accessibilityLabel((isOpen(group) ? "Hide sub-agents of " : "Show sub-agents of ") + session.title) + .accessibilityIdentifier("widget.sessions.group." + group.id) + } + Button { open(session) } label: { + VStack(alignment: .leading, spacing: 3) { + AgentRosterGroupLabel(title: session.title, provider: session.target.provider, + project: session.project, account: session.account, + total: group.children.count, running: group.running, + live: session.status == "working" || group.running > 0, + lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), + unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0) + if session.hiddenByFilter { + Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 31) + } + } + .padding(.vertical, 7).padding(.horizontal, 4) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.horizontal, 4) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + private func row(_ session: WidgetSession, title: String, depth: Int) -> some View { - HStack(alignment: .top, spacing: 5) { - Button { - if let onOpen { onOpen(session) } - else { model.select(session.key); model.section = "Inbox" } - } label: { + let selected = session.key == model.selectedKey + return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { + Button { open(session) } label: { VStack(alignment: .leading, spacing: 3) { AgentRosterRow(title: title, provider: session.target.provider, role: session.role ?? "lead", model: session.model, account: session.account, @@ -125,28 +171,42 @@ public struct WidgetSessionBrowser: View { lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), toolCalls: session.toolCalls ?? 0, unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0, - selected: session.key == model.selectedKey, showsRunningLabel: session.agentId == nil) + selected: selected, showsRunningLabel: session.agentId == nil) if session.hiddenByFilter { Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 15) } - }.frame(maxWidth: .infinity, alignment: .leading) - }.buttonStyle(.genHoverRow()) - .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + } + .padding(.vertical, 6).padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.leading, CGFloat(min(depth, 4)) * 14).padding(.trailing, 4) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + + /// cmux and pin, inside the row's own box so its selection and hover cover them. cmux is live only on the + /// hovered or selected row. + private func trailing(_ session: WidgetSession, hovering: Bool) -> some View { + HStack(spacing: 0) { + WidgetCmuxButton(model: model, session: session, size: 10.5, + live: hovering || session.key == model.selectedKey) Button { model.action(["action": "visibility", "key": .string(session.key), "pinned": .bool(!session.pinned)]) } label: { Image(systemName: session.pinned ? "pin.fill" : "pin") .font(.system(size: 10)).foregroundStyle(session.pinned ? Color.blue : .secondary) - .frame(width: 22, height: 25) + .frame(width: 24, height: 24).contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()).instantTooltip(session.pinned ? "Unpin from widget" : "Pin to widget") .accessibilityLabel((session.pinned ? "Unpin " : "Pin ") + session.title) } - .padding(.leading, CGFloat(min(depth, 4)) * 12).padding(.vertical, 5).padding(.horizontal, 6) - .background(session.key == model.selectedKey ? Color.blue.opacity(0.12) : .clear, - in: RoundedRectangle(cornerRadius: 9)) } private func setProjects(_ projects: [String]) { model.action(["action": "preferences", "patch": ["projects": .array(projects.map(WidgetJSON.string))]]) } } + diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 7eed9bbd47..4711180179 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -286,6 +286,10 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var alertAnswers: Bool? public var alertSound: String? public var alertPopup: Bool? + /// The agents hover list: "needs-you" (default) or "active" (sessions that work now too). + public var previewFilter: String? + /// The agents hover list's order: "attention" (default), "recent" or "project". + public var previewSort: String? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( @@ -375,6 +379,8 @@ public struct WidgetInboxItem: Codable, Equatable, Sendable { /// A short title (a decision's title or prompt, an answer's question) and a decision's number, for alerts. public var title: String? = nil public var number: Int? = nil + /// How many items this entry stands for: an answers group lists only its newest item. + public var count: Int? = nil } public struct WidgetInboxSession: Codable, Equatable, Sendable { public var key: String @@ -383,6 +389,8 @@ public struct WidgetInboxSession: Codable, Equatable, Sendable { public var latest: WidgetInboxItem? public var unreadItem: WidgetInboxItem? public var pendingItem: WidgetInboxItem? + /// The session's listed entries for the inbox overview, waiting ones first, then newest; nil from an older hub. + public var items: [WidgetInboxItem]? = nil } public struct WidgetInboxSummary: Codable, Equatable, Sendable { public struct Profile: Codable, Equatable, Sendable { public var hostId: String } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift index 972e33d9f6..09346c758a 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift @@ -95,6 +95,26 @@ final class GitPickerTests: XCTestCase { XCTAssertEqual(unpicked.highlighted, choices[0].id, "Down from nothing starts at the top") } + func testShiftExtendsARangeThatKeepsEveryCommitBetweenItsEnds() { + let choices = Self.choices(10) + var state = GitCommitPickerState(choices: choices, selection: choices[2].sha) + XCTAssertNil(state.range, "one commit is no range") + state.extend(1) + state.extend(1) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 2", "Change number 3", "Change number 4"]) + state.extend(-3) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 1", "Change number 2"], "the range grows from its start") + state.move(1) + XCTAssertNil(state.range, "a plain move is back to one commit") + + // A Shift-click across a filter: the hidden commits between the ends still belong to the range. + state.highlighted = choices[1].id + state.extend(to: choices[1].id) + state.query = "number 6" + state.extend(to: choices[6].id) + XCTAssertEqual(state.range?.count, 6, "commits 1 to 6, though the filter shows only one row") + } + func testTheDetailsComeFromOneGitLogWithTheFileAndTheCommitCounts() throws { let repo = FileManager.default.temporaryDirectory.appendingPathComponent("commit-details-\(UUID().uuidString)") try FileManager.default.createDirectory(at: repo.appendingPathComponent("notes"), withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 410015e7ee..538ca53dc3 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -594,6 +594,39 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(roster.active, 2, "952 sessions, 2 of them active: the preview shows 2, not 952") } + func testTheHoverListShowsEverySessionThatNeedsYouAndActiveOnesOnlyWhenAsked() { + let target = WidgetTarget(hostId: "local", provider: "claude", sessionId: "s", sourceHome: "", cwd: "/fixture") + func session(_ key: String, _ status: String, at: Double, project: String = "Fixture", visible: Bool = true) + -> WidgetSession { + WidgetSession(key: key, target: target, title: key, project: project, activityAt: at, + status: status, pinned: true, visible: visible, hiddenByFilter: false) + } + let sessions = [session("asks", "recent", at: 1, project: "Beta"), session("waits", "waiting", at: 2), + session("hidden-asks", "recent", at: 3, visible: false), session("works", "working", at: 9), + session("quiet", "finished", at: 8)] + let inbox = ["asks": WidgetInboxSession(key: "asks", unread: 0, needsAnswer: 2), + "hidden-asks": WidgetInboxSession(key: "hidden-asks", unread: 0, needsAnswer: 1)] + let needs = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "attention") + XCTAssertEqual(needs.map(\.key), ["hidden-asks", "waits", "asks"], + "every session that needs you, also one the rail does not show, newest first") + let active = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "attention") + XCTAssertEqual(active.map(\.key), ["hidden-asks", "waits", "asks", "works"], "working ones join after them") + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "recent") + .map(\.key), ["works", "hidden-asks", "waits", "asks"]) + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "project") + .first?.key, "asks", "Beta before Fixture") + } + + func testTheSessionInboxFilterSplitsInboxEntriesFromDecisions() { + let cards = ["answer", "result", "form", "decision", "todo"].map { kind in + WidgetCard(id: kind, kind: kind, sessionKey: "k", sourceId: kind, at: 1, title: kind, body: "", + status: "open", choices: [], attachments: [], refs: [], read: true) + } + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "inbox").map(\.kind), ["answer", "result", "form"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "decisions").map(\.kind), ["decision", "todo"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "all").count, 5) + } + @MainActor func testASnapshotDecodedForAStoppedWatchIsDroppedAfterARestart() async throws { let decoder = WidgetSnapshotDecoder() diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift index 99e9ce35c0..68c543b185 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift @@ -30,14 +30,8 @@ extension TerminalHost { func send(sessionId: String, text: String) -> String? { send(sessionId: sessionId, provider: nil, text: text) } } -/// The `tools ` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know -/// their own history (Grok has no journal and its own fallback); anything else goes through Claude's. -func cmuxTool(provider: String?) -> String { - switch provider { - case "codex", "grok": return provider ?? "claude" - default: return "claude" - } -} +/// The `tools ` that owns a session's cmux resolver (GenesisKit `CmuxCommandRunner.tool`). +func cmuxTool(provider: String?) -> String { CmuxCommandRunner.tool(provider: provider) } enum TerminalHosts { static let current: TerminalHost = CmuxHost() @@ -54,11 +48,7 @@ struct CmuxHost: TerminalHost { var isAvailable: Bool { Self.binary != nil } - static var binary: String? { - ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] - .map { ($0 as NSString).expandingTildeInPath } - .first { FileManager.default.isExecutableFile(atPath: $0) } - } + static var binary: String? { CmuxCommandRunner.binary } func tree() -> CmuxTree? { let span = HubPerf.begin("cmux.tree") @@ -83,31 +73,11 @@ struct CmuxHost: TerminalHost { } } - /// What `tools claude cmux focus --json` said, in words: nil when it focused. A recorded tab that - /// was closed answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { - guard status != 0 else { return nil } - struct Opaque: Decodable { - init(from decoder: Decoder) throws {} - } - struct Result: Decodable { - let gone: Bool? - let matches: [Opaque]? - } - if let result = try? JSONDecoder().decode(Result.self, from: stdout) { - if result.gone == true { - return CmuxHost.paneGone - } - if result.matches?.isEmpty == true { - return "No cmux pane shows this session. Choose a pane to resume it in." - } - } - let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) - let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) }.last { !$0.isEmpty } - return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + CmuxCommandRunner.focusFailure(status: status, stdout: stdout, stderr: stderr) } - static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + static let paneGone = CmuxCommandRunner.paneGone func send(sessionId: String, provider: String?, text: String) -> String? { do { @@ -142,69 +112,25 @@ struct CmuxHost: TerminalHost { } } - /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. private static func failure(of capture: ProcessCapture) -> String { - struct Failure: Decodable { let error: String } - if let failure = try? JSONDecoder().decode(Failure.self, from: capture.stdout) { - return failure.error - } - let stderr = String(decoding: capture.stderr, as: UTF8.self).trimmed - return stderr.isEmpty ? "tools claude cmux open-session exited \(capture.status)" : String(stderr.suffix(300)) + CmuxCommandRunner.openSessionFailure(status: capture.status, stdout: capture.stdout, stderr: capture.stderr) + ?? "tools claude cmux open-session failed" } private static func openSessionArgs(_ target: CmuxTarget) -> [String]? { target.openSessionArgs } - /// Words are shell-quoted here because cmux takes one command string; nothing in them comes from a URL. private func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { - let words = command.map(ShellWords.word).joined(separator: " ") - let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" - switch target { - case .newWorkspace(let window): - let result = Self.cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + (window.map { ["--window", $0] } ?? [])) - return result.ok ? nil : result.out.trimmed - case .newPane(let workspace): - return typeInto(Self.cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, "--focus", "true"]), workspace: workspace, line: line) - case .newTab(let workspace, let pane): - return typeInto(Self.cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, "--focus", "true"]), workspace: workspace, line: line) - case .surface(let workspace, let surface): - return send(line, workspace: workspace, surface: surface) - } - } - - /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. - private func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { - guard created.ok else { return created.out.trimmed } - guard let range = created.out.range(of: "surface:[0-9]+", options: .regularExpression) else { - return "cmux did not report the new surface: \(created.out.trimmed)" - } - return send(line, workspace: workspace, surface: String(created.out[range])) - } - - private func send(_ line: String, workspace: String, surface: String) -> String? { - let sent = Self.cmux(["send", "--workspace", workspace, "--surface", surface, line]) - guard sent.ok else { return sent.out.trimmed } - let enter = Self.cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) - return enter.ok ? nil : enter.out.trimmed + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) } @discardableResult static func cmux(_ args: [String]) -> (ok: Bool, out: String) { - guard let binary else { return (false, "cmux is not installed") } let span = HubPerf.begin("cmux.\(args.first ?? "")") defer { span.end() } - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = args - var environment = ProcessInfo.processInfo.environment - environment["CMUX_QUIET"] = "1" - process.environment = environment - guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } - let text = String(decoding: result.stdout, as: UTF8.self) - return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + return CmuxCommandRunner.cmux(args) } - } // MARK: - Loading the tree off the main thread diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift index d71f97d2ff..a4b9f34cba 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift @@ -120,12 +120,7 @@ enum WorktreeDiscovery { enum AgentLauncher { static func resumeCommand(for session: HubSession) -> [String]? { - switch session.provider { - case "claude": return ["tools", "claude", "resume", session.sessionId] - case "codex": return ["codex", "resume", session.sessionId] - case "grok": return ["grok", "--resume", session.sessionId] - default: return nil - } + CmuxCommandRunner.resumeCommand(provider: session.provider, sessionId: session.sessionId) } /// Runs `command` in `cwd` in a new workspace of the current terminal host (cmux today, see diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift index 9103d4a451..689475145a 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift @@ -2826,8 +2826,9 @@ private struct ScopeLink: View { } /// Codex's review source menu: what the diff compares. Its label is the chip the markdown viewer's scope picker shows -/// (GenesisKit `GitPickerChip`); "Pick a Commit…" opens the commit chooser (`gitCommitPopover`), with each commit's -/// author, date and line counts, a filter and the keyboard. +/// (GenesisKit `GitPickerChip`); "Pick Commit(s)…" opens the commit chooser (`gitCommitPopover`), with each commit's +/// author, date and line counts, a filter and the keyboard. Shift with the arrows or a Shift-click picks several +/// commits in a row, shown as one range from the parent of the oldest to the newest. struct ScopeMenu: View { @ObservedObject var model: ReviewModel @State private var pickingCommit = false @@ -2846,9 +2847,20 @@ struct ScopeMenu: View { .instantTooltip("What this diff compares: \(label)") // The list exists only while it is open: a closed chooser builds nothing (GenesisKit GitCommitPicker.swift). .gitCommitPopover(isPresented: $pickingCommit, choices: model.commits.map { GitCommitChoice($0) }, selection: shownCommit, - title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo)) { choice in - model.setScope(.commit(sha: choice.sha, title: choice.subject)) - } + title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo), + onPick: { choice in model.setScope(.commit(sha: choice.sha, title: choice.subject)) }, + onPickRange: { range in + if let scope = Self.rangeScope(range) { model.setScope(scope) } + }) + } + + /// Several commits of the list (newest first, as `model.commits` holds them) as one range: from the parent of the + /// oldest to the newest. One commit stays a commit scope. + static func rangeScope(_ range: [GitCommitChoice]) -> DiffScope? { + guard let newest = range.first, let oldest = range.last else { return nil } + guard range.count > 1 else { return .commit(sha: newest.sha, title: newest.subject) } + return .range(base: oldest.sha + "^", head: newest.sha, + label: "\(oldest.sha.prefix(7))..\(newest.sha.prefix(7)) (\(Plural.count(range.count, "commit")))") } /// The commit the diff shows now, for the chooser's check. @@ -2864,10 +2876,13 @@ struct ScopeMenu: View { // fills, not a greyed-out item. let turns = model.session != nil && model.remoteHead == nil let onCommit: Bool = { - if case .commit = model.scope { return true } - return false + switch model.scope { + case .commit: return true + case .range(_, let head, _, _): return model.commits.contains { $0.sha == head } + default: return false + } }() - let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick a Commit…", + let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick Commit(s)…", checked: onCommit, enabled: !model.commits.isEmpty, run: pickCommit) let prItem: [MenuButtonItem] = model.prScope.map { scope in [.action("PR \(model.prLabel)", checked: model.scope == scope) { model.setScope(scope) }] diff --git a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift index 2e532dbd1a..e58197674d 100644 --- a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift @@ -125,6 +125,9 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { if parts.count > 1, let target = surface(2) { model.openModule(parts[1], on: target) } case "hover", "unhover": if let target = surface(1) { model.hover(target, inside: parts.first == "hover") } + // What the inbox button and a rail session button do: open, or hide when already open. + case "inbox": if let target = surface(1) { model.toggleInboxOverview(on: target) } + case "session": if parts.count > 1, let target = surface(2) { model.toggleSession(parts[1], on: target) } case "collapse": model.collapse() case "select": if parts.count > 1 { model.select(parts[1]) } case "settings": coordinator.showSettings(pageID: parts.count > 1 ? parts[1] : "widgets.general") diff --git a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift index 64d5fa5a12..9d777e8d27 100644 --- a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift +++ b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift @@ -79,17 +79,17 @@ final class HubMenuButtonTests: XCTestCase { ] var opened = 0 var items = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }).items - let pick = items.first { $0.title == "Pick a Commit…" } + let pick = items.first { $0.title == "Pick Commit(s)…" } XCTAssertEqual(pick?.isEnabled, true) XCTAssertEqual(pick?.state, .off) XCTAssertNil(pick?.submenu, "the chooser is a popover built when it opens, not a submenu") let menu = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }) - menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick a Commit…" } ?? -1) + menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick Commit(s)…" } ?? -1) XCTAssertEqual(opened, 1) model.scope = .commit(sha: model.commits[1].sha, title: model.commits[1].subject) items = MenuButtonPresenter.menu(ScopeMenu.items(model: model)).items - XCTAssertEqual(items.first { $0.title == "Pick a Commit…" }?.state, .on) + XCTAssertEqual(items.first { $0.title == "Pick Commit(s)…" }?.state, .on) XCTAssertEqual(GitCommitChoice(model.commits[0]).relative, "2 hours ago", "the git words show until the details arrive") } From 659b4d4c1a31cda04877f230697df012ddd4d216 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:28:38 +0200 Subject: [PATCH 020/251] docs(skills, ux): name the commit of the widget inbox findings --- .claude/skills/ux/references/findings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 8a5407d1d4..4876b1678c 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,7 +3,7 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. -- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** +- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** Commit 5e0a6e328d. - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). - *A second click on a rail session ("4") did nothing.* Every session button called `openInboxNotification` From 95daa5e7b140d0b1a1290c6cfe6de1fcf56da138 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:34:00 +0200 Subject: [PATCH 021/251] feat(gitlab): a review is signed by $GENESIS_TOOLS_AGENT when --agent is not given, so a Fable or Codex session names itself, and the /user type carries email and commit_email --- src/gitlab/README.md | 2 +- src/gitlab/commands/pr-review.ts | 7 ++++++- src/gitlab/commands/review-judgements.ts | 13 +++++++++++-- src/gitlab/lib/agent-name.ts | 10 ++++++++++ src/gitlab/lib/client.ts | 3 +++ src/gitlab/lib/pr-review.test.ts | 21 +++++++++++++++++++++ src/utils/env/envVariables.ts | 2 ++ 7 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/gitlab/lib/agent-name.ts diff --git a/src/gitlab/README.md b/src/gitlab/README.md index b0e67a2b0b..bf561c8b40 100644 --- a/src/gitlab/README.md +++ b/src/gitlab/README.md @@ -202,7 +202,7 @@ Fixed in the next push. - **Actions**: `T` reply, reply-resolve, none; `Y` reply, none; `D` keep, reword, move, delete; `N` comment, none. - **Anchors** carry the text of the line: `check` refuses a line outside the diff, or a line whose text differs, and names the line where that text really is. - **`check`** also wants a `[NN%]` badge on every verdict, the text each action sends, and the configured `review.draftRules`. -- **`render`**: quoted comments are never shortened; text meant to be posted is a fenced block indented by three spaces (display only). An answer in my own thread is signed `[NN%] :` from its badge (`--agent`, default `Opus`). +- **`render`**: quoted comments are never shortened; text meant to be posted is a fenced block indented by three spaces (display only). An answer in my own thread is signed `[NN%] :` from its badge, and `--proposal` puts the same name into `author.agent` (`--agent`, default `$GENESIS_TOOLS_AGENT`, else `Opus`). A review merged from two models names both, e.g. `--agent "Opus and Fable"`. - **`comments post`**: a dry run lists each step; `--apply` posts them as drafts, stops at the first failure, reads every result back and records it in the ledger. A D draft that `publish` turned into a thread is answered with `--answers D05`. --- diff --git a/src/gitlab/commands/pr-review.ts b/src/gitlab/commands/pr-review.ts index 87513ecf80..11dc719671 100644 --- a/src/gitlab/commands/pr-review.ts +++ b/src/gitlab/commands/pr-review.ts @@ -30,6 +30,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { type FetchReviewOptions, runFetchReview } from "@app/gitlab/commands/fetch-review"; import { progress, type TargetOptions, withProject } from "@app/gitlab/commands/shared"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import { currentUser, type ProjectApi, resolveProjectApi } from "@app/gitlab/lib/client"; import { FETCH_FORMATS, loadConfig } from "@app/gitlab/lib/config"; import { gitResult } from "@app/gitlab/lib/git"; @@ -184,7 +185,11 @@ export function registerPrReview(pr: Command): Command { .option("--expand ", "Print these ids in full, e.g. F02,T01 (--give reads the saved facts)") .option("--refresh", "--give: with --expand, collect the facts again instead of reading the saved ones") .option("--proposal-skeleton", "--give: print a review proposal JSON pre-filled from the facts") - .option("--agent ", "--give: author.agent in the proposal skeleton", "agent") + .option( + "--agent ", + "--give: author.agent in the proposal skeleton (default: $GENESIS_TOOLS_AGENT, else agent)", + defaultAgentName("agent") + ) .option("--no-impact", "--give: skip the scan of other open MRs") .option( "--impact-limit ", diff --git a/src/gitlab/commands/review-judgements.ts b/src/gitlab/commands/review-judgements.ts index 9a7f2a8be3..62a356eabf 100644 --- a/src/gitlab/commands/review-judgements.ts +++ b/src/gitlab/commands/review-judgements.ts @@ -16,6 +16,7 @@ import { resolve } from "node:path"; import { platform } from "node:process"; import { pickMode } from "@app/gitlab/commands/pr-review"; import { progress, type TargetOptions, withProject } from "@app/gitlab/commands/shared"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import { resolveProjectApi } from "@app/gitlab/lib/client"; import { loadConfig } from "@app/gitlab/lib/config"; import { parseJudgementsFile, reportPathFor } from "@app/gitlab/lib/judgements"; @@ -131,7 +132,11 @@ export function registerReviewJudgements(review: Command): void { "--repo ", "The checkout file links and code views come from (default: the current directory)" ) - .option("--agent ", "Who signs an answer in your own thread: [90%] : …", "Opus") + .option( + "--agent ", + "Who signs: author.agent of the proposal and [90%] : in your own thread (default: $GENESIS_TOOLS_AGENT, else Opus)", + defaultAgentName() + ) .option("--context-lines ", "Lines around each thread's anchor (default: review.fetch.contextLines)") ).action(runRender); } @@ -221,7 +226,11 @@ export function registerCommentsPost(comments: Command): void { .option("--do ", "Run each item's Action, e.g. T01,D03,N01") .option("--answers ", "Post each item's Proposed answer into my own thread (a D item after publish)") .option("--apply", "Post for real") - .option("--agent ", "Who signs an answer in your own thread: [90%] : …", "Opus") + .option( + "--agent ", + "Who signs: author.agent of the proposal and [90%] : in your own thread (default: $GENESIS_TOOLS_AGENT, else Opus)", + defaultAgentName() + ) ).action(runPost); } diff --git a/src/gitlab/lib/agent-name.ts b/src/gitlab/lib/agent-name.ts new file mode 100644 index 0000000000..5a4f831d18 --- /dev/null +++ b/src/gitlab/lib/agent-name.ts @@ -0,0 +1,10 @@ +import { env } from "@genesiscz/utils/env"; + +/** + * Who signs a review. `--agent` wins; without it the name comes from `GENESIS_TOOLS_AGENT`, so a Fable or Codex + * session names itself instead of inheriting "Opus". + */ +export function defaultAgentName(fallback = "Opus"): string { + const fromEnv = env.agents.getName()?.trim(); + return fromEnv ? fromEnv : fallback; +} diff --git a/src/gitlab/lib/client.ts b/src/gitlab/lib/client.ts index 6b2ac42d35..734809e3ab 100644 --- a/src/gitlab/lib/client.ts +++ b/src/gitlab/lib/client.ts @@ -524,6 +524,9 @@ export interface GitLabUser { name: string; web_url: string; state?: string; + /** Only `/user` (the token owner) returns these two. */ + email?: string; + commit_email?: string; } export interface GitLabProject { diff --git a/src/gitlab/lib/pr-review.test.ts b/src/gitlab/lib/pr-review.test.ts index 0a29c4982c..f54aadb09a 100644 --- a/src/gitlab/lib/pr-review.test.ts +++ b/src/gitlab/lib/pr-review.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { factsBaseName, factsPathOf } from "@app/gitlab/commands/pr-review"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import type { ProjectApi } from "@app/gitlab/lib/client"; import { mergeConfig, NEUTRAL_CONFIG } from "@app/gitlab/lib/config"; import { applyRefs, emptyIdMap } from "@app/gitlab/lib/ids"; @@ -41,6 +42,7 @@ import type { DraftSummary } from "@app/gitlab/lib/review-drafts"; import { collectThreadContext, type Discussion } from "@app/gitlab/lib/review-render"; import { parseProposal } from "@app/hub/lib/proposal"; import { type DetectProbes, detectGenesisTools, resetGenesisToolsCache } from "@genesiscz/utils/cli/genesis-tools"; +import { env } from "@genesiscz/utils/env"; import { json2md } from "@genesiscz/utils/json2md"; const GIT_DIFF = [ @@ -978,3 +980,22 @@ describe("the api impact source", () => { } }); }); + +describe("defaultAgentName", () => { + test("GENESIS_TOOLS_AGENT names the signer, trimmed", async () => { + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: " Fable " }, async () => { + expect(defaultAgentName()).toBe("Fable"); + expect(defaultAgentName("agent")).toBe("Fable"); + }); + }); + + test("an unset or blank variable falls back", async () => { + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: " " }, async () => { + expect(defaultAgentName()).toBe("Opus"); + expect(defaultAgentName("agent")).toBe("agent"); + }); + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: undefined }, async () => { + expect(defaultAgentName()).toBe("Opus"); + }); + }); +}); diff --git a/src/utils/env/envVariables.ts b/src/utils/env/envVariables.ts index b914f6836c..a039089ec5 100644 --- a/src/utils/env/envVariables.ts +++ b/src/utils/env/envVariables.ts @@ -412,6 +412,8 @@ export const env = { agents: { /** AGENTS_HOOKS_DISABLE=1 is the emergency stop: every `tools agents` hook exits 0 at once. */ areHooksDisabled: () => getRaw("AGENTS_HOOKS_DISABLE") === "1", + /** GENESIS_TOOLS_AGENT names the agent of this session (for example `Fable`, `Codex`), for tools that sign its work. */ + getName: () => getTrimmed("GENESIS_TOOLS_AGENT"), }, task: { From cb8c22db9962539688ccdeab804c09367fd0a6f1 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:52:33 +0200 Subject: [PATCH 022/251] fix(agents, hooks): a diff the hook showed itself goes to .hook.md so no loaded copy of the full-diff mod draws it twice, the mod marks its session at load and before each tool call, and the mods added 2026-10-23 start off behind tools agents hooks mods enable|disable --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- plugins/genesis-tools-mods/hooks/register.tsx | 4 ++ plugins/genesis-tools-mods/hooks/settings.ts | 21 +++---- .../tests/agent-model.test.tsx | 16 ++++++ .../tests/features.test.tsx | 7 ++- src/agents/README.md | 8 ++- src/agents/commands/hooks.ts | 57 ++++++++++++++++++- src/agents/lib/hooks/config.ts | 21 +++---- src/agents/lib/hooks/diff/diff.test.ts | 3 + src/agents/lib/hooks/diff/full.ts | 12 +++- src/agents/lib/hooks/diff/run.ts | 1 + src/agents/lib/hooks/hooks.test.ts | 6 +- src/agents/lib/hooks/mods-plugin.test.ts | 21 ++++++- src/agents/lib/hooks/mods.ts | 26 +++++++++ 15 files changed, 176 insertions(+), 35 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 201db6ae5a..26a078ff51 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -26,8 +26,8 @@ }, { "name": "genesis-tools-mods", - "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Install with: tools agents hooks mods install --write.", - "version": "1.8.0", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Install with: tools agents hooks mods install --write.", + "version": "1.9.0", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index fa13964e9d..6ef373030b 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.8.0", - "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Configured with: tools agents hooks config set mods.. --write.", + "version": "1.9.0", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index aa1aa657a6..332d7078f4 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -381,6 +381,8 @@ const FEATURES: Feature[] = [decisions, gitBand, spinner, cacheBand, tmpUsage, a export const register: Register = (on) => { on("session.start", async ($, e, next) => { const host = hostOf($); + // A reload runs `session.start` again in the middle of a turn: mark the session at once, not at the next turn. + await announceSession($); for (const feature of FEATURES) { for (const command of feature.commands ?? []) { @@ -411,6 +413,8 @@ export const register: Register = (on) => { on("tool.call", async ($, e, next) => { const host = hostOf($); + // Before the call runs, so its PostToolUse hook sees the mark; a no-op once this module set it. + await announceSession($); rememberToolPaths(e); const ends = FEATURES.flatMap((feature) => feature.beforeTool?.(host, e) ?? []); diff --git a/plugins/genesis-tools-mods/hooks/settings.ts b/plugins/genesis-tools-mods/hooks/settings.ts index c3c39566ea..d7b57196ee 100644 --- a/plugins/genesis-tools-mods/hooks/settings.ts +++ b/plugins/genesis-tools-mods/hooks/settings.ts @@ -25,19 +25,20 @@ export interface ModsSettings { hooks: { tokens: boolean; dedupe: boolean }; } +/** Every mod past the full diff and the idle compaction starts off: `tools agents hooks mods enable --write`. */ export const MODS_DEFAULTS: ModsSettings = { diff: { fullDiff: true, collapseGitSync: true }, idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, - agentModel: { enabled: true, format: "label" }, - decisions: { enabled: true }, - links: { enabled: true }, - git: { enabled: true }, - askRow: { enabled: true }, - spinner: { enabled: true }, - cacheBand: { enabled: true, warnMinutes: 10, others: true }, - tmpUsage: { enabled: true, minutes: 5 }, - agentsBand: { enabled: true, warnMinutes: 60 }, - hooks: { tokens: true, dedupe: true }, + agentModel: { enabled: false, format: "label" }, + decisions: { enabled: false }, + links: { enabled: false }, + git: { enabled: false }, + askRow: { enabled: false }, + spinner: { enabled: false }, + cacheBand: { enabled: false, warnMinutes: 10, others: true }, + tmpUsage: { enabled: false, minutes: 5 }, + agentsBand: { enabled: false, warnMinutes: 60 }, + hooks: { tokens: false, dedupe: false }, }; function whole(value: unknown, min: number, max: number, fallback: number): number { diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx index 36c13a783c..b534786eaf 100644 --- a/plugins/genesis-tools-mods/tests/agent-model.test.tsx +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -6,6 +6,14 @@ import { expect, mock, test } from "claude-code/testing"; /** The engine's own spawn, beneath the plugin: records the description the plugin passed down. */ function engine(on: On, seen: string[]): void { mock.env(on, { HOME: "/home/test" }); + // Every mod past the full diff starts off; this test turns its own on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { agentModel: { enabled: true } } }) }; + } + + throw new Error("no file"); + }); on("agent.spawn", async (_$, e) => { seen.push(e.description); @@ -58,6 +66,14 @@ test("a spawn that names no model keeps its description", async ($, on) => { /** The model's response beneath the plugin: an Agent call that names a model, an Agent call that does not, a Bash call. */ function response(on: On): void { mock.env(on, { HOME: "/home/test" }); + // Every mod past the full diff starts off; this test turns its own on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { agentModel: { enabled: true } } }) }; + } + + throw new Error("no file"); + }); on("turn.step", async function* () { yield { kind: "tool", index: 0, id: "toolu_a", name: "Agent" }; yield { kind: "input", index: 0, json: '{"description":"Pair one",' }; diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx index dfe626f372..08096d70d4 100644 --- a/plugins/genesis-tools-mods/tests/features.test.tsx +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -152,7 +152,12 @@ function machine(on: On): void { "/repo/notes/plan.md": "file", }; - on("fs.read", async () => { + // Every mod past the full diff starts off; the link fixer is turned on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { links: { enabled: true }, decisions: { enabled: true } } }) }; + } + throw new Error("no file"); }); on("fs.stat", async (_$, e) => { diff --git a/src/agents/README.md b/src/agents/README.md index 885de3bb2c..e5cb003a9f 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -232,9 +232,11 @@ turn, a context under `mods.idleCompact.minTokens` (200,000) and a session with appends a notice that starts `Compacted while idle`. `tools agents hooks config set mods.idleCompact false --write` turns it off, also for a timer already armed. A reload of the mod drops the timer until the next turn ends. -**The daily mods.** The same plugin carries more features, each an object under `mods` in the hooks file, all on -by default; `config set mods. false --write` turns one off (it means `mods..enabled`), and the -flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.idleCompactMinutes`, …) are still read and still set. +**The daily mods.** The same plugin carries more features, each an object under `mods` in the hooks file, all off +by default. `tools agents hooks mods enable git agentsBand --write` turns mods on by name (`all` is every mod, +`disable` turns them off, `mods status` shows each one); `config set mods. true --write` does the same for one +(it means `mods..enabled`). The flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.idleCompactMinutes`, …) +are still read and still set. Each feature appends what it did to `~/.genesis-tools/agents/mods-log/.jsonl`, so its heuristics can be checked against real use. diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index 9766e45f5d..43524f1c37 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -46,9 +46,11 @@ import { import { claudePlugin, claudeUserSettingsPath, + MOD_SWITCHES, MODS_PLUGIN, MODS_SCOPES, type ModsScope, + modSwitchKeys, modsPluginState, modsWatchState, PLUGIN_DIRS_VAR, @@ -736,7 +738,7 @@ function registerMods(hooks: Command): void { const mods = hooks .command("mods") .description( - `Claude Code mods (${MODS_PLUGIN}): status, install, uninstall. Settings: mods.* in the hooks config` + `Claude Code mods (${MODS_PLUGIN}): status, enable, disable, install, uninstall. Settings: mods.* in the hooks config` ); mods.command("status", { isDefault: true }) @@ -851,6 +853,18 @@ function registerMods(hooks: Command): void { ); }); + for (const on of [true, false]) { + const verb = on ? "enable" : "disable"; + + mods.command(verb) + .description(`Turn mods ${on ? "on" : "off"} by name, or all: ${Object.keys(MOD_SWITCHES).join(", ")}`) + .argument("[names...]", "The mods to switch") + .option("--write", "Actually write; without it this is a dry run") + .action((names: string[], options: { write?: boolean }) => { + switchMods({ names, on, write: Boolean(options.write) }); + }); + } + mods.command("install") .description("Install the mods plugin from this repository's marketplace") .option("--scope ", `Where Claude Code records it: ${MODS_SCOPES.join(", ")}`, "user") @@ -875,6 +889,47 @@ function registerMods(hooks: Command): void { }); } +/** `hooks mods enable|disable `: one `config set` per switch key of the named mods. */ +function switchMods({ names, on, write }: { names: string[]; on: boolean; write: boolean }): void { + const { keys, unknown } = modSwitchKeys(names); + const verb = on ? "enable" : "disable"; + + if (names.length === 0 || unknown.length > 0) { + if (unknown.length > 0) { + ui.err(`No such mod: ${unknown.join(", ")}`); + } + + ui.raw(`Mods: all, ${Object.keys(MOD_SWITCHES).join(", ")}`); + ui.raw( + suggestCommand("tools agents", { replaceCommand: ["hooks", "mods", verb, "git", "agentsBand", "--write"] }) + ); + process.exitCode = 1; + return; + } + + for (const key of keys) { + try { + setHooksConfig(key, String(on), { write }); + } catch (err) { + logger.debug({ err, key }, "Rejected a mods switch"); + ui.err(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + ui.raw(`${pc.bold(key)} → ${on}`); + } + + ui.raw(pc.dim(hooksConfigPath())); + + if (!write) { + ui.raw(pc.yellow("dry run: nothing was written. Pass --write to apply.")); + return; + } + + ui.raw(pc.dim("A running session reads the change at its next turn; a band redraws on its next refresh.")); +} + function runPluginCommand({ args, write }: { args: string[]; write: boolean }): void { const shown = `claude plugin ${args.join(" ")}`; diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index 57a35f4084..c0a988cac7 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -457,19 +457,20 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { staleness: { warnAfterMinutes: 30, alarmAfterMinutes: 120, notify: true }, }, agentsTalk: { hint: true }, + // Every mod past the full diff and the idle compaction starts off: `tools agents hooks mods enable --write`. mods: { diff: { fullDiff: true, collapseGitSync: true }, idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, - agentModel: { enabled: true, format: "label" }, - decisions: { enabled: true }, - links: { enabled: true }, - git: { enabled: true }, - askRow: { enabled: true }, - spinner: { enabled: true }, - cacheBand: { enabled: true, warnMinutes: 10, others: true }, - tmpUsage: { enabled: true, minutes: 5 }, - agentsBand: { enabled: true, warnMinutes: 60 }, - hooks: { tokens: true, dedupe: true }, + agentModel: { enabled: false, format: "label" }, + decisions: { enabled: false }, + links: { enabled: false }, + git: { enabled: false }, + askRow: { enabled: false }, + spinner: { enabled: false }, + cacheBand: { enabled: false, warnMinutes: 10, others: true }, + tmpUsage: { enabled: false, minutes: 5 }, + agentsBand: { enabled: false, warnMinutes: 60 }, + hooks: { tokens: false, dedupe: false }, }, unpushed: { enabled: true, maxCommits: 3, maxAgeMinutes: 60, remindEveryMinutes: 30 }, logPath: defaultLogPath(), diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index a9daa09ed0..3a3bfaa563 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -4010,6 +4010,8 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(stripAnsi(decision.message ?? "")).toContain(`Full diff: ${path}`); expect((decision.message ?? "").length).toBeLessThanOrEqual(900); expect(readFileSync(path, "utf8")).toContain("+line 79"); + // The message showed the diff, so the note's name keeps any copy of the full-diff mod from drawing it too. + expect(basename(path)).toEndWith(".hook.md"); } finally { rmSync(join(repo, "big.ts"), { force: true }); rmSync(path, { force: true }); @@ -4031,6 +4033,7 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(decision.message).toBeUndefined(); expect(decision.files).toEqual([join(repo, "small.ts")]); expect(readFileSync(path, "utf8")).toContain("+two"); + expect(basename(path)).toMatch(/^call-mod-\d+\.md$/); } finally { rmSync(join(repo, "small.ts"), { force: true }); rmSync(path, { force: true }); diff --git a/src/agents/lib/hooks/diff/full.ts b/src/agents/lib/hooks/diff/full.ts index 52f0147362..486a98e6dd 100644 --- a/src/agents/lib/hooks/diff/full.ts +++ b/src/agents/lib/hooks/diff/full.ts @@ -63,12 +63,21 @@ export function overflowPart(path: string, patch: string): FullDiffPart { */ export const DEFERRED_NOTE_SUFFIX = ".task"; +/** + * The note of a call whose diff the hook's own message showed (the session has no mod mark): the mod draws only + * `.md`, so this name keeps any loaded copy of it from drawing the diff a second time. A session that loaded the + * mod before the mark existed never sets it, and drew such calls twice (col-tools, 2026-10-23). `diff show ` still + * finds it, by prefix. + */ +export const HOOK_NOTE_SUFFIX = ".hook"; + export function writeFullDiff(options: { toolUseId: string | undefined; command: string; parts: FullDiffPart[]; notShown: string[]; deferred?: boolean; + shownByHook?: boolean; }): string | null { const call = safeSegment(options.toolUseId); @@ -103,7 +112,8 @@ export function writeFullDiff(options: { ? ["## Changed, past the file cap", "", ...options.notShown.map((path) => `- ${path}`), ""] : []), ]; - const path = join(fullDiffDir(), `${call}${options.deferred ? DEFERRED_NOTE_SUFFIX : ""}.md`); + const suffix = options.deferred ? DEFERRED_NOTE_SUFFIX : options.shownByHook ? HOOK_NOTE_SUFFIX : ""; + const path = join(fullDiffDir(), `${call}${suffix}.md`); try { mkdirSync(fullDiffDir(), { recursive: true, mode: 0o700 }); diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index 142a3fcda9..c70f3d5762 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -1795,6 +1795,7 @@ export function runDiffPost({ parts: [...blocks.flatMap((block) => (block.source ? [block.source] : [])), ...overflow], notShown: notShown.filter((path) => !overflow.some((part) => part.path === path)), deferred, + shownByHook: true, }); 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 9000fa42ac..710f230d86 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -678,12 +678,12 @@ describe("applySetting", () => { it("the other mods keys are set and checked by the type of their default", () => { const set = (key: string, value: string) => applySetting(DEFAULT_HOOKS_CONFIG, key, value).mods; - expect(set("mods.agentModel.format", "short").agentModel).toEqual({ enabled: true, format: "short" }); + expect(set("mods.agentModel.format", "short").agentModel).toEqual({ enabled: false, format: "short" }); expect(() => set("mods.agentModel.format", "tiny")).toThrow("label or short"); - expect(set("mods.links", "false").links.enabled).toBe(false); + expect(set("mods.links", "true").links.enabled).toBe(true); expect(set("mods.cacheBand.warnMinutes", "15").cacheBand.warnMinutes).toBe(15); expect(() => set("mods.cacheBand.warnMinutes", "75")).toThrow("1 to 59"); - expect(set("mods.hooks.dedupe", "false").hooks).toEqual({ tokens: true, dedupe: false }); + expect(set("mods.hooks.dedupe", "true").hooks).toEqual({ tokens: false, dedupe: true }); expect(() => set("mods.nonsense.enabled", "true")).toThrow("Unknown mods key"); expect(() => set("mods.links.colour", "red")).toThrow("Unknown mods key"); }); diff --git a/src/agents/lib/hooks/mods-plugin.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts index 70311c4513..23ae822f73 100644 --- a/src/agents/lib/hooks/mods-plugin.test.ts +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -24,6 +24,7 @@ import { MODS_DEFAULTS, modsSettings } from "../../../../plugins/genesis-tools-m import { DEFAULT_HOOKS_CONFIG } from "./config"; import { overflowPart } from "./diff/full"; import { MOD_MAX_PATCH_CHARS, MODS_SESSION_VAR } from "./diff/run"; +import { MOD_SWITCHES, modSwitchKeys } from "./mods"; const NOTE = [ "# Diff of toolu_x", @@ -248,6 +249,22 @@ describe("the mods section", () => { expect(MODS_DEFAULTS).toEqual(DEFAULT_HOOKS_CONFIG.mods); }); + it("starts every mod off but the full diff and the idle compaction, and each one has a switch by name", () => { + const valueAt = (key: string): unknown => + key + .split(".") + .slice(1) + .reduce( + (at, part) => (typeof at === "object" && at !== null ? Reflect.get(at, part) : undefined), + MODS_DEFAULTS + ); + const on = Object.entries(MOD_SWITCHES).filter(([, keys]) => keys.every((key) => valueAt(key) === true)); + expect(on.map(([name]) => name)).toEqual(["diff", "idleCompact"]); + expect(modSwitchKeys(["git", "hooks", "git"]).keys).toEqual(["mods.git.enabled", "mods.hooks.tokens"]); + expect(modSwitchKeys(["all"]).keys).toHaveLength(Object.keys(MOD_SWITCHES).length); + expect(modSwitchKeys(["gti"]).unknown).toEqual(["gti"]); + }); + it("the plugin reads the nested keys, and the flat keys of before where the object leaves a field out", () => { const read = modsSettings( SafeJSON.stringify({ @@ -265,10 +282,10 @@ describe("the mods section", () => { expect(read.diff).toEqual({ fullDiff: false, collapseGitSync: false }); expect(read.idleCompact).toEqual({ enabled: false, minutes: 40, minTokens: 200_000 }); - expect(read.agentModel).toEqual({ enabled: true, format: "short" }); + expect(read.agentModel).toEqual({ enabled: false, format: "short" }); // Out of range keeps the default, as the CLI does. expect(read.cacheBand.warnMinutes).toBe(10); - expect(read.hooks).toEqual({ tokens: true, dedupe: false }); + expect(read.hooks).toEqual({ tokens: false, dedupe: false }); expect(modsSettings("{ not json")).toEqual(MODS_DEFAULTS); }); }); diff --git a/src/agents/lib/hooks/mods.ts b/src/agents/lib/hooks/mods.ts index 476788cb7a..95faadfe0c 100644 --- a/src/agents/lib/hooks/mods.ts +++ b/src/agents/lib/hooks/mods.ts @@ -15,6 +15,32 @@ export const MODS_PLUGIN = "genesis-tools-mods@genesis-tools"; export const MODS_SCOPES = ["user", "project", "local"] as const; export type ModsScope = (typeof MODS_SCOPES)[number]; +/** Each mod by the name `hooks mods enable|disable` takes, with the hooks-config keys that switch it. */ +export const MOD_SWITCHES: Record = { + diff: ["mods.diff.fullDiff"], + idleCompact: ["mods.idleCompact.enabled"], + agentModel: ["mods.agentModel.enabled"], + decisions: ["mods.decisions.enabled"], + links: ["mods.links.enabled"], + git: ["mods.git.enabled"], + askRow: ["mods.askRow.enabled"], + spinner: ["mods.spinner.enabled"], + cacheBand: ["mods.cacheBand.enabled"], + tmpUsage: ["mods.tmpUsage.enabled"], + agentsBand: ["mods.agentsBand.enabled"], + hooks: ["mods.hooks.tokens"], + hooksDedupe: ["mods.hooks.dedupe"], +}; + +/** The keys of the named mods (`all` is every one), and the names that are no mod. */ +export function modSwitchKeys(names: readonly string[]): { keys: string[]; unknown: string[] } { + const picked = names.includes("all") ? Object.keys(MOD_SWITCHES) : names; + const unknown = picked.filter((name) => !(name in MOD_SWITCHES)); + const keys = picked.flatMap((name) => MOD_SWITCHES[name] ?? []); + + return { keys: [...new Set(keys)], unknown }; +} + export interface ModsPluginState { installed: boolean; enabled: boolean; From ba34f4e0c8492f30f47ad213dde39af7c7b70462 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:07:29 +0200 Subject: [PATCH 023/251] feat(notifications): GenesisKit NotificationEngine is the one door to Notification Center: quiet rules, categories added as a union, the add, a replacement sweep by key and the ledger line; HangWatch, FocusChime, Clicky and GenesisTools.app's notify.post post through it, and postNotification no longer records the app backend twice --- .../GenesisKit/Clicky/ClickyModel.swift | 12 +- .../Focus/Pomodoro/FocusChime.swift | 26 +- .../Notifications/NotificationEngine.swift | 322 ++++++++++++++++++ .../GenesisKitBase/Perf/HangWatch.swift | 26 +- .../NotificationEngineTests.swift | 128 +++++++ .../GenesisTools/Sources/Host/Notify.swift | 108 +++--- src/utils/macos/notifications.ts | 7 + 7 files changed, 524 insertions(+), 105 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift create mode 100644 src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift index 448ec64222..1bcce74733 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift @@ -609,20 +609,14 @@ public final class ClickyModel: ObservableObject { private func notify(_ title: String, body: String) { guard preferences.notifications, !previewOnly else { return } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - UNUserNotificationCenter.current().add( - UNNotificationRequest( - identifier: "clicky.status", content: content, - trigger: nil) - ) { [weak self] error in + NotificationEngine.shared.post(EngineNotification( + source: "clicky", title: title, body: body, identifier: "clicky.status" + )) { [weak self] error in if let error { Task { @MainActor in self?.log.error("Notification failed: \(error.localizedDescription, privacy: .public)") } } } - NotificationLedger.record(source: "clicky", channel: "macos", title: title, body: body) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift index ec2d3f0e78..9e41d9f3e4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift @@ -75,24 +75,20 @@ public enum FocusChime { // Genesis.app, and `UNUserNotificationCenter.current()` does not fail there, it raises // and takes the test process down with it. guard FlowFocusHost.shared.notificationsEnabled else { return } - let content = UNMutableNotificationContent() - content.title = "\(finished.label) finished" - content.body = autoStarted - ? "\(next.label) has started." - : "\(next.label) is ready when you are." - content.sound = nil // the chime already played; two sounds for one event is noise - content.categoryIdentifier = "focus.phase" - content.userInfo = ["kind": notificationKind] - - let request = UNNotificationRequest( - identifier: "focus.phase.\(Int(Date().timeIntervalSince1970))", - content: content, - trigger: nil) - UNUserNotificationCenter.current().add(request) { error in + // One phase card at a time: the next phase replaces the last one instead of stacking under it. + NotificationEngine.shared.post(EngineNotification( + source: "focus", + title: "\(finished.label) finished", + body: autoStarted ? "\(next.label) has started." : "\(next.label) is ready when you are.", + idPrefix: "focus.phase", + replaceKey: "focus.phase", + category: "focus.phase", + userInfo: ["kind": notificationKind], + sound: nil // the chime already played; two sounds for one event is noise + )) { error in if let error { FlowFocusLog.focus.debug("focus phase notification failed: \(error.localizedDescription)") } } - NotificationLedger.record(source: "focus", channel: "macos", title: content.title, body: content.body) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift new file mode 100644 index 0000000000..e56b0d41ed --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift @@ -0,0 +1,322 @@ +import Foundation +import UserNotifications + +/// One macOS notification to post through `NotificationEngine`. +public struct EngineNotification { + /// Ledger source: `monitor.cache`, `disk`, `hang`, `tools`, ... Quiet rules match on it too. + public var source: String + public var title: String + public var subtitle: String + public var body: String + /// A fixed id replaces the card with that id in place. nil posts a fresh `.`. + public var identifier: String? + public var idPrefix: String + /// After the post, older delivered cards with the same key are removed, so one countdown or one + /// alert leaves one card. A fresh id plus a sweep, because re-using an id lets macOS update the old + /// card silently with no banner. + public var replaceKey: String? + public var category: String? + public var threadId: String? + public var userInfo: [String: Any] + public var sound: UNNotificationSound? + public var interruption: UNNotificationInterruptionLevel? + public var attachments: [UNNotificationAttachment] + /// Buttons for this card only. They are registered as a category named after the card's id, in a + /// union with every category already registered. + public var actions: [UNNotificationAction] + /// Extra ledger fields: the session behind a banner, the alert key, ... + public var ledgerFields: [String: Any] + + public init( + source: String, + title: String, + subtitle: String = "", + body: String, + identifier: String? = nil, + idPrefix: String = "genesis", + replaceKey: String? = nil, + category: String? = nil, + threadId: String? = nil, + userInfo: [String: Any] = [:], + sound: UNNotificationSound? = .default, + interruption: UNNotificationInterruptionLevel? = nil, + attachments: [UNNotificationAttachment] = [], + actions: [UNNotificationAction] = [], + ledgerFields: [String: Any] = [:] + ) { + self.source = source + self.title = title + self.subtitle = subtitle + self.body = body + self.identifier = identifier + self.idPrefix = idPrefix + self.replaceKey = replaceKey + self.category = category + self.threadId = threadId + self.userInfo = userInfo + self.sound = sound + self.interruption = interruption + self.attachments = attachments + self.actions = actions + self.ledgerFields = ledgerFields + } +} + +public enum NotificationPostResult: Equatable, Sendable { + case posted(id: String) + case suppressed(reason: String) + /// No notification center in this process (`swift test`, a raw binary outside its `.app`). + case unavailable +} + +/// A delivered card, as much of it as the engine needs. +public struct DeliveredNotification { + public var identifier: String + public var category: String + public var threadId: String + public var userInfo: [AnyHashable: Any] + + public init(identifier: String, category: String, threadId: String, userInfo: [AnyHashable: Any]) { + self.identifier = identifier + self.category = category + self.threadId = threadId + self.userInfo = userInfo + } +} + +/// The part of `UNUserNotificationCenter` the engine uses. Tests pass a recorder. +public protocol NotificationCenterPort: AnyObject { + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) + func removeDelivered(identifiers: [String]) + func categories(_ completion: @escaping (Set) -> Void) + func setCategories(_ categories: Set) +} + +final class SystemNotificationCenter: NotificationCenterPort { + static let shared = SystemNotificationCenter() + private var center: UNUserNotificationCenter { UNUserNotificationCenter.current() } + + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) { + center.add(request, withCompletionHandler: completion) + } + + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) { + center.getDeliveredNotifications { notes in + completion(notes.map { + DeliveredNotification(identifier: $0.request.identifier, category: $0.request.content.categoryIdentifier, + threadId: $0.request.content.threadIdentifier, userInfo: $0.request.content.userInfo) + }) + } + } + + func removeDelivered(identifiers: [String]) { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + + func categories(_ completion: @escaping (Set) -> Void) { + center.getNotificationCategories(completionHandler: completion) + } + + func setCategories(_ categories: Set) { + center.setNotificationCategories(categories) + } +} + +/// The one door to macOS Notification Center for Genesis.app, GenesisTools.app and GenesisKit (Martin, +/// 2026-10-23: "one GenesisKit NotificationEngine"). Every post goes through the same steps: quiet rules, +/// category registration as a union, the add, the replacement sweep, and a line in `NotificationLedger`. +/// Before it, four posting paths each did their own subset (two kept a ledger of nothing, one replaced +/// the whole category set, three had no replacement). +public final class NotificationEngine: @unchecked Sendable { + public static let shared = NotificationEngine() + + /// The userInfo key the replacement sweep reads. Posters keep their own keys beside it. + public static let replaceKeyInfoKey = "genesis.replaceKey" + + /// nil when this process must not touch Notification Center: `UNUserNotificationCenter.current()` + /// aborts outside a real `.app` (raw `swift run`, `swift test`). + public var centerProvider: () -> NotificationCenterPort? + /// Where ledger lines go; nil is the shared ledger. Tests point it at a temp file. + public var ledgerFile: String? + + private let lock = NSLock() + private var rules: [(name: String, rule: (EngineNotification) -> String?)] = [] + private var registered: [String: UNNotificationCategory] = [:] + + public init(centerProvider: @escaping () -> NotificationCenterPort? = NotificationEngine.systemCenterIfAvailable, + ledgerFile: String? = nil) { + self.centerProvider = centerProvider + self.ledgerFile = ledgerFile + } + + public static func systemCenterIfAvailable() -> NotificationCenterPort? { + guard NSClassFromString("XCTestCase") == nil, + Bundle.main.bundleIdentifier?.isEmpty == false, + Bundle.main.bundlePath.hasSuffix(".app") + else { return nil } + return SystemNotificationCenter.shared + } + + // MARK: - Quiet rules + + /// A rule returns a reason to hold the notification back, or nil. Rules run in the order added; the + /// first reason wins and is written to the ledger as the suppression reason. + public func addQuietRule(_ name: String, _ rule: @escaping (EngineNotification) -> String?) { + lock.lock() + defer { lock.unlock() } + rules.removeAll { $0.name == name } + rules.append((name, rule)) + } + + public func removeQuietRule(_ name: String) { + lock.lock() + defer { lock.unlock() } + rules.removeAll { $0.name == name } + } + + func suppression(for note: EngineNotification) -> String? { + lock.lock() + let current = rules + lock.unlock() + for entry in current { + if let reason = entry.rule(note) { return "\(entry.name): \(reason)" } + } + return nil + } + + // MARK: - Categories + + /// Adds categories to whatever is registered, never replacing the set: `setNotificationCategories` + /// replaces it whole, so one feature registering `[mine]` used to wipe every other card's buttons. + public func register(categories: [UNNotificationCategory], then: (() -> Void)? = nil) { + guard let center = centerProvider() else { + then?() + return + } + lock.lock() + for category in categories { registered[category.identifier] = category } + lock.unlock() + center.categories { [weak self] existing in + guard let self else { return } + self.lock.lock() + let mine = self.registered + self.lock.unlock() + var next = existing.filter { mine[$0.identifier] == nil } + next.formUnion(mine.values) + center.setCategories(next) + then?() + } + } + + // MARK: - Posting + + /// `completion` runs once macOS answered the add, with its error. It does not run for a suppressed or + /// unavailable result: nothing was added, and the return value already says so. + @discardableResult + public func post(_ note: EngineNotification, completion: ((Error?) -> Void)? = nil) -> NotificationPostResult { + if let reason = suppression(for: note) { + record(note, outcome: .suppressed, reason: reason, id: nil) + return .suppressed(reason: reason) + } + guard let center = centerProvider() else { return .unavailable } + let identifier = note.identifier ?? "\(note.idPrefix).\(UUID().uuidString)" + let request = UNNotificationRequest(identifier: identifier, content: content(note, identifier: identifier), + trigger: nil) + let add = { [weak self] in + center.add(request) { error in + if let error { + self?.record(note, outcome: .failed, reason: error.localizedDescription, id: identifier) + } else if let key = note.replaceKey { + self?.sweep(center: center, replaceKey: key, keep: identifier) + } + completion?(error) + } + } + record(note, outcome: .posted, reason: nil, id: identifier) + if note.actions.isEmpty { + add() + } else { + register(categories: [UNNotificationCategory(identifier: identifier, actions: note.actions, + intentIdentifiers: [], options: [])], then: add) + } + return .posted(id: identifier) + } + + func content(_ note: EngineNotification, identifier: String) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.title = note.title + if !note.subtitle.isEmpty { content.subtitle = note.subtitle } + content.body = note.body + content.sound = note.sound + if let interruption = note.interruption { content.interruptionLevel = interruption } + if !note.actions.isEmpty { + content.categoryIdentifier = identifier + } else if let category = note.category { + content.categoryIdentifier = category + } + if let thread = note.threadId ?? note.replaceKey { content.threadIdentifier = thread } + var info = note.userInfo + if let key = note.replaceKey { info[Self.replaceKeyInfoKey] = key } + content.userInfo = info + content.attachments = note.attachments + return content + } + + /// Older cards with the same replace key, never the one just posted (the delivered list is read + /// asynchronously and may already hold it). + static func stale(_ delivered: [DeliveredNotification], replaceKey: String, keep: String) -> [String] { + delivered + .filter { $0.identifier != keep && $0.userInfo[replaceKeyInfoKey] as? String == replaceKey } + .map(\.identifier) + } + + private func sweep(center: NotificationCenterPort, replaceKey: String, keep: String) { + center.delivered { delivered in + let stale = Self.stale(delivered, replaceKey: replaceKey, keep: keep) + if !stale.isEmpty { center.removeDelivered(identifiers: stale) } + } + } + + // MARK: - Withdrawing and counting + + /// Removes delivered cards that match, and answers with their ids. + public func withdraw(where match: @escaping (DeliveredNotification) -> Bool, + completion: (([String]) -> Void)? = nil) { + guard let center = centerProvider() else { + completion?([]) + return + } + center.delivered { delivered in + let ids = delivered.filter(match).map(\.identifier) + if !ids.isEmpty { center.removeDelivered(identifiers: ids) } + completion?(ids) + } + } + + public func withdraw(replaceKey: String, completion: (([String]) -> Void)? = nil) { + withdraw(where: { $0.userInfo[Self.replaceKeyInfoKey] as? String == replaceKey }, completion: completion) + } + + /// Delivered cards that match, without removing them. + public func delivered(where match: @escaping (DeliveredNotification) -> Bool, + completion: @escaping ([String]) -> Void) { + guard let center = centerProvider() else { + completion([]) + return + } + center.delivered { completion($0.filter(match).map(\.identifier)) } + } + + // MARK: - Ledger + + private func record(_ note: EngineNotification, outcome: NotificationLedger.Outcome, reason: String?, id: String?) { + var fields = note.ledgerFields + if let id { fields["id"] = id } + if let key = note.replaceKey { fields["replaceKey"] = key } + if let category = note.category { fields["category"] = category } + NotificationLedger.record(source: note.source, channel: "macos", outcome: outcome, title: note.title, + subtitle: note.subtitle.isEmpty ? nil : note.subtitle, body: note.body, + reason: reason, fields: fields, file: ledgerFile) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift index 81ed3eb68c..a3458f996d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift @@ -195,20 +195,18 @@ public enum HangWatch { // the test runner has none and no notification center to abort in. let config = PerfConfiguration.current guard let prefix = config.alertBundlePrefix, Bundle.main.bundleIdentifier?.hasPrefix(prefix) == true else { return } - let content = UNMutableNotificationContent() - content.title = "\(config.appName) is not responding" - content.body = String( - format: "The main thread has been blocked for %.0f seconds. %@", - stallSoFar, - lastCaptureFile.map { "Stack: \(($0.path as NSString).abbreviatingWithTildeInPath)" } - ?? "No stack was captured." - ) - content.sound = .default - UNUserNotificationCenter.current().add( - UNNotificationRequest(identifier: "genesis.hang.\(UUID().uuidString)", content: content, trigger: nil) - ) - NotificationLedger.record(source: "hang", channel: "macos", title: content.title, body: content.body, - fields: ["stallSeconds": Int(stallSoFar)]) + NotificationEngine.shared.post(EngineNotification( + source: "hang", + title: "\(config.appName) is not responding", + body: String( + format: "The main thread has been blocked for %.0f seconds. %@", + stallSoFar, + lastCaptureFile.map { "Stack: \(($0.path as NSString).abbreviatingWithTildeInPath)" } + ?? "No stack was captured." + ), + idPrefix: "genesis.hang", + ledgerFields: ["stallSeconds": Int(stallSoFar)] + )) } /// Test seam: the reporter without a 20 s real stall in front of it. diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift new file mode 100644 index 0000000000..d6536dd56f --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift @@ -0,0 +1,128 @@ +import UserNotifications +import XCTest +@testable import GenesisKit +@testable import GenesisKitBase + +private final class FakeCenter: NotificationCenterPort { + var added: [UNNotificationRequest] = [] + var removed: [String] = [] + var categorySet: Set = [] + var deliveredNotes: [DeliveredNotification] = [] + var failNextAdd = false + + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) { + if failNextAdd { + failNextAdd = false + completion(NSError(domain: "fake", code: 1, userInfo: [NSLocalizedDescriptionKey: "denied"])) + return + } + added.append(request) + deliveredNotes.append(DeliveredNotification(identifier: request.identifier, + category: request.content.categoryIdentifier, + threadId: request.content.threadIdentifier, + userInfo: request.content.userInfo)) + completion(nil) + } + + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) { completion(deliveredNotes) } + + func removeDelivered(identifiers: [String]) { + removed += identifiers + deliveredNotes.removeAll { identifiers.contains($0.identifier) } + } + + func categories(_ completion: @escaping (Set) -> Void) { completion(categorySet) } + func setCategories(_ categories: Set) { categorySet = categories } +} + +final class NotificationEngineTests: XCTestCase { + private var center: FakeCenter! + private var engine: NotificationEngine! + private var ledger: String! + + override func setUpWithError() throws { + center = FakeCenter() + ledger = FileManager.default.temporaryDirectory.appendingPathComponent("engine-ledger-\(UUID()).jsonl").path + let fake = center! + engine = NotificationEngine(centerProvider: { fake }, ledgerFile: ledger) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(atPath: ledger) + } + + private func ledgerLines() throws -> [[String: Any]] { + NotificationLedger.flush() + guard FileManager.default.fileExists(atPath: ledger) else { return [] } + return try String(contentsOfFile: ledger, encoding: .utf8).split(separator: "\n") + .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + } + + func testAReplaceKeyLeavesOneCardPerKey() throws { + let first = engine.post(EngineNotification(source: "monitor.cache", title: "10 min left", body: "s1", + replaceKey: "s1")) + engine.post(EngineNotification(source: "monitor.cache", title: "other session", body: "s2", replaceKey: "s2")) + let third = engine.post(EngineNotification(source: "monitor.cache", title: "5 min left", body: "s1", + replaceKey: "s1")) + guard case .posted(let firstId) = first, case .posted(let thirdId) = third else { return XCTFail("posted") } + XCTAssertEqual(center.removed, [firstId], "only the older card of the same key goes") + XCTAssertEqual(Set(center.deliveredNotes.map(\.identifier)).contains(thirdId), true) + XCTAssertEqual(center.deliveredNotes.count, 2) + XCTAssertEqual(center.added.last?.content.threadIdentifier, "s1", "the key threads the cards") + } + + func testAQuietRuleHoldsTheCardBackAndTheLedgerSaysWhy() throws { + engine.addQuietRule("focus") { $0.source == "server.notify" ? "focus session active" : nil } + XCTAssertEqual(engine.post(EngineNotification(source: "server.notify", title: "t", body: "b")), + .suppressed(reason: "focus: focus session active")) + if case .posted = engine.post(EngineNotification(source: "disk", title: "t", body: "b")) {} else { + XCTFail("another source still posts") + } + XCTAssertEqual(center.added.count, 1) + let lines = try ledgerLines() + XCTAssertEqual(lines.map { $0["outcome"] as? String }, ["suppressed", "posted"]) + XCTAssertEqual(lines[0]["reason"] as? String, "focus: focus session active") + } + + func testCategoriesAreAddedToTheRegisteredSetNeverReplacingIt() { + center.categorySet = [UNNotificationCategory(identifier: "someone.else", actions: [], intentIdentifiers: [])] + engine.register(categories: [UNNotificationCategory(identifier: "genesis.disk", actions: [], intentIdentifiers: [])]) + engine.post(EngineNotification(source: "tools", title: "t", body: "b", identifier: "card-1", + actions: [UNNotificationAction(identifier: "ok", title: "OK")])) + XCTAssertEqual(Set(center.categorySet.map(\.identifier)), ["someone.else", "genesis.disk", "card-1"]) + XCTAssertEqual(center.added.first?.content.categoryIdentifier, "card-1", "ad-hoc buttons use the card's id") + } + + func testAFailedAddIsRecordedAsFailed() throws { + center.failNextAdd = true + let failed = expectation(description: "completion") + engine.post(EngineNotification(source: "hang", title: "t", body: "b", ledgerFields: ["stallSeconds": 21])) { + XCTAssertNotNil($0) + failed.fulfill() + } + wait(for: [failed], timeout: 1) + let lines = try ledgerLines() + XCTAssertEqual(lines.map { $0["outcome"] as? String }, ["posted", "failed"]) + XCTAssertEqual(lines[1]["reason"] as? String, "denied") + XCTAssertEqual(lines[0]["stallSeconds"] as? Int, 21) + } + + func testWithdrawByKeyAndCountByCategory() { + engine.post(EngineNotification(source: "disk", title: "full", body: "b", replaceKey: "disk.full", + category: "genesis.disk")) + engine.post(EngineNotification(source: "monitor.cache", title: "t", body: "b", category: "genesis.monitor.session")) + var counted: [String] = [] + engine.delivered(where: { $0.category == "genesis.monitor.session" }) { counted = $0 } + XCTAssertEqual(counted.count, 1) + var withdrawn: [String] = [] + engine.withdraw(replaceKey: "disk.full") { withdrawn = $0 } + XCTAssertEqual(withdrawn.count, 1) + XCTAssertEqual(center.deliveredNotes.map(\.category), ["genesis.monitor.session"]) + } + + func testNoCenterMeansNothingPostedAndNothingRecorded() throws { + let quiet = NotificationEngine(centerProvider: { nil }, ledgerFile: ledger) + XCTAssertEqual(quiet.post(EngineNotification(source: "x", title: "t", body: "b")), .unavailable) + XCTAssertTrue(try ledgerLines().isEmpty) + } +} diff --git a/src/macos/GenesisTools/Sources/Host/Notify.swift b/src/macos/GenesisTools/Sources/Host/Notify.swift index 8899afb680..fcf665df9c 100644 --- a/src/macos/GenesisTools/Sources/Host/Notify.swift +++ b/src/macos/GenesisTools/Sources/Host/Notify.swift @@ -17,6 +17,7 @@ import AppKit import Foundation +import GenesisKitBase import UserNotifications // MARK: - Request shapes @@ -130,6 +131,8 @@ func emitError(code: String, message: String, exitCode: Int32) -> Never { } private func emit(_ payload: [String: Any]) { + // Every RPC exits right after this; the notification ledger writes on its own queue. + NotificationLedger.flush() guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let line = String(data: data, encoding: .utf8) @@ -155,25 +158,10 @@ private func writeReply(_ text: String) { /// `sound` defaults to the system default, matching the DarwinKit and osascript backends this /// replaces. terminal-notifier stays silent unless asked, so a caller that relied on that gets a /// sound it did not before; that is the one deliberate behaviour change here. -func notificationContent(_ params: NotifyPostParams, identifier: String) -> UNMutableNotificationContent { - let content = UNMutableNotificationContent() - content.title = params.title ?? "GenesisTools" - content.body = params.message - - if let subtitle = params.subtitle, !subtitle.isEmpty { - content.subtitle = subtitle - } - - if let sound = params.sound, !sound.isEmpty, sound.lowercased() != "default" { - content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) - } else { - content.sound = .default - } - - if let group = params.group, !group.isEmpty { - content.threadIdentifier = group - } - +/// The banner as GenesisKit's `NotificationEngine` posts it. The engine registers the buttons as a +/// category named after the id, in a union with every category already registered, and writes the +/// notification ledger line (`source` is the poster's group, as the TypeScript side names it). +func engineNotification(_ params: NotifyPostParams, identifier: String) -> EngineNotification { // Keyed by the action that should run it. The body click is "default"; a button click uses the // button's own id. Reading it back needs no knowledge of the category that rendered the buttons. var routes: [String: [String: String]] = [:] @@ -193,30 +181,32 @@ func notificationContent(_ params: NotifyPostParams, identifier: String) -> UNMu userInfo["routes"] = filteredRoutes } - content.userInfo = userInfo - - // Best effort only. Punching through a Focus mode needs the time-sensitive entitlement, which a - // Developer ID signature cannot carry, so this degrades to a normal banner rather than failing. - if params.ignoreDnD == true { - content.interruptionLevel = .timeSensitive + let sound: UNNotificationSound = if let name = params.sound, !name.isEmpty, name.lowercased() != "default" { + UNNotificationSound(named: UNNotificationSoundName(name)) + } else { + .default } // appIcon first, so it is the thumbnail on the banner. UNUserNotificationCenter has no way to // replace the app icon itself (terminal-notifier's -appIcon meaning), so the closest honest // mapping is to show the image as the notification's own artwork. let files = [params.appIcon].compactMap { $0 } + (params.attachments ?? []) - content.attachments = files.enumerated().compactMap { index, path in - buildAttachment(path: path, index: index) - } - - if let actions = params.actions, !actions.isEmpty { - // Category registration happens in `post()`, as a union with whatever is already - // registered. `setNotificationCategories` replaces the entire set, so doing it here - // with `[one]` would wipe every other notification's buttons. - content.categoryIdentifier = identifier - } - return content + return EngineNotification( + source: params.group.flatMap { $0.isEmpty ? nil : $0 } ?? "tools", + title: params.title ?? "GenesisTools", + subtitle: params.subtitle ?? "", + body: params.message, + identifier: identifier, + threadId: params.group.flatMap { $0.isEmpty ? nil : $0 }, + userInfo: userInfo, + sound: sound, + // Best effort only. Punching through a Focus mode needs the time-sensitive entitlement, which a + // Developer ID signature cannot carry, so this degrades to a normal banner rather than failing. + interruption: params.ignoreDnD == true ? .timeSensitive : nil, + attachments: files.enumerated().compactMap { index, path in buildAttachment(path: path, index: index) }, + actions: (params.actions ?? []).map(buildAction) + ) } private func route(open: String?, execute: String?) -> [String: String] { @@ -562,41 +552,25 @@ private func post(_ params: NotifyPostParams) { exitCode: 77 ) default: - let request = UNNotificationRequest( - identifier: identifier, - content: notificationContent(params, identifier: identifier), - trigger: nil - ) - let add = { - center.add(request) { addError in - if let addError { - emitError( - code: "internal", - message: "could not post: \(addError.localizedDescription)", - exitCode: 70 - ) - } - - emitResult(["id": identifier]) + let result = NotificationEngine.shared.post(engineNotification(params, identifier: identifier)) { addError in + if let addError { + emitError( + code: "internal", + message: "could not post: \(addError.localizedDescription)", + exitCode: 70 + ) } - } - guard let actions = params.actions, !actions.isEmpty else { - add() - return + emitResult(["id": identifier]) } - let category = UNNotificationCategory( - identifier: identifier, - actions: actions.map(buildAction), - intentIdentifiers: [], - options: [] - ) - center.getNotificationCategories { existing in - var next = existing.filter { $0.identifier != identifier } - next.insert(category) - center.setNotificationCategories(next) - add() + switch result { + case .posted: + break + case .suppressed(let reason): + emitResult(["id": identifier, "suppressed": reason]) + case .unavailable: + emitError(code: "internal", message: "no notification center in this process", exitCode: 70) } } } diff --git a/src/utils/macos/notifications.ts b/src/utils/macos/notifications.ts index 4203ac4c07..d5e72b5894 100644 --- a/src/utils/macos/notifications.ts +++ b/src/utils/macos/notifications.ts @@ -444,6 +444,13 @@ export interface PostedNotification { */ export async function postNotification(opts: NotificationOptions): Promise { const posted = await postThroughChain(opts); + + // GenesisTools.app posts through GenesisKit's NotificationEngine, which records the line itself with + // the real outcome of the add. Recording here too would count every banner twice. + if (posted.backend === NotificationBackend.GenesisApp) { + return posted; + } + recordNotification({ source: opts.group ?? "tools", channel: "macos", From 93fcb07b9e8027875914f8df31f1a98c7edb57d2 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:27:48 +0200 Subject: [PATCH 024/251] feat(ai, notifications): session rows carry alive, read at serve time from the ~/.claude/sessions registry with a signal-0 probe (0.4 ms against 213 ms with ps, no change to the 265 ms listing), so the monitor can skip banners for sessions whose process exited; the ledger gains a withdrawn outcome --- src/ai/lib/sessions/agent-session-rows.ts | 2 + src/ai/lib/sessions/rows-cache.test.ts | 29 +++++++++++ src/ai/lib/sessions/rows-cache.ts | 34 ++++++++++++- src/claude/lib/usage/session-rows.test.ts | 1 + src/claude/lib/usage/session-rows.ts | 13 ++++- .../Notifications/NotificationLedger.swift | 4 +- src/utils/claude/peer-message.test.ts | 36 +++++++++++++ src/utils/claude/peer-message.ts | 50 +++++++++++++++++++ src/utils/notifications/ledger.ts | 4 +- 9 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/utils/claude/peer-message.test.ts diff --git a/src/ai/lib/sessions/agent-session-rows.ts b/src/ai/lib/sessions/agent-session-rows.ts index 08afa723ac..2eb8a02299 100644 --- a/src/ai/lib/sessions/agent-session-rows.ts +++ b/src/ai/lib/sessions/agent-session-rows.ts @@ -74,6 +74,8 @@ export interface AgentSessionRow { lastUserAt?: number | null; modelSwitched?: boolean; cmux?: SessionCmuxLocation | null; + /** Claude only: whether a claude process still runs it (registry); null when the registry is unreadable. */ + alive?: boolean | null; } export interface AgentSessionRowsOptions { diff --git a/src/ai/lib/sessions/rows-cache.test.ts b/src/ai/lib/sessions/rows-cache.test.ts index d3c5630c16..e1e2248dcd 100644 --- a/src/ai/lib/sessions/rows-cache.test.ts +++ b/src/ai/lib/sessions/rows-cache.test.ts @@ -12,6 +12,7 @@ import { type SessionRowsCache, sessionRowsCacheKey, sessionRowsJson, + withLiveness, writeSessionRowsCache, } from "./rows-cache"; @@ -313,3 +314,31 @@ describe("refreshSessionRowsCache", () => { expect(result.reason).toBe("nobody has asked for an hour"); }); }); + +describe("withLiveness", () => { + const row = (provider: AgentSessionRow["provider"], sessionId: string): AgentSessionRow => ({ + provider, + sessionId, + title: null, + cwd: "/x", + cwdShort: "/x", + project: null, + mtime: 0, + model: null, + account: null, + filePath: `/x/${sessionId}.jsonl`, + }); + + test("marks Claude rows from the registry, case-insensitively, and leaves other providers alone", () => { + const rows = withLiveness( + [row("claude", "AAAA"), row("claude", "bbbb"), row("codex", "cccc")], + new Set(["aaaa"]) + ); + expect(rows.map((r) => r.alive)).toEqual([true, false, undefined]); + }); + + test("an unreadable registry changes nothing: unknown is not dead", () => { + const rows = [row("claude", "aaaa")]; + expect(withLiveness(rows, null)).toBe(rows); + }); +}); diff --git a/src/ai/lib/sessions/rows-cache.ts b/src/ai/lib/sessions/rows-cache.ts index 1a1071fddd..85cf10088f 100644 --- a/src/ai/lib/sessions/rows-cache.ts +++ b/src/ai/lib/sessions/rows-cache.ts @@ -3,6 +3,7 @@ import { dirname } from "node:path"; import type { AgentSessionRow, AgentSessionRowsOptions } from "@app/ai/lib/sessions/agent-session-rows"; import { computeCacheStatus } from "@app/claude/lib/usage/cache-clock"; import { aiDataDir } from "@genesiscz/utils/ai/config/paths"; +import { liveClaudeSessionIds } from "@genesiscz/utils/claude/peer-message"; import { type TmuxCmuxLocation, tmuxCmuxLocations } from "@genesiscz/utils/cmux/tmux-host"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -171,6 +172,26 @@ async function withTmuxLocations( }); } +/** + * Claude rows with `alive` read now from the session registry, never from the cached file: a cached answer may + * be 90 s old, and a session that exited in that time would still read as running. Other providers have no + * registry and keep what they had. `live` null (registry unreadable) changes nothing: unknown is not dead. + */ +export function withLiveness(rows: AgentSessionRow[], live: Set | null): AgentSessionRow[] { + if (!live) { + return rows; + } + + return rows.map((row) => { + if (row.provider !== "claude") { + return row; + } + + const alive = live.has(row.sessionId.toLowerCase()); + return row.alive === alive ? row : { ...row, alive }; + }); +} + /** * Rows with each prompt-cache clock (`cacheStatus`, `cacheTtlSec`) worked out for `now` from `lastCacheAt` and * `cacheLifetimeSec`, which are facts of the transcript. A cached answer may be 90 s old, and its clocks used to @@ -208,6 +229,7 @@ export async function sessionRowsJson( path = sessionRowsCachePath(), listRows, tmuxLocations = isTestProcess() ? async () => new Map() : () => tmuxCmuxLocations(), + liveSessionIds = isTestProcess() ? () => null : () => liveClaudeSessionIds(), }: { fresh?: boolean; cacheWrite?: boolean; @@ -220,6 +242,8 @@ export async function sessionRowsJson( listRows: (options: AgentSessionRowsOptions) => Promise; /** Live cmux tabs of tmux-hosted sessions. A test that injects none gets none. */ tmuxLocations?: () => Promise>; + /** Running Claude session ids (`liveClaudeSessionIds`); a test that injects none gets `alive: null`. */ + liveSessionIds?: () => Set | null; } ): Promise<{ fetchedAt: number; cached: boolean; rows: AgentSessionRow[] }> { if (!fresh) { @@ -242,7 +266,9 @@ export async function sessionRowsJson( return { fetchedAt: cached.fetchedAt, cached: true, - rows: liveLocations ? await withTmuxLocations(rows, tmuxLocations) : rows, + rows: liveLocations + ? withLiveness(await withTmuxLocations(rows, tmuxLocations), liveSessionIds()) + : rows, }; } } @@ -253,5 +279,9 @@ export async function sessionRowsJson( await writeSessionRowsCache({ query: listing, fetchedAt: now, lastRequestedAt: now, rows }, path); } - return { fetchedAt: now, cached: false, rows: liveLocations ? await withTmuxLocations(rows, tmuxLocations) : rows }; + return { + fetchedAt: now, + cached: false, + rows: liveLocations ? withLiveness(await withTmuxLocations(rows, tmuxLocations), liveSessionIds()) : rows, + }; } diff --git a/src/claude/lib/usage/session-rows.test.ts b/src/claude/lib/usage/session-rows.test.ts index 11f095baff..584fe52bce 100644 --- a/src/claude/lib/usage/session-rows.test.ts +++ b/src/claude/lib/usage/session-rows.test.ts @@ -278,6 +278,7 @@ describe("listSessionRows", () => { expect(Object.keys(hot ?? {}).sort()).toEqual( [ "account", + "alive", "cacheCreateTokens", "cacheReadTokens", "cacheLifetimeSec", diff --git a/src/claude/lib/usage/session-rows.ts b/src/claude/lib/usage/session-rows.ts index 1098fb1677..76f3a9985b 100644 --- a/src/claude/lib/usage/session-rows.ts +++ b/src/claude/lib/usage/session-rows.ts @@ -1,6 +1,7 @@ import { cleanPromptText } from "@app/claude/lib/cmux/sessions"; import { getSessionListing, type SessionMetadataRecord } from "@app/claude/lib/history/search"; import { loadPins } from "@genesiscz/utils/agent-sessions/pins"; +import { liveClaudeSessionIds } from "@genesiscz/utils/claude/peer-message"; import { readTailBytes } from "@genesiscz/utils/claude/session.utils"; import { loadAllSessionCmuxRefs } from "@genesiscz/utils/cmux/session-refs"; import { FileIdentityCache, fileIdentity } from "@genesiscz/utils/fs/identity-cache"; @@ -76,6 +77,12 @@ export interface SessionRow { account: string | null; /** Recorded cmux location from the refs journal — where the session started, unverified. */ cmux: SessionCmuxLocation | null; + /** + * Whether a claude process still runs this session (`~/.claude/sessions` registry). null when the + * registry could not be read. The Genesis monitor skips cache banners for `false`: a session whose + * process exited cannot be resumed from its pane, and those were 80 of 203 banners in 40 h (2026-10-23). + */ + alive: boolean | null; filePath: string; } @@ -124,6 +131,8 @@ export interface ListSessionRowsOptions { maxDiscoveryAgeMs?: number; /** Passed to `getSessionListing`: `false` reads the index with no refresh. */ refresh?: boolean; + /** Which session ids have a running claude process; tests inject it. Defaults to the registry. */ + liveSessionIds?: () => Set | null; } function simplifyModel(model: string): string { @@ -396,6 +405,7 @@ export async function extractTailUsageUncached(filePath: string): Promise<{ usag interface RowJoins { accounts: Map; cmux: Map; + live: Set | null; } function buildRow(record: SessionMetadataRecord, usage: TailUsage, now: number, joins: RowJoins): SessionRow { @@ -431,6 +441,7 @@ function buildRow(record: SessionMetadataRecord, usage: TailUsage, now: number, lastUserAt: usage.lastUserAt, account: joins.accounts.get(sessionId.toLowerCase()) ?? null, cmux: joins.cmux.get(sessionId.toLowerCase()) ?? null, + alive: joins.live ? joins.live.has(sessionId.toLowerCase()) : null, filePath: record.filePath, }; } @@ -527,7 +538,7 @@ export async function listSessionRowsWithTimings( }); } - return { accounts, cmux }; + return { accounts, cmux, live: (opts.liveSessionIds ?? liveClaudeSessionIds)() }; }); const joinMs = performance.now() - joinStarted; const rank: Record = { HOT: 0, COOLING: 1, CRITICAL: 2, COLD: 3 }; diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift index 4ad2bcf9b8..441f03e98c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift @@ -7,12 +7,14 @@ import Foundation /// banners were logged nowhere, and the macOS unified log kept about 14 hours of them without titles. /// /// Fields every line has: `at`, `source` (`monitor.cache`, `disk`, `hang`, ...), `channel` (`macos`, `in-app`), -/// `outcome` (`posted`, `failed`, `suppressed`), `writer`. A suppressed line names its `reason`. +/// `outcome` (`posted`, `failed`, `suppressed`, `withdrawn`), `writer`. A suppressed line names its `reason`. public enum NotificationLedger { public enum Outcome: String, Sendable { case posted case failed case suppressed + /// A delivered card was taken away on purpose (a countdown's card once its session went cold). + case withdrawn } public static var path: String { diff --git a/src/utils/claude/peer-message.test.ts b/src/utils/claude/peer-message.test.ts new file mode 100644 index 0000000000..c4ca3fe311 --- /dev/null +++ b/src/utils/claude/peer-message.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { liveClaudeSessionIds } from "./peer-message"; + +describe("liveClaudeSessionIds", () => { + function registry(entries: Record[]): string { + const dir = mkdtempSync(join(tmpdir(), "claude-sessions-")); + for (const entry of entries) { + writeFileSync(join(dir, `${entry.pid}.json`), SafeJSON.stringify(entry)); + } + writeFileSync(join(dir, "123.abc.key"), "not a registry file"); + return dir; + } + + it("keeps sessions whose pid runs, with or without a messaging socket", () => { + const dir = registry([ + { pid: 1, sessionId: "AAAA-live", messagingSocketPath: "/tmp/s" }, + { pid: 2, sessionId: "bbbb-nosocket" }, + { pid: 3, sessionId: "cccc-dead" }, + ]); + const ids = liveClaudeSessionIds(dir, () => new Set([1, 2])); + expect(ids).toEqual(new Set(["aaaa-live", "bbbb-nosocket"])); + }); + + it("drops a parked entry, whose id moved to a background job", () => { + const dir = registry([{ pid: 1, sessionId: "parent", parkedJobId: "child" }]); + expect(liveClaudeSessionIds(dir, () => new Set([1]))).toEqual(new Set()); + }); + + it("answers null, not empty, when the registry cannot be read", () => { + expect(liveClaudeSessionIds(join(tmpdir(), "no-such-registry-dir"), () => new Set())).toBeNull(); + }); +}); diff --git a/src/utils/claude/peer-message.ts b/src/utils/claude/peer-message.ts index 00773b70df..4da5895e70 100644 --- a/src/utils/claude/peer-message.ts +++ b/src/utils/claude/peer-message.ts @@ -120,6 +120,56 @@ export function parseRegistryEntry(raw: string, file: string): ClaudeLiveSession }; } +/** + * Ids (lower case) of every Claude session whose process is still running, from the registry alone: no + * messaging socket needed. A parked entry (`parkedJobId`, the conversation moved to a background job under a + * new id) does not count, because its id is frozen. null when the registry cannot be read, so a caller can + * tell "unknown" from "not running". + * + * Liveness is a signal-0 probe only, without `liveClaudePids`' batched `ps`: measured 2026-10-23 on 26 + * entries, 0.39 ms median against 213 ms, and this runs on every monitor poll. Claude deletes its entry on + * exit, so only a crash leaves one behind, and a recycled pid then reads as alive: the fail-open side. + */ +export function liveClaudeSessionIds( + dir: string = claudeSessionsDir(), + liveOf: (pids: number[]) => Set = (pids) => new Set(pids.filter((pid) => isProcessAlive(pid))) +): Set | null { + let names: string[]; + + try { + names = readdirSync(dir); + } catch (error) { + log.debug({ error, dir }, "no Claude session registry"); + return null; + } + + const byPid = new Map(); + + for (const name of names) { + if (!/^\d+\.json$/.test(name)) { + continue; + } + + try { + const parsed: unknown = SafeJSON.parse(readFileSync(join(dir, name), "utf8"), { strict: true }); + + if ( + isRecord(parsed) && + typeof parsed.pid === "number" && + text(parsed.sessionId) && + !text(parsed.parkedJobId) + ) { + byPid.set(parsed.pid, String(parsed.sessionId).toLowerCase()); + } + } catch (error) { + log.debug({ error, name }, "unreadable session registry file"); + } + } + + const live = liveOf([...byPid.keys()]); + return new Set([...byPid].filter(([pid]) => live.has(pid)).map(([, id]) => id)); +} + /** Every live Claude session that advertises a messaging socket. `liveOf` checks the whole inventory at once. */ export function listClaudeLiveSessions( dir: string = claudeSessionsDir(), diff --git a/src/utils/notifications/ledger.ts b/src/utils/notifications/ledger.ts index 5be13a04bc..df66438550 100644 --- a/src/utils/notifications/ledger.ts +++ b/src/utils/notifications/ledger.ts @@ -24,8 +24,8 @@ export interface NotificationLedgerEntry { source: string; /** Where it went: `macos`, `in-app`, `telegram`, `webhook`, `say`. */ channel: string; - /** `posted`, `failed`, or `suppressed` (a rule decided against it; `reason` says which). */ - outcome: "posted" | "failed" | "suppressed"; + /** `posted`, `failed`, `suppressed` (a rule decided against it; `reason` says which), or `withdrawn`. */ + outcome: "posted" | "failed" | "suppressed" | "withdrawn"; title?: string; subtitle?: string; body?: string; From 844bfd9f6c891c6e8bdf835ab50f061eb843c6e5 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:29:00 +0200 Subject: [PATCH 025/251] feat(GenesisKit, GenesisHub, widgets): bubbles answer the pointer, the hover list stops blinking, rows and the header carry their times and context, alerts get a sound per kind and flash the session that asked, Esc goes back to the list first, and opinionated behavior becomes a setting --- .claude/rules/opinionated-settings.md | 45 +++++++ .claude/skills/ux/SKILL.md | 24 ++++ .claude/skills/ux/references/checklist.md | 14 ++ .claude/skills/ux/references/findings.md | 14 ++ CLAUDE.md | 1 + src/hub/lib/widget/types.ts | 8 ++ .../GenesisKit/Controls/FittedScroll.swift | 14 +- .../Flow/Support/AttentionPulse.swift | 18 ++- .../Settings/NativeSettingsModel.swift | 2 +- .../GenesisKit/Widgets/LiveWidgetView.swift | 42 ++++-- .../Widgets/WidgetAgentPreview.swift | 86 +++++++++++- .../GenesisKit/Widgets/WidgetAlerts.swift | 13 ++ .../GenesisKit/Widgets/WidgetBubble.swift | 52 ++++++++ .../Widgets/WidgetCoordinator.swift | 7 +- .../Widgets/WidgetFeatureSettings.swift | 64 +++++++-- .../GenesisKit/Widgets/WidgetHostView.swift | 32 ++++- .../Widgets/WidgetInboxOverview.swift | 6 +- .../GenesisKit/Widgets/WidgetModel.swift | 124 +++++++++++++++++- .../Widgets/WidgetSessionBrowser.swift | 6 +- .../GenesisKit/Widgets/WidgetWireModels.swift | 8 ++ .../Window/EdgePanelController.swift | 3 + .../WidgetInboxTasksPolishTests.swift | 53 ++++++++ 22 files changed, 596 insertions(+), 40 deletions(-) create mode 100644 .claude/rules/opinionated-settings.md create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift diff --git a/.claude/rules/opinionated-settings.md b/.claude/rules/opinionated-settings.md new file mode 100644 index 0000000000..79863b6f40 --- /dev/null +++ b/.claude/rules/opinionated-settings.md @@ -0,0 +1,45 @@ +--- +paths: + - "src/macos/**/*.swift" + - "**/*.tsx" + - "src/*/ui/**" + - "src/dashboard/**" + - "src/dev-dashboard/**" + - "src/hub/lib/widget/**" + - "src/notify/**" +--- + +# Opinionated behavior gets a setting + +Loaded by Claude Code when you read, write or edit native or web UI code, the widget's wire schema, or notifications. + +Martin's rule (2026-10-23): when a UI change picks one way where a reasonable user could want another, the choice +is a setting with that way as its default. You decide the default and ship the setting in the same change; do not +ask first, and do not ship the behavior hard-coded. + +## Opinionated means + +- **Interruptions**: a sound, which sound per kind of event, a popup, a panel that opens or expands by itself, a + notification, a focus steal. +- **Attention cues**: a flash, a pulse, a badge, their length and their colour; "flash the thing that caused it" is + the expected default, and its duration is the setting. +- **Placement and order**: where a composer or toolbar sits (top or bottom), list order, which filter a list opens + with, what a list shows (only what needs you, or everything active). +- **Hover behavior**: a tooltip or a preview on hover, hover delays, whether hover opens anything at all. +- **Timings**: auto-collapse delays, how long a popup stays. + +Not opinionated: a bug fix, a layout that was wrong, a missing hover effect, accessibility labels, performance. +Those are fixed, not made optional. + +## How + +- One preference per choice, stored where its surface already stores preferences (the widget: the zod schema in + `src/hub/lib/widget/types.ts` plus `WidgetPreferences` in GenesisKit `WidgetWireModels.swift`; a native window: + its settings store). An enum over a boolean when a third way is plausible ("auto" | "always" | "never"). +- The control goes on the settings page of that surface, in the card the behavior belongs to, with a one-line detail + that says what it does. A sound picker has a play button. A list that has a filter in place gets a settings + shortcut that opens the page at its card (`#`, `.nativeSettingsAnchor`), which flashes the card's border. +- The default is your best judgment of what Martin asked for; the option you did not pick stays reachable. +- A shared component's look change keeps the old look as a named option (the `AttentionPulse` presets: the border + flash is the default, the filled flash stays as `.filledFlash`). +- Say in the reply which settings you added and their defaults. diff --git a/.claude/skills/ux/SKILL.md b/.claude/skills/ux/SKILL.md index 0c45b6aed0..dcc60cd6e1 100644 --- a/.claude/skills/ux/SKILL.md +++ b/.claude/skills/ux/SKILL.md @@ -26,6 +26,30 @@ thing for real, frame by frame, and comparing it with the checklist**, then fixi after sit next to each other. 6. **Write down what was wrong on the first try** in the ledger, generically, with the commit. +## What Martin asks for, before he has to (his reviews of 2026-10-23) + +Read this before a widget, hub or settings change, and do these without being asked. Each line is a request he had +to make by screenshot. + +- **Every clickable thing answers the pointer.** Round bubbles get a round hover ring, not a rectangular plate or + nothing. If he cannot tell a thing is clickable by hovering it, it is a defect. +- **Opinionated means configurable.** A sound, a popup, an auto-expand, a flash, a tooltip, a placement, an order: + ship a setting with your pick as its default (`.claude/rules/opinionated-settings.md`), and say which you added. +- **Say why something moved.** When the widget chimes or opens by itself, the session that caused it flashes + (its bubble, its row, its popup) for a configurable few seconds. A cue without its cause is noise. +- **Rows carry their times.** A session row says when it was last active and when its newest question or answer + arrived ("active 12m ago · asked 3m ago"), live (`LiveAgo`). A header says what the session is: agent, project, + model, working or idle for how long, context size. +- **No jumps.** A filter that empties a list does not shrink an open panel; a tab, filter or headline never scrolls + under a fade; content never blinks empty while a panel grows or a hover changes it. +- **The action is near the eye.** The reply box sits where the reading happens (under the tabs), not at the far + bottom of a tall panel. +- **Attention cues point, they do not cover.** A flash is the border; a tinted overlay over a whole card reads as + covered. Border flash is the `AttentionPulse` default. +- **A tooltip never stands in for the real hover behavior.** If hover should open a preview, a tooltip appearing + instead is a bug to fix, not a fallback. +- **Every count opens what it counts**, and every session offers its terminal (cmux) and its inbox. + ## Design authorities (load before a judgment call) | Question | Load | diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 5c4cbdcc55..134cd00968 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -63,6 +63,20 @@ Walk it for every view you change. Each line exists because it was missed at lea - A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it otherwise keeps the old offset, and a short section drew its rows over the header. +## Widget and panel controls, round 2 (Martin, 2026-10-23 15:35) +- Hover on every control, shaped like the control: a circle bubble gets a ring (`WidgetBubbleChrome`), which pulses + once on enter and settles. Opacity only, never a repeating animation. +- A filter bar and its headline sit outside the scroll view they filter, pinned under the tabs. +- A filter change in an open panel holds the panel's height (`WidgetModel.holdAgentFit`); folding a row or switching + view releases it. An empty filter result must not collapse the panel. +- A header line under a title is the place for facts: agent · project · model · working / idle `LiveAgo` · ctx. +- Rows: last active and newest question or answer, both `LiveAgo`. +- An alert (sound, popup, auto-expand) has a setting each, a sound per kind with a play button, and flashes the + session that caused it for `alertFlashSeconds`. +- A hover tooltip that duplicates a hover preview is off by default ("auto"), and the preview must open reliably: + a control the pointer reaches inside a panel starts the panel's hover itself, since the panel's own hover fires + only on crossing its edge. + ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. - Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 4876b1678c..f2293a5f68 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,6 +3,20 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. +- 2026-10-23 16:30 — **Widget round 2 and the markdown tab bar (Martin's screenshots 23-28).** + - *Rail bubbles had no hover answer.* `.genHoverPlain` drew a rectangular plate a round bubble hid. Fix: + `WidgetBubbleChrome`, a round ring that pulses once on enter and settles (opacity only). + - *The hover list blinked and opened cut at the top.* One hover asked the panel for 336, 295 and 489 pt within 42 ms + (`edge.request` lines in `~/.genesis-tools/logs/app-perf.log`): the list is rebuilt per hover and its first layout + measured short. Fix: `FittedScroll(initialHeight:)` from the last height, shrinks wait 300 ms, side content pinned + to the top. Lesson: log every resize request with its branch before guessing at a blink. + - *A bubble showed its tooltip instead of the preview.* The panel's hover fires only on crossing its edge; after a + collapse under a resting pointer it never fired again. Fix: a bubble the pointer reaches starts the panel hover; + the bubble tooltip is off while previews are on (`bubbleTooltip`). + - *A filter that emptied the list dropped the open panel's height.* Fix: `holdAgentFit` before a filter change. + - *72 tabs wrapped into nine rows.* Fix: tab groups in GenesisKit (`MarkdownTabGrouping`), inline by default, the + wrapped bar capped at 4 rows with a fade. A first engine split big groups into singletons, so everything landed in + "Other": render the real data (`MARKDOWN_TAB_GROUPS_RENDER`) before calling a grouping rule done. - 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** Commit 5e0a6e328d. - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). diff --git a/CLAUDE.md b/CLAUDE.md index 86a98a8be1..7e68071d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ The detailed rules live in area files. Claude Code loads an area file by itself - AI, accounts, secrets, cost, agent history (`src/utils/ai/`, `src/utils/security/`, `src/utils/agent-sessions/`, `src/ai-proxy/`, `src/ask/`, `src/claude/`, `src/codex/`): `.claude/rules/ai.md`. - Models and pricing (the catalog, the proxy ledger, Claude request rules, Codex and Grok model lists, the handoff-to rankings): `.claude/rules/models.md`. Where each model fact lives, and the checklist for a new model. - Web UI (`*.tsx`, `src/*/ui/`, `src/dashboard/`, `src/dev-dashboard/`): `.claude/rules/web-ui.md`. +- Any UI, native or web, the widget schema and notifications: `.claude/rules/opinionated-settings.md`. A behavior a reasonable user could want another way (a sound, a popup, a flash, a placement, a hover tooltip, a timing) ships as a setting with your pick as its default. - macOS app, notifications, privacy grants (`src/macos/`, `src/utils/macos/`, `src/notify/`): `.claude/rules/macos.md`. Window rules for the Swift app (`src/macos/GenesisTools/`): `.claude/rules/macos-app.md`. - The personal-data probe and the git hooks: `.claude/rules/placeholder-check.md`. - Browser automation (`src/chrome-devtools/`, `src/utils/chrome-devtools/`, which another repo vendors): `.claude/rules/chrome-devtools.md`. The plugin (`plugins/`, `.claude-plugin/`): `plugins.md`. CI, guards and git hooks (`.github/`, `scripts/ci/`, `.githooks/`): `ci.md`. Dependencies (`package.json`, `bun.lock`): `dependencies.md`. diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index 5b8716d922..f66048998f 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -71,6 +71,14 @@ export const widgetPreferencesSchema = z.object({ previewFilter: z.enum(["needs-you", "active"]).default("needs-you"), /** The agents hover list's order: waiting for you first, newest activity first, or by project. */ previewSort: z.enum(["attention", "recent", "project"]).default("attention"), + /** The sound an answer or message plays; "same" uses `alertSound`, "none" plays no sound. */ + alertAnswerSound: z.string().trim().min(1).max(40).default("same"), + /** Seconds the session that raised an alert flashes in the widget; 0 does not flash it. */ + alertFlashSeconds: z.number().int().min(0).max(30).default(5), + /** Where a session inbox puts its reply composer: under the tabs, or below the list. */ + inboxComposer: z.enum(["top", "bottom"]).default("top"), + /** A rail session bubble's tooltip: only while hover previews are off ("auto"), always, or never. */ + bubbleTooltip: z.enum(["auto", "always", "never"]).default("auto"), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift index 13c6a889d9..97bd0a00bb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift @@ -11,14 +11,23 @@ import SwiftUI public struct FittedScroll: View { public var maxHeight: CGFloat public var minHeight: CGFloat + /// The height until the content is first measured, and every measurement goes to `measured`. A view that is + /// built again each time it shows (the widget's hover list) passes its last height here: from 0, the first layout + /// pass reported a list-less height, its window shrank and grew back within 40 ms, and the panel blinked + /// (edge.request log, 2026-10-23: 336, then 295, then 489 pt for one hover). + public var initialHeight: CGFloat + private let measured: ((CGFloat) -> Void)? private let content: () -> Content @State private var height: CGFloat = 0 @State private var expansion = FittedExpansionNote() @GenReduceMotion private var reduceMotion - public init(maxHeight: CGFloat, minHeight: CGFloat = 0, @ViewBuilder content: @escaping () -> Content) { + public init(maxHeight: CGFloat, minHeight: CGFloat = 0, initialHeight: CGFloat = 0, + measured: ((CGFloat) -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) { self.maxHeight = maxHeight self.minHeight = minHeight + self.initialHeight = initialHeight + self.measured = measured self.content = content } @@ -29,11 +38,12 @@ public struct FittedScroll: View { .scrollOverflowContent() } .scrollOverflowHints() - .frame(height: min(maxHeight, max(minHeight, height))) + .frame(height: min(maxHeight, max(minHeight, height > 0 ? height : initialHeight))) .onGenExpansion { expansion.note($0) } } private func apply(_ value: CGFloat) { + measured?(value) guard height > 0, let expanding = expansion.recent() else { height = value return diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift index daf5f60405..98f3fcce68 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift @@ -28,8 +28,15 @@ struct AttentionPulseStyle: Equatable { /// Number of blinks. Never zero, and a zero period cannot divide by zero. var cycles: Int { max(1, Int((duration / max(period, 0.05)).rounded())) } - /// Three quick flashes of border and fill over about 1.4 s. Reads as "look here, now". - static func flash(color: Color, cornerRadius: CGFloat) -> Self { + /// Three quick flashes of the border over about 1.4 s. Reads as "look here, now" without covering the content, + /// so it is the default (Martin, 2026-10-23: a settings card under a tinted overlay read as covered, not pointed at). + static func borderFlash(color: Color, cornerRadius: CGFloat) -> Self { + Self(color: color, cornerRadius: cornerRadius, lineWidth: 2, + duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) + } + + /// The same three flashes with a tinted fill over the whole view, for a target too small for a border to read. + static func filledFlash(color: Color, cornerRadius: CGFloat) -> Self { Self(color: color, cornerRadius: cornerRadius, fillOpacity: 0.30, duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) } @@ -109,8 +116,11 @@ struct AttentionPulse: ViewModifier { } extension View { - /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. - func attentionPulse(trigger: Trigger, style: AttentionPulseStyle) -> some View { + /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. The default is a + /// border-only flash in the accent colour; `.filledFlash` and `.slowBorder` are the other presets. + func attentionPulse( + trigger: Trigger, style: AttentionPulseStyle = .borderFlash(color: .accentColor, cornerRadius: 12) + ) -> some View { modifier(AttentionPulse(trigger: trigger, style: style)) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index b9d525958d..418e13683d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -85,7 +85,7 @@ private struct NativeSettingsAnchor: ViewModifier { func body(content: Content) -> some View { content - .attentionPulse(trigger: pulses, style: .flash(color: .accentColor, cornerRadius: 12)) + .attentionPulse(trigger: pulses) // A window that opens on the page asks before the card exists: the card pulses once it is on screen. .onAppear { if center.anchor == id { pulseSoon() } } .onChange(of: center.token) { _, _ in if center.anchor == id { pulseSoon() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index a1ffbfbf85..0c2e1a825a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -182,6 +182,10 @@ public struct LiveWidgetView: View { waitingSessions } } else { + if composerOnTop { composer } + // Pinned above the list: inside it, a session that opened at its selected card scrolled the filter + // under the top fade (Martin's image 24, 2026-10-23). + if section == "Inbox", !model.cards.isEmpty { inboxFilterBar } ScrollViewReader { reader in ScrollView { LazyVStack(alignment: .leading, spacing: 14) { @@ -217,7 +221,7 @@ public struct LiveWidgetView: View { .scrollOverflowHints() } // While a decision card holds the composer for an own answer, the bottom one would edit the same draft. - if !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { + if showsComposer && !composerOnTop { composer } } @@ -269,10 +273,13 @@ public struct LiveWidgetView: View { VStack(alignment: .leading, spacing: 3) { Text(model.inboxOverview ? "Inbox" : model.selected?.title ?? "Agent inbox") .font(.system(size: 13, weight: .semibold)).lineLimit(1) - Text(model.inboxOverview ? "Every session" - : [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } - .joined(separator: " · ")) - .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + if model.inboxOverview { + Text("Every session").font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } else if let selected = model.selected { + WidgetSessionFacts(session: selected, + contextTokens: WidgetModel.contextTokens(model.transcript) ?? model.contextTokens[selected.key], + animate: !reduceMotion) + } } .padding(.horizontal, 6).padding(.vertical, 3) } @@ -332,6 +339,16 @@ public struct LiveWidgetView: View { private var reduceMotion: Bool { systemReduceMotion || model.effectiveReduceMotion } + /// While a decision card holds the composer for an own answer, a second one would edit the same draft. + private var showsComposer: Bool { + !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) + } + + /// The inbox's composer sits under the tabs unless the user moved it to the bottom (Widgets → Sessions). + private var composerOnTop: Bool { + showsComposer && section == "Inbox" && (model.snapshot?.state.preferences.inboxComposer ?? "top") == "top" + } + /// No session picked: the sessions that hold inbox items, so nothing waits out of sight. private var waitingSessions: some View { ScrollView { @@ -388,17 +405,25 @@ public struct LiveWidgetView: View { /// Every item of the session as a row; the open row carries the whole card in place. Several answers from one /// session read as a list, not as one card behind a drop-down. - @ViewBuilder private var inbox: some View { - if !model.cards.isEmpty { + private var inboxFilterBar: some View { + VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { GenSegmentedTabs( "Inbox filter", items: [.init("all", "All"), .init("inbox", "Inbox entries"), .init("decisions", "Decisions")], - selection: $inboxFilter, minSegmentWidth: 64, identifier: "widget.inbox.filter") + selection: Binding(get: { inboxFilter }, set: { value in + model.holdAgentFit() + inboxFilter = value + }), minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton } Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + } + } + + @ViewBuilder private var inbox: some View { + if !model.cards.isEmpty { let shown = Self.filtered(model.cards, by: inboxFilter) if shown.isEmpty { Text(inboxFilter == "decisions" ? "No decisions in this session." : "No inbox entries in this session.") @@ -435,6 +460,7 @@ public struct LiveWidgetView: View { /// Opens or folds a row in one `GenMotion` animation; the panel around it grows or shrinks to fit (W3). private func toggle(_ card: WidgetCard) { let opening = !isOpen(card) + model.releaseAgentFit() GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { if opening { model.openCard(card) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index 8a219b9752..d799a2d305 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -127,7 +127,8 @@ struct AgentWidgetPreview: View { } }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) } else { - FittedScroll(maxHeight: 360) { + FittedScroll(maxHeight: 360, initialHeight: model.previewListHeight, + measured: { if $0 > 0 { model.previewListHeight = $0 } }) { LazyVStack(spacing: 6) { ForEach(model.previewList) { session in row(session) } } @@ -172,7 +173,7 @@ struct AgentWidgetPreview: View { } label: { HStack(spacing: 9) { AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) HStack(spacing: 5) { Text(session.project.isEmpty ? session.target.provider.capitalized : session.project) @@ -180,6 +181,7 @@ struct AgentWidgetPreview: View { Text("·") Text(session.visualStatus.label).lineLimit(1) }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) + WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key)) } Spacer(minLength: 2) if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { @@ -204,5 +206,85 @@ struct AgentWidgetPreview: View { model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), in: RoundedRectangle(cornerRadius: 10)) + .attentionPulse(trigger: model.attentionTrigger(session.key), style: rowAttention) + } + + private var rowAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = 10 + return style + } +} + +/// When a session was last active and when its newest question or answer arrived: "working now · asked 3m ago", +/// "active 12m ago · answered 1h ago". Both live, so a row left open keeps counting. +struct WidgetSessionTimes: View { + let session: WidgetSession + let inbox: WidgetInboxSession? + + var body: some View { + HStack(spacing: 5) { + if session.visualStatus == .working { + Text("working now") + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "active " + $0 } + } + if let entry = Self.latest(inbox) { + Text("·") + LiveAgo(date: Date(timeIntervalSince1970: entry.at / 1000), style: .brief) { entry.verb + " " + $0 } + .foregroundStyle(entry.waiting ? Color.orange.opacity(0.8) : .white.opacity(0.45)) + } + } + .font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)).lineLimit(1) + } + + /// The newest entry: a question still waiting wins, since that is what the row is about. + static func latest(_ inbox: WidgetInboxSession?) -> (verb: String, at: Double, waiting: Bool)? { + if let item = inbox?.pendingItem, item.at.isFinite, item.at > 0 { + return (item.kind == "decision" ? "decision asked" : "asked", item.at, true) + } + guard let item = inbox?.unreadItem ?? inbox?.latest, item.at.isFinite, item.at > 0 else { return nil } + switch item.kind { + case "answer": return ("answered", item.at, false) + case "result": return ("finished", item.at, false) + default: return ("new entry", item.at, false) + } + } +} + +/// The header line under a session's title: agent, project and model, then whether it works now or how long it +/// has been idle, and its context size (Martin, 2026-10-23: "the header probably can have way more info"). +struct WidgetSessionFacts: View { + let session: WidgetSession + let contextTokens: Int? + let animate: Bool + + var body: some View { + HStack(spacing: 5) { + Text(Self.origin(session)).lineLimit(1).truncationMode(.middle) + Text("·") + if session.visualStatus == .working { + WidgetActivityIndicator(status: .working, animate: animate) + Text("working").foregroundStyle(session.visualStatus.color) + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "idle " + $0 } + } else { + Text(session.visualStatus.label) + } + if let contextTokens { + Text("·") + // The header is the session chooser's button, whose tooltip covers this line; VoiceOver says it. + Text("ctx " + SessionFormat.tokens(contextTokens)).monospacedDigit() + .accessibilityLabel("Context size of the last model call, \(contextTokens) tokens") + } + } + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + + /// "Claude · col-tools · opus-5-5": the agent, the project, and the model without its vendor prefix. + static func origin(_ session: WidgetSession) -> String { + let model = session.model.map { $0.replacingOccurrences(of: "claude-", with: "") } + return [AIProviders.meta(for: session.target.provider).displayName, session.project, model] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift index ec37a3c67e..33d4991068 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift @@ -35,6 +35,19 @@ public enum WidgetAlerts { } } + /// The sound for a burst: `alertSound` when anything in it waits for an answer, else the answers' own sound + /// (`alertAnswerSound`, where "same" means `alertSound`). nil plays the default sound. + public static func sound(for items: [WidgetInboxItem], preferences: WidgetPreferences?) -> String? { + let asks = items.contains { $0.needsAnswer || $0.kind == "decision" || $0.kind == "form" } + guard !asks, let answer = preferences?.alertAnswerSound, answer != "same" else { return preferences?.alertSound } + return answer + } + + /// Seconds the session behind an alert flashes in the widget; 0 is off. + public static func flashSeconds(_ preferences: WidgetPreferences?) -> Double { + max(0, min(30, preferences?.alertFlashSeconds ?? 5)) + } + private static var lastChime = Date.distantPast /// Plays `name` once; a burst of arrivals within 1.5 s chimes once. "none" or an unknown name is silent. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift new file mode 100644 index 0000000000..f702453720 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The chrome of a session bubble on the rail and the top strip: a ring that answers the pointer, and the flash of +/// a session that just asked for you. +/// +/// Hover: the ring fades in strong, then settles to a quieter ring while the pointer stays, so a bubble reads as a +/// control the moment it is reached (Martin, 2026-10-23: "missing hover effects (pulsing around)"). Opacity only, +/// two timed steps, never a repeating animation: a `repeatForever` lays the whole window out every frame (swift skill). +struct WidgetBubbleChrome: ViewModifier { + let trigger: Int + let attention: AttentionPulseStyle + let reduceMotion: Bool + @State private var ring = 0.0 + @State private var settle: Task? + + func body(content: Content) -> some View { + content + .background(Circle().fill(Color.white.opacity(ring > 0 ? 0.07 : 0)).padding(2)) + .overlay(Circle().strokeBorder(Color.white.opacity(ring), lineWidth: 1.5).padding(2).allowsHitTesting(false)) + .attentionPulse(trigger: trigger, style: attention) + .onHover { inside in + settle?.cancel() + guard inside else { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.18)) { ring = 0 } + return + } + guard !reduceMotion else { + ring = 0.5 + return + } + withAnimation(.easeOut(duration: 0.14)) { ring = 0.9 } + settle = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(220)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.35)) { ring = 0.45 } + } + } + .onDisappear { settle?.cancel() } + } +} + +extension WidgetModel { + /// A rail bubble's tooltip under `bubbleTooltip`: "auto" shows it only while hover previews are off, because + /// the preview already names the session and an extra label over it read as the preview failing to open. + func bubbleTooltip(_ content: TooltipContent) -> TooltipContent { + switch snapshot?.state.preferences.bubbleTooltip ?? "auto" { + case "always": return content + case "never": return TooltipContent() + default: return layout.hoverPreviews ? TooltipContent() : content + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index 31bd235a76..0fad4a7a64 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -181,7 +181,10 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { let wanted = WidgetAlerts.wanted(items, preferences: preferences) guard let item = WidgetAlerts.popupItem(wanted) else { return } PerfLog.mark("widget.alert items=\(wanted.count) id=\(item.id) kind=\(item.kind)") - playChime(preferences?.alertSound) + playChime(WidgetAlerts.sound(for: wanted, preferences: preferences)) + // The session that asked flashes on its rail bubble and in the lists, popup or not: it says why the widget + // chimed or opened (Martin, 2026-10-23). + model.flashAttention(item.key, seconds: WidgetAlerts.flashSeconds(preferences)) // Checked before the module switch: a popup the user's interaction refuses must not swap the pane they use. guard preferences?.alertPopup ?? true, let surface = peekSurface(), model.canPeek(on: surface) else { return } // The peek shows the agents module and gives the user's own module back when it ends. @@ -693,7 +696,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { answering: model.answerCard)) { case .pass: return event - case .collapse: model.collapse() + case .collapse: if !model.escapeToList() { model.collapse() } case .previous: model.next(-1) case .next: model.next() case .choose(let choice): model.submit(choice: choice) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index 7e7eb00f7f..73b96f42db 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -173,6 +173,14 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Hover previews", detail: "See a preview without changing keyboard focus.", isOn: boolean("hoverPreviews", prefs?.hoverPreviews ?? true)) + Divider() + NativeSettingsRow("Session bubble tooltips", detail: "Automatic shows them only while hover previews are off.") { + Picker("Session bubble tooltips", selection: string("bubbleTooltip", prefs?.bubbleTooltip ?? "auto")) { + Text("Automatic").tag("auto") + Text("Always").tag("always") + Text("Never").tag("never") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Shape") { NativeSettingsRow("Side style", detail: "Classic keeps a compact monochrome rail with its handle below.") { @@ -219,6 +227,13 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Changed files", detail: "Show a session's working changes alongside its conversation.", isOn: boolean("showChanges", prefs?.showChanges ?? true)) + Divider() + NativeSettingsRow("Reply box", detail: "Where a session's inbox puts the reply box.") { + Picker("Reply box", selection: string("inboxComposer", prefs?.inboxComposer ?? "top")) { + Text("Under the tabs").tag("top") + Text("Below the list").tag("bottom") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Alerts", subtitle: "When an agent asks or answers while the widget is closed.") { NativeSettingsToggle( @@ -229,26 +244,53 @@ private struct WidgetGeneralSettings: View { "Answers and messages", detail: "A new logged answer or a message an agent sent you.", identifier: "widget.alertAnswers", isOn: boolean("alertAnswers", prefs?.alertAnswers ?? true)) Divider() - NativeSettingsRow("Sound", detail: "One chime per burst of new items.") { - HStack(spacing: 6) { - Picker("Alert sound", selection: string("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound)) { - Text("None").tag("none") - ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } - }.labelsHidden().frame(width: 140, alignment: .trailing) - IconButton(systemName: "speaker.wave.2", tooltip: "Play the alert sound") { - WidgetAlerts.preview(prefs?.alertSound ?? WidgetAlerts.defaultSound) - }.disabled((prefs?.alertSound ?? WidgetAlerts.defaultSound) == "none") - } + NativeSettingsRow("Sound for decisions", detail: "Decisions and questions that wait for you. One chime per burst.") { + soundPicker("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound, same: false) + } + Divider() + NativeSettingsRow("Sound for answers", detail: "Answers and messages, when nothing in the burst waits for you.") { + soundPicker("alertAnswerSound", prefs?.alertAnswerSound ?? "same", same: true) } Divider() NativeSettingsToggle( - "Popup", detail: "The widget opens a short preview of the new item; a click on it answers.", + "Open the widget", detail: "The widget opens a short preview of the new item; a click on it answers.", identifier: "widget.alertPopup", isOn: boolean("alertPopup", prefs?.alertPopup ?? true)) + Divider() + NativeSettingsRow("Flash the session", detail: "Its rail bubble and its row flash, so you see which session asked.") { + Picker( + "Flash the session", + selection: Binding( + get: { Int(WidgetAlerts.flashSeconds(prefs)) }, + set: { patch(["alertFlashSeconds": .number(Double($0))]) }) + ) { + Text("Off").tag(0) + Text("3 seconds").tag(3) + Text("5 seconds").tag(5) + Text("10 seconds").tag(10) + Text("20 seconds").tag(20) + }.labelsHidden().frame(width: 150, alignment: .trailing) + } } + .nativeSettingsAnchor("alerts") } }.task { model.refreshSettings() } } + /// A system sound picker with a play button. `same` adds "Same as decisions", stored as "same". + private func soundPicker(_ key: String, _ value: String, same: Bool) -> some View { + let played = value == "same" ? prefs?.alertSound ?? WidgetAlerts.defaultSound : value + return HStack(spacing: 6) { + Picker(key == "alertSound" ? "Decision sound" : "Answer sound", selection: string(key, value)) { + if same { Text("Same as decisions").tag("same") } + Text("None").tag("none") + ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } + }.labelsHidden().frame(width: 160, alignment: .trailing) + IconButton(systemName: "speaker.wave.2", tooltip: "Play this sound") { + WidgetAlerts.preview(played) + }.disabled(played == "none") + } + } + private func string(_ key: String, _ value: String) -> Binding { Binding(get: { value }, set: { patch([key: .string($0)]) }) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index d25584c6f9..4be2eefbb6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -58,8 +58,11 @@ struct WidgetHostView: View { joined: joined) } + /// Side panels pin their content to the top: content taller than its window (a measurement still on its way, a + /// screen too short) then loses its footer, never its header. Centred, the hover list opened with its header and + /// its last row both cut off (Martin's screenshot, 2026-10-23). private var edgeAlignment: Alignment { - surface.edge == .top ? .top : (surface.edge == .right ? .trailing : .leading) + surface.edge == .top ? .top : (surface.edge == .right ? .topTrailing : .topLeading) } private var topPadding: CGFloat { @@ -165,9 +168,10 @@ struct WidgetHostView: View { model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) - .frame(minWidth: 24, minHeight: 30).fixedSize() - }.buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .frame(minWidth: 30, minHeight: 30).fixedSize() + .modifier(bubbleChrome(session)) + }.buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } @@ -274,14 +278,15 @@ struct WidgetHostView: View { } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) + .modifier(bubbleChrome(session)) .overlay(alignment: .bottomTrailing) { // Which agent the count belongs to, at a glance; the tooltip names the session. AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 11) .opacity(0.85).allowsHitTesting(false) } } - .buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } @@ -407,6 +412,14 @@ struct WidgetHostView: View { .accessibilityIdentifier("widget." + location + "." + surface.key + "." + id) } + /// The bubble's hover ring and the alert flash, in place of the rectangular hover plate of `.genHoverPlain`. + private func bubbleChrome(_ session: WidgetSession) -> WidgetBubbleChrome { + var style = model.attentionStyle + style.cornerRadius = 17 + return WidgetBubbleChrome(trigger: model.attentionTrigger(session.key), attention: style, + reduceMotion: model.effectiveReduceMotion) + } + private func sessionActivity(_ session: WidgetSession) -> some View { let inbox = model.inboxFor(session.key) return WidgetSessionActivity(status: session.visualStatus, @@ -495,8 +508,15 @@ struct WidgetPeekView: View { .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention) .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + (item.title ?? "")) } + + private var popupAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = 10 + return style + } } struct AgentWidgetModuleView: View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 8e30b237d7..dff6fa31de 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -24,7 +24,10 @@ struct WidgetInboxOverview: View { GenSegmentedTabs( "Inbox entries", items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], - selection: $filter, minSegmentWidth: 70, identifier: "widget.overview.filter") + selection: Binding(get: { filter }, set: { value in + model.holdAgentFit() + filter = value + }), minSegmentWidth: 70, identifier: "widget.overview.filter") Spacer(minLength: 4) if model.inboxCount > 0 { Button("Mark all read", action: model.markAllRead) @@ -63,6 +66,7 @@ struct WidgetInboxOverview: View { private func toggle(_ item: WidgetInboxItem) { let opening = openID != item.id + model.releaseAgentFit() GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { openID = opening ? item.id : nil } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index fa448a2499..592e976ea9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -241,26 +241,70 @@ public final class WidgetModel: ObservableObject { /// The agents preview's measured height. The old estimate (100 + 54 per row) was 11 pt plus 2 pt per row short, /// so the centred content lost its header and the top of its first row (2026-10-10, Martin's screenshot). public private(set) var previewFitHeight: CGFloat? + /// The hover list's last measured height. The list is built again on every hover; it starts at this height so its + /// first layout is already the real one (`FittedScroll.initialHeight`). Not published: it must not re-render. + var previewListHeight: CGFloat = 0 public var previewHeight: CGFloat { if let previewFitHeight { return previewFitHeight } return sessionRoster.preview.isEmpty ? 180 : 112 + CGFloat(sessionRoster.preview.count) * 56 } /// Whole points and a 2 pt hysteresis, as `reportAgentFit`, so the measurement cannot chase its own resize. + /// + /// A taller measurement applies at once; a shorter one only after it held for `previewShrinkDelay`. The list is + /// built again on every hover, and its first layout passes measured it short (header and footer, then the rows): + /// one hover asked for 336, 295 and 489 pt within 42 ms, the panel shrank and grew back, and its content blinked + /// or opened cut at the top (edge.request log and Martin's screenshots, 2026-10-23). func reportPreviewFit(_ height: CGFloat) { let rounded = ceil(height) - if let current = previewFitHeight, abs(rounded - current) < 2 { return } + if let current = previewFitHeight, abs(rounded - current) < 2 { + previewShrink?.cancel() + return + } + previewShrink?.cancel() + if let current = previewFitHeight, rounded < current { + previewShrink = Task { [weak self] in + do { try await Task.sleep(for: Self.previewShrinkDelay) } catch { return } + guard let self else { return } + self.previewFitHeight = rounded + self.presentationChanged?() + } + return + } previewFitHeight = rounded presentationChanged?() } + static let previewShrinkDelay = Duration.milliseconds(300) + private var previewShrink: Task? + public var selected: WidgetSession? { sessionIndex[selectedKey] ?? (lastSelected?.key == selectedKey ? lastSelected : nil) } + /// The height a filter change may not shrink the pane below, for the view (overview, section, session) it was + /// taken in. A filter that shows fewer rows, or none, used to drop the open panel by hundreds of points and grow + /// it back on the next click (Martin, 2026-10-23: "it probably doesn't need to lower the height"). + private var agentFitFloor: (view: String, height: CGFloat)? + private var fitView: String { "\(inboxOverview)|\(section)|\(selectedKey)" } + + /// Called just before a filter changes what an open pane lists: the pane keeps at least its current height. + public func holdAgentFit() { + guard expanded != nil, let height = agentFitHeight else { return } + agentFitFloor = (fitView, max(height, agentFitFloor?.view == fitView ? agentFitFloor?.height ?? 0 : 0)) + } + + /// A row folding or another view: the pane fits its content again. + public func releaseAgentFit() { + agentFitFloor = nil + } + /// Whole points, and only a change of 2 pt or more, so a measurement cannot chase its own resize. func reportAgentFit(_ height: CGFloat?) { - let rounded = height.map { ceil($0) } + var rounded = height.map { ceil($0) } + if let floor = agentFitFloor { + if floor.view == fitView, let value = rounded { rounded = max(value, floor.height) } else { agentFitFloor = nil } + } if let rounded, let current = agentFitHeight, abs(rounded - current) < 2 { return } guard rounded != agentFitHeight else { return } agentFitHeight = rounded @@ -592,6 +636,10 @@ public final class WidgetModel: ObservableObject { return } guard layout.hoverPreviews, !dialogOpen, !draggingSide, expanded == nil else { return } + // The panel's own hover fires only when the pointer crosses its edge. After a click collapsed the panel + // under a resting pointer it never fired again, so the next bubble showed its tooltip and no preview + // (Martin, 2026-10-23). A bubble the pointer reaches starts the panel's hover itself. + if hoveredSurface != surface { hover(surface, inside: true) } cancelHoverPrewarm() hoveredSession = (key, surface) transcriptHoverTask = Task { [weak self] in @@ -728,6 +776,7 @@ public final class WidgetModel: ObservableObject { expanded = edge prewarmTranscript() resumeTranscript() + refreshContext() presentationChanged?() scheduleQuietReduction() if openingInbox == nil, let card, card.kind == "answer", !card.read { @@ -837,6 +886,31 @@ public final class WidgetModel: ObservableObject { public func inboxFor(_ key: String) -> WidgetInboxSession? { inboxSessions[key] } public func inboxPulseFor(_ key: String) -> Int { inboxSessionPulses[key] ?? 0 } + + /// The session an alert came from, flashed for `attentionSeconds` on its rail bubble, its hover row and its + /// popup. The counter restarts a running flash (`AttentionPulse`). + /// One counter per session, so flashing one session never changes another session's trigger. + @Published public private(set) var attentionCounts: [String: Int] = [:] + public private(set) var attentionSeconds: Double = 5 + + public func flashAttention(_ key: String, seconds: Double) { + guard seconds > 0 else { return } + attentionSeconds = seconds + attentionCounts[key, default: 0] += 1 + } + + /// The pulse trigger for one session: changes only when that session is flashed. + public func attentionTrigger(_ key: String) -> Int { attentionCounts[key] ?? 0 } + + /// The style every flash of a session that needs you uses. + var attentionStyle: AttentionPulseStyle { + var style = AttentionPulseStyle.borderFlash(color: .orange, cornerRadius: 12) + style.duration = attentionSeconds + style.period = 0.8 + style.fadeIn = 0.18 + style.fadeOut = 0.3 + return style + } public var inboxCount: Int { max(0, inbox.unread) + max(0, inbox.needsAnswer) } /// Expands one inbox card in place. An unread answer or result counts as read once the user opens it, the same @@ -878,6 +952,7 @@ public final class WidgetModel: ObservableObject { public func openInboxNotification(on surface: WidgetSurfaceID, key: String? = nil, needsAnswer: Bool? = nil) { guard !stopping else { return } + listOrigin = .overview let candidates: [WidgetInboxSession] if let key { candidates = inboxSessions[key].map { [$0] } ?? [] @@ -917,8 +992,26 @@ public final class WidgetModel: ObservableObject { } /// An overview entry's "Open in session": that session's inbox with the entry open. + /// The list a session was opened from, which the first Esc goes back to (Martin, 2026-10-23: "it should first go + /// to the list and the second escape should close the expansion entirely"). + public enum ListOrigin: Sendable { case overview, sessions } + public var listOrigin: ListOrigin = .overview + + /// Esc in a session: back to the list it came from. False when a list (or no session) is on screen, so Esc then + /// closes the panel. + @discardableResult + public func escapeToList() -> Bool { + guard activeModuleID == "agents", selected != nil, !inboxOverview, section != "Sessions" else { return false } + switch listOrigin { + case .overview: inboxOverview = true + case .sessions: section = "Sessions" + } + return true + } + public func openInboxItem(_ item: WidgetInboxItem) { guard !stopping else { return } + listOrigin = .overview openingInbox = item select(item.key) selectedCardID = item.id @@ -969,6 +1062,7 @@ public final class WidgetModel: ObservableObject { public func collapse() { cancelHoverPrewarm() + agentFitFloor = nil openingInbox = nil followedTranscript = nil hoveredSurface = nil @@ -993,6 +1087,7 @@ public final class WidgetModel: ObservableObject { inboxOverview = false selectedKey = key lastSelected = snapshot?.sessions.first { $0.key == key } + if expanded != nil { refreshContext() } selectedCardID = nil action(["action": "selection", "key": .string(key)]) if let edge { open(edge) } @@ -1777,6 +1872,31 @@ public final class WidgetModel: ObservableObject { provider: session.target.provider) } + /// Context size per session key, for the header (`WidgetSessionFacts`). Read from the transcript cache when a + /// session opens: the conversation itself loads only on the Conversation tab, and the header shows on every tab. + @Published public private(set) var contextTokens: [String: Int] = [:] + private var contextTask: Task? + + private func refreshContext() { + guard let session = selected, session.target.provider != "unknown" else { return } + let key = session.key + let query = transcriptQuery(session) + contextTask?.cancel() + contextTask = Task { [weak self] in + guard let self, let envelope = try? await self.transcriptCache.value(for: query), !Task.isCancelled, + let tokens = Self.contextTokens(envelope.turns) else { return } + if self.contextTokens[key] != tokens { self.contextTokens[key] = tokens } + } + } + + /// The context the session's last model call carried: its fresh input plus its cache reads. nil when the turns + /// have no call with usage (a provider that reports none, or nothing loaded yet). + nonisolated static func contextTokens(_ turns: [TranscriptTurn]) -> Int? { + guard let usage = turns.last(where: { $0.role == "assistant" && $0.usage != nil })?.usage else { return nil } + let total = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + return total > 0 ? total : nil + } + private func prewarmTranscript(on surface: WidgetSurfaceID? = nil) { let module = surface.map { moduleSelections[$0.key] ?? "agents" } ?? activeModuleID let hovered = surface.flatMap { surface in diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 878b8a2384..a66d75fef3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -114,7 +114,11 @@ public struct WidgetSessionBrowser: View { private func open(_ session: WidgetSession) { if let onOpen { onOpen(session) } - else { model.select(session.key); model.section = "Inbox" } + else { + model.select(session.key) + model.section = "Inbox" + model.listOrigin = .sessions + } } /// A top-level session: its name, project, account and sub-agent count, the same for every session. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 4711180179..d6c7614003 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -290,6 +290,14 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var previewFilter: String? /// The agents hover list's order: "attention" (default), "recent" or "project". public var previewSort: String? + /// An answer's sound: "same" (default, `alertSound`), "none" or a system sound name. + public var alertAnswerSound: String? + /// Seconds the session that raised an alert flashes; 0 does not flash it. Default 5. + public var alertFlashSeconds: Double? + /// A session inbox's composer: "top" (default, under the tabs) or "bottom". + public var inboxComposer: String? + /// A rail bubble's tooltip: "auto" (default, only without hover previews), "always" or "never". + public var bubbleTooltip: String? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift index bfeb413c40..5517fe7ee4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift @@ -249,6 +249,9 @@ public final class EdgePanelController { let wasReshaping = transition?.reshape == true end(outcome: "interrupted") isExpanded = expanded + let branch = reduceMotion || !panel.isVisible || from == target ? "settle" + : motion.presentation == presentation && (!wasTransitioning || wasReshaping) ? "reshape" : "begin" + PerfLog.mark("edge.request window=\(panel.windowNumber) edge=\(placement.rawValue) shown=\(motion.presentation) to=\(presentation) \(branch) from=\(Int(from.width))x\(Int(from.height)) target=\(Int(target.width))x\(Int(target.height)) transitioning=\(wasTransitioning)") if reduceMotion || !panel.isVisible || from == target { settle(presentation, frame: target) } else if motion.presentation == presentation, !wasTransitioning || wasReshaping { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 538ca53dc3..cba590e773 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -617,6 +617,59 @@ final class WidgetInboxPolishTests: XCTestCase { .first?.key, "asks", "Beta before Fixture") } + func testAnAlertPlaysItsKindsSoundAndFlashesForTheConfiguredTime() throws { + let decision = WidgetInboxItem(id: "decision:d_1_s", sourceId: "d_1_s", kind: "decision", key: "k", at: 1, + needsAnswer: true) + let answer = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 2, needsAnswer: false) + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + let split = try prefs(#"{"alertSound":"Ping","alertAnswerSound":"Pop","alertFlashSeconds":10}"#) + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: split), "Pop") + XCTAssertEqual(WidgetAlerts.sound(for: [answer, decision], preferences: split), "Ping", + "a burst with a question in it plays the decisions' sound") + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: try prefs(#"{"alertSound":"Ping","alertAnswerSound":"same"}"#)), + "Ping", "\"same\" follows the decisions' sound") + XCTAssertNil(WidgetAlerts.sound(for: [answer], preferences: nil), "no preferences: the default sound") + XCTAssertEqual(WidgetAlerts.flashSeconds(split), 10) + XCTAssertEqual(WidgetAlerts.flashSeconds(nil), 5, "flashing is on by default") + XCTAssertEqual(WidgetAlerts.flashSeconds(try prefs(#"{"alertFlashSeconds":0}"#)), 0) + } + + @MainActor + func testFlashingOneSessionLeavesEveryOtherSessionsTriggerAlone() { + let defaults = UserDefaults(suiteName: "widget.attention.\(UUID().uuidString)")! + let model = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults) + model.flashAttention("a", seconds: 5) + let other = model.attentionTrigger("b") + model.flashAttention("a", seconds: 5) + XCTAssertEqual(model.attentionTrigger("a"), 2) + XCTAssertEqual(model.attentionTrigger("b"), other, "an unchanged trigger never replays another session's flash") + model.flashAttention("b", seconds: 0) + XCTAssertEqual(model.attentionTrigger("b"), other, "0 seconds is off") + } + + func testTheHeaderReadsTheLastCallsContextAndRowsTheirNewestEntry() { + func turn(_ role: String, _ usage: TranscriptUsage?) -> TranscriptTurn { + TranscriptTurn(id: UUID().uuidString, role: role, at: nil, text: "", tools: [], usage: usage) + } + XCTAssertEqual(WidgetModel.contextTokens([ + turn("assistant", TranscriptUsage(inputTokens: 10, cacheReadTokens: 90)), + turn("assistant", TranscriptUsage(inputTokens: 5, cacheReadTokens: 120_000)), turn("user", nil), + ]), 120_005, "the newest call that reports usage, input plus cache reads") + XCTAssertNil(WidgetModel.contextTokens([turn("user", nil)])) + + let asked = WidgetInboxItem(id: "form:f", sourceId: "f", kind: "form", key: "k", at: 3_000, needsAnswer: true) + let answered = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 9_000, + needsAnswer: false) + let both = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 1, unreadItem: answered, pendingItem: asked) + XCTAssertEqual(WidgetSessionTimes.latest(both)?.verb, "asked", "a waiting question wins over a newer answer") + let read = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 0, unreadItem: answered) + XCTAssertEqual(WidgetSessionTimes.latest(read)?.verb, "answered") + XCTAssertNil(WidgetSessionTimes.latest(nil)) + } + func testTheSessionInboxFilterSplitsInboxEntriesFromDecisions() { let cards = ["answer", "result", "form", "decision", "todo"].map { kind in WidgetCard(id: kind, kind: kind, sessionKey: "k", sourceId: kind, at: 1, title: kind, body: "", From f23da41f74f33698d7579a9e596cc8f0b34c4952 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:29:02 +0200 Subject: [PATCH 026/251] feat(GenesisKit, markdown): open tabs fall into groups by project, folder, date or kind, folded, renamed, reordered and moved by drag in place, wrapped inline in at most four scrolling rows --- .../GenesisKitMarkdownConfiguration.swift | 25 ++ .../MarkdownDocumentsModel+TabGroups.swift | 174 ++++++++++ .../Window/MarkdownDocumentsModel.swift | 25 +- .../Window/MarkdownDocumentsView.swift | 327 +++++++++++++++++- .../Markdown/Window/MarkdownTabGrouping.swift | 248 +++++++++++++ .../Markdown/MarkdownTabGroupingTests.swift | 149 ++++++++ 6 files changed, 935 insertions(+), 13 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift create mode 100644 src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift index d167f89d9b..044ebf007c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift @@ -49,6 +49,22 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { /// A single click in the file tree opens a preview tab (italic title) that the next single click /// replaces. An edit, a double-click or a double-click on the tab keeps it. @Published public var previewTabs: Bool { didSet { store(previewTabs, "previewTabs") } } + /// How tabs fall into groups by themselves (`MarkdownTabGrouping`). `.off` keeps one flat row. + @Published public var tabGrouping: MarkdownTabGroupRule { didSet { store(tabGrouping.rawValue, "tabGrouping") } } + /// Groups appear once this many tabs are open; fewer stay one plain row. 0 groups always. + @Published public var tabGroupFrom: Int { didSet { store(tabGroupFrom, "tabGroupFrom") } } + /// An automatic group smaller than this joins "Other". + @Published public var tabGroupMinimum: Int { didSet { store(tabGroupMinimum, "tabGroupMinimum") } } + /// A Project group with more tabs than this splits by its next folder. + @Published public var tabGroupSplitAbove: Int { didSet { store(tabGroupSplitAbove, "tabGroupSplitAbove") } } + /// Wrapped tabs: groups flow one after another (the default), or each open group starts its own rows. + @Published public var tabGroupRows: Bool { didSet { store(tabGroupRows, "tabGroupRows") } } + /// Wrapped tabs take at most this many rows and scroll past them, with a fade at the cut edge. 0 is no limit. + @Published public var wrapTabsMaxRows: Int { didSet { store(wrapTabsMaxRows, "wrapTabsMaxRows") } } + /// The reader's in-place edits: group order, collapsed groups, names, tabs moved by hand. + @Published public var tabGroupState: MarkdownTabGroupState { + didSet { if let data = try? JSONEncoder().encode(tabGroupState) { store(data, "tabGroupState") } } + } // MARK: Chrome @@ -109,6 +125,15 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { hoverPreviewDelay = double("hoverPreviewDelay", 1.0) wrapTabs = bool("wrapTabs", false) previewTabs = bool("previewTabs", true) + tabGrouping = string("tabGrouping").flatMap(MarkdownTabGroupRule.init(rawValue:)) ?? .smart + func int(_ key: String, _ fallback: Int) -> Int { defaults.object(forKey: Self.prefix + key) as? Int ?? fallback } + tabGroupFrom = int("tabGroupFrom", 10) + tabGroupMinimum = int("tabGroupMinimum", 2) + tabGroupSplitAbove = int("tabGroupSplitAbove", 12) + tabGroupRows = bool("tabGroupRows", false) + wrapTabsMaxRows = int("wrapTabsMaxRows", 4) + tabGroupState = defaults.data(forKey: Self.prefix + "tabGroupState") + .flatMap { try? JSONDecoder().decode(MarkdownTabGroupState.self, from: $0) } ?? MarkdownTabGroupState() contrast = string("contrast").flatMap(MarkdownContrast.init(rawValue:)) ?? .high toolbarLabels = string("toolbarLabels").flatMap(MarkdownToolbarLabels.init(rawValue:)) ?? .whenRoom treeFontScalesWithWidth = bool("treeFontScalesWithWidth", true) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift new file mode 100644 index 0000000000..b9001099b1 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift @@ -0,0 +1,174 @@ +// +// MarkdownDocumentsModel+TabGroups.swift +// GenesisKit +// +// The tab groups of one window: computed by `MarkdownTabGrouping` from the open tabs, the rule and the reader's own +// edits (`GenesisKitMarkdownConfiguration.tabGroupState`), and the in-place actions the tab bar's menus and drags call. +// + +import Foundation + +/// The inputs of the last grouping and its result, so a tab bar pass that changed none of them reuses it. +final class MarkdownTabGroupMemo { + struct Inputs: Equatable { + var ids: [String] + var rule: MarkdownTabGroupRule + var state: MarkdownTabGroupState + var minimum: Int + var splitAbove: Int + var day: Int + } + + var inputs: Inputs? + var groups: [MarkdownTabGroup] = [] +} + +extension MarkdownDocumentsModel { + /// Whether the tab bar shows groups now: a rule is chosen and enough tabs are open. + var groupsTabs: Bool { + configuration.tabGrouping != .off && docs.count >= max(1, configuration.tabGroupFrom) + } + + /// The open tabs as groups, or none while `groupsTabs` is false. + var tabGroups: [MarkdownTabGroup] { + guard groupsTabs else { return [] } + let config = configuration + let inputs = MarkdownTabGroupMemo.Inputs( + ids: docs.map(\.id), rule: config.tabGrouping, state: config.tabGroupState, + minimum: config.tabGroupMinimum, splitAbove: config.tabGroupSplitAbove, + day: Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0) + if tabGroupMemo.inputs == inputs { return tabGroupMemo.groups } + let groups = MarkdownTabGrouping.groups( + docs.map { MarkdownTabGroupItem(id: $0.id, path: $0.url.path, title: $0.title) }, + rule: inputs.rule, state: inputs.state, minimumSize: inputs.minimum, splitAbove: inputs.splitAbove) + tabGroupMemo.inputs = inputs + tabGroupMemo.groups = groups + return groups + } + + /// The tab order the bar shows, for Next Tab and Previous Tab. + var visibleTabOrder: [String] { + let groups = tabGroups + guard !groups.isEmpty else { return docs.map(\.id) } + return MarkdownTabGrouping.visualOrder(groups, collapsed: configuration.tabGroupState.collapsed, + selected: selected?.id) + } + + func group(of tabID: String) -> MarkdownTabGroup? { + tabGroups.first { $0.tabIDs.contains(tabID) } + } + + // MARK: Collapse + + func toggleGroup(_ id: String) { + editGroups { state in + if state.collapsed.contains(id) { state.collapsed.remove(id) } else { state.collapsed.insert(id) } + } + } + + /// ⌥-click on a group: this one open, every other one folded. + func focusGroup(_ id: String) { + let others = Set(tabGroups.map(\.id)).subtracting([id]) + editGroups { state in state.collapsed = others } + } + + func setAllGroups(collapsed: Bool) { + let all = Set(tabGroups.map(\.id)) + editGroups { state in state.collapsed = collapsed ? all : [] } + } + + // MARK: Names and order + + func renameGroup(_ id: String, to title: String) { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + editGroups { state in state.names[id] = trimmed.isEmpty ? nil : trimmed } + } + + /// Moves a group before another one, or to the end with nil. The order is kept for groups that come back. + func moveGroup(_ id: String, before target: String?) { + guard id != target else { return } + var order = tabGroups.map(\.id) + order.removeAll { $0 == id } + let slot = target.flatMap { order.firstIndex(of: $0) } ?? order.endIndex + order.insert(id, at: slot) + let current = configuration.tabGroupState.order + editGroups { state in state.order = order + current.filter { !order.contains($0) } } + } + + /// One step left or right, from a group's menu. + func shiftGroup(_ id: String, by offset: Int) { + let ids = tabGroups.map(\.id) + guard let index = ids.firstIndex(of: id) else { return } + let target = index + offset + guard ids.indices.contains(target) else { return } + moveGroup(id, before: offset < 0 ? ids[target] : (target + 1 < ids.count ? ids[target + 1] : nil)) + } + + // MARK: Moving tabs between groups + + /// Puts a tab in a group by hand; nil gives it back to the rule. + func assign(_ tabID: String, to groupID: String?) { + editGroups { state in state.assigned[tabID] = groupID } + } + + /// A new group holding this tab, named after it until the reader renames it. + @discardableResult + func newGroup(with tabID: String, title: String? = nil) -> String { + let id = MarkdownTabGrouping.manualPrefix + UUID().uuidString.prefix(8).lowercased() + let name = title ?? "New Group" + editGroups { state in + state.assigned[tabID] = id + state.names[id] = name + state.collapsed.remove(id) + } + return id + } + + /// A tab dropped on another tab: it goes before that tab, into that tab's group. + func dropTab(_ dragged: String, before target: String) { + guard dragged != target else { return } + if groupsTabs, let destination = group(of: target), group(of: dragged)?.id != destination.id { + assign(dragged, to: destination.id) + } + moveTab(dragged, before: target) + } + + /// A tab dropped on a group's chip: it joins the group at its end and the group opens. + func dropTab(_ dragged: String, onGroup groupID: String) { + assign(dragged, to: groupID) + editGroups { state in state.collapsed.remove(groupID) } + let after = tabGroups.first { $0.id == groupID }?.tabIDs.last { $0 != dragged } + if let after, let index = docs.firstIndex(where: { $0.id == after }) { + moveTab(dragged, before: docs.indices.contains(index + 1) ? docs[index + 1].id : nil) + } + } + + /// Dissolves a group the reader made, or takes back every hand move into an automatic one. + func ungroup(_ groupID: String) { + editGroups { state in + state.assigned = state.assigned.filter { $0.value != groupID } + state.names[groupID] = nil + state.collapsed.remove(groupID) + state.order.removeAll { $0 == groupID } + } + } + + /// Closes every tab of a group; a dirty one asks first, as a single close does. + func closeGroup(_ groupID: String) { + guard let group = tabGroups.first(where: { $0.id == groupID }) else { return } + for id in group.tabIDs { + guard let doc = docs.first(where: { $0.id == id }) else { continue } + guard close(doc) else { return } + } + } + + /// One write per action: each assignment to the state publishes and re-runs the bar. + private func editGroups(_ change: (inout MarkdownTabGroupState) -> Void) { + var state = configuration.tabGroupState + change(&state) + // Hand moves of tabs that are no longer open are dropped, so the saved state cannot grow without end. + let open = Set(docs.map(\.id)) + state.assigned = state.assigned.filter { open.contains($0.key) } + if state != configuration.tabGroupState { configuration.tabGroupState = state } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift index 3fa0779fcb..ae6582772f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift @@ -51,6 +51,8 @@ final class MarkdownDocumentsModel: ObservableObject { /// The viewer's tunables (preview and wrapped tabs, hover previews, contrast, toolbar labels). The /// process-wide one; a test hands in its own. The window forwards its changes to this model. var configuration = GenesisKitMarkdownConfiguration.shared + /// The last grouping of the tabs and what it was computed from (`MarkdownDocumentsModel+TabGroups`). + let tabGroupMemo = MarkdownTabGroupMemo() /// Reading view, source, or both (⌘E / ⌘\), per window, not per file. /// Remembered between launches like the rest of the chrome. @Published var modeRaw: String = MarkdownViewMode.preview.rawValue { @@ -717,11 +719,26 @@ final class MarkdownDocumentsModel: ObservableObject { persistTabs() } - /// Next Tab / Previous Tab (`MarkdownKeymap`, ⇧⌘ with the two keys right of P): wrapping at the ends. + /// Next Tab / Previous Tab (`MarkdownKeymap`, ⇧⌘ with the two keys right of P): wrapping at the ends, in the + /// order the tab bar shows them (groups in their order, a collapsed group's tabs skipped). func selectTab(offset: Int) { - guard docs.count > 1, let current = docs.firstIndex(where: { $0.id == selected?.id }) else { return } - let next = ((current + offset) % docs.count + docs.count) % docs.count - select(docs[next].id) + let order = visibleTabOrder + guard order.count > 1, let current = order.firstIndex(where: { $0 == selected?.id }) else { return } + let next = ((current + offset) % order.count + order.count) % order.count + select(order[next]) + } + + /// Moves a tab before another one, or to the end with nil (a drag in the tab bar). The order is saved like any + /// other tab change. + func moveTab(_ id: String, before target: String?) { + guard id != target, let from = docs.firstIndex(where: { $0.id == id }) else { return } + var reordered = docs + let doc = reordered.remove(at: from) + let slot = target.flatMap { target in reordered.firstIndex { $0.id == target } } ?? reordered.endIndex + reordered.insert(doc, at: slot) + guard reordered.map(\.id) != docs.map(\.id) else { return } + docs = reordered + persistTabs() } /// ⌃⇧C, the toolbar, and a file row's Copy Path: the open note's path. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift index fa0ba46813..6d28495ba8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift @@ -182,7 +182,9 @@ struct MarkdownDocumentsView: View { } /// The tab row: one scrolling strip, or every tab on as many rows as it takes (`wrapTabs`). Either way the -/// selected tab is on screen. A right-click on its empty part offers Wrap Tabs. +/// selected tab is on screen. With enough tabs open they fall into groups (`MarkdownTabGrouping`): a chip per group +/// that folds it, drags that move tabs and groups, and menus that rename, regroup and close. A right-click on its +/// empty part offers Wrap Tabs and the grouping rule. private struct MarkdownTabBar: View { @ObservedObject var model: MarkdownDocumentsModel @ObservedObject var configuration: GenesisKitMarkdownConfiguration @@ -191,22 +193,36 @@ private struct MarkdownTabBar: View { let _ = RenderProbe.hit("md.tabBar") // Read once per pass: `selected` searches the tabs, and asking it per tab made a pass N² (2026-10-15). let selectedID = model.selected?.id + let groups = model.tabGroups Group { - if configuration.wrapTabs { - MarkdownTabFlow(spacing: 2, rowSpacing: 2) { - ForEach(model.docs) { doc in - tab(doc, selectedID: selectedID) + if groups.isEmpty { + if configuration.wrapTabs { + capped(selectedID: selectedID) { + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(model.docs) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } else { + strip(selectedID: selectedID) + } + } else if configuration.wrapTabs { + capped(selectedID: selectedID) { + if configuration.tabGroupRows { + wrapped(groups, selectedID: selectedID) + } else { + flowing(groups, selectedID: selectedID) } } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) } else { - strip(selectedID: selectedID) + groupedStrip(groups, selectedID: selectedID) } } .contentShape(Rectangle()) .contextMenu { + MarkdownTabGroupingMenu(configuration: configuration) + Divider() MarkdownWrapTabsToggle(configuration: configuration) } .accessibilityIdentifier("markdown-tab-bar") @@ -245,6 +261,287 @@ private struct MarkdownTabBar: View { } } } + + // MARK: Groups + + private var collapsed: Set { configuration.tabGroupState.collapsed } + + /// The tabs a group shows: all of them, or only the selected one while it is folded, so the tab you read never + /// disappears into a folded group. + private func shown(_ group: MarkdownTabGroup, selectedID: String?) -> [MarkdownDocument] { + let ids = collapsed.contains(group.id) ? group.tabIDs.filter { $0 == selectedID } : group.tabIDs + let byID = Dictionary(model.docs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ids.compactMap { byID[$0] } + } + + private func chip(_ group: MarkdownTabGroup) -> some View { + MarkdownTabGroupChip(group: group, collapsed: collapsed.contains(group.id), model: model) + } + + /// The wrapped bar: at most `wrapTabsMaxRows` rows (about 32 pt each), scrolling past them with a fade at the cut + /// edge, and following the selected tab. Shorter content keeps its own height (`FittedScroll`). + private func capped(selectedID: String?, @ViewBuilder content: @escaping () -> Content) -> some View { + let rows = configuration.wrapTabsMaxRows + return ScrollViewReader { proxy in + FittedScroll(maxHeight: rows > 0 ? CGFloat(rows) * 32 + 12 : 10_000) { + content() + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: selectedID, initial: true) { _, id in + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + } + } + + /// Wrapped, the default: every group's chip and its tabs flow on, one group after another. + private func flowing(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(groups) { group in + chip(group).padding(.leading, group.id == groups.first?.id ? 0 : 6) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } + + /// Wrapped with "Each Group on Its Own Rows": an open group starts its own rows with its chip; folded groups next + /// to each other share a row. + private func wrapped(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(MarkdownTabGroupRows.rows(groups, collapsed: collapsed)) { row in + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(row.groups) { group in + chip(group) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } + } + } + + private func groupedStrip(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(groups) { group in + chip(group).padding(.leading, group.id == groups.first?.id ? 0 : 8) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .onChange(of: selectedID, initial: true) { _, id in + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + } + } +} + +/// Which groups share a row in the wrapped bar: every open group is a row of its own, and a run of folded groups +/// is one row of chips. +enum MarkdownTabGroupRows { + struct Row: Identifiable, Equatable { + var groups: [MarkdownTabGroup] + var id: String { groups.map(\.id).joined(separator: "|") } + } + + static func rows(_ groups: [MarkdownTabGroup], collapsed: Set) -> [Row] { + var rows: [Row] = [] + for group in groups { + if collapsed.contains(group.id), let last = rows.last, last.groups.allSatisfy({ collapsed.contains($0.id) }) { + rows[rows.count - 1].groups.append(group) + } else { + rows.append(Row(groups: [group])) + } + } + return rows + } +} + +/// A group's chip: its colour, a chevron, its name and its count. A click folds or opens it, ⌥-click shows only +/// this group, a double-click renames it. Drop a tab on it to move the tab in; drag it onto another chip to reorder. +private struct MarkdownTabGroupChip: View { + let group: MarkdownTabGroup + let collapsed: Bool + let model: MarkdownDocumentsModel + @State private var renaming = false + @State private var name = "" + @State private var targeted = false + + private var color: Color { MarkdownTabGroupChip.color(for: group.id) } + + var body: some View { + Button(action: click) { + HStack(spacing: 5) { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .rotationEffect(.degrees(collapsed ? 0 : 90)) + Text(group.title).font(.system(size: 11.5, weight: .semibold)).lineLimit(1) + Text(verbatim: "\(group.tabIDs.count)") + .font(.system(size: 10.5, weight: .medium)).monospacedDigit() + .foregroundStyle(.secondary) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(color.opacity(targeted ? 0.32 : 0.16))) + .overlay(Capsule().strokeBorder(color.opacity(targeted ? 0.9 : 0.4), lineWidth: 1)) + .foregroundStyle(color) + .contentShape(Capsule()) + } + .buttonStyle(.genHoverPlain()) + .padding(.vertical, 2) + .instantTooltip(tooltip) + .accessibilityLabel("\(group.title) group, \(group.tabIDs.count) tabs, \(collapsed ? "folded" : "open")") + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("markdown-tab-group-" + group.id) + .draggable(MarkdownTabDrag.group(group.id)) + .dropDestination(for: String.self) { items, _ in + for item in items { + switch MarkdownTabDrag.parse(item) { + case .tab(let id): model.dropTab(id, onGroup: group.id) + case .group(let id): model.moveGroup(id, before: group.id) + case nil: return false + } + } + return true + } isTargeted: { targeted = $0 } + .contextMenu { menu } + .popover(isPresented: $renaming, arrowEdge: .bottom) { + TextField("Group name", text: $name) + .textFieldStyle(.roundedBorder) + .frame(width: 220) + .padding(12) + .onSubmit { + model.renameGroup(group.id, to: name) + renaming = false + } + } + } + + private var tooltip: TooltipContent { + TooltipContent(title: group.title + (group.detail.map { " · " + $0 } ?? ""), bullets: [ + "Click folds or opens the group, ⌥-click shows only this group", + "Double-click renames it", + "Drag a tab onto it to move the tab in; drag the group to reorder", + ]) + } + + private func click() { + if (NSApp.currentEvent?.clickCount ?? 1) >= 2 { + // The first click of the double-click already folded or opened it: put that back, then rename. + model.toggleGroup(group.id) + startRename() + } else if NSEvent.modifierFlags.contains(.option) { + model.focusGroup(group.id) + } else { + model.toggleGroup(group.id) + } + } + + private func startRename() { + name = group.title + renaming = true + } + + @ViewBuilder private var menu: some View { + Button("Rename Group…", action: startRename) + Button(collapsed ? "Open Group" : "Fold Group") { model.toggleGroup(group.id) } + Button("Show Only This Group") { model.focusGroup(group.id) } + Button("Open All Groups") { model.setAllGroups(collapsed: false) } + Button("Fold All Groups") { model.setAllGroups(collapsed: true) } + Divider() + Button("Move Group Left") { model.shiftGroup(group.id, by: -1) } + Button("Move Group Right") { model.shiftGroup(group.id, by: 1) } + if group.manual || model.configuration.tabGroupState.names[group.id] != nil { + Button(group.manual ? "Ungroup" : "Undo Changes to This Group") { model.ungroup(group.id) } + } + Divider() + Button("Close \(group.tabIDs.count == 1 ? "Tab" : "\(group.tabIDs.count) Tabs") in Group") { + model.closeGroup(group.id) + } + Divider() + MarkdownTabGroupingMenu(configuration: model.configuration) + } + + /// A stable colour per group id, so a group keeps its colour across launches and reorders. + static func color(for id: String) -> Color { + let palette: [Color] = [.blue, .purple, .pink, .orange, .teal, .green, .indigo, .mint, .cyan, .yellow, .red, .brown] + let hash = id.unicodeScalars.reduce(UInt32(5381)) { ($0 &* 33) &+ $1.value } + return palette[Int(hash % UInt32(palette.count))] + } +} + +/// What a drag in the tab bar carries: a tab or a group, as text with a prefix, so a drop knows which it got. +enum MarkdownTabDrag: Equatable { + case tab(String) + case group(String) + + static func tab(_ id: String) -> String { "genesis-tab:" + id } + static func group(_ id: String) -> String { "genesis-tab-group:" + id } + + static func parse(_ text: String) -> MarkdownTabDrag? { + if text.hasPrefix("genesis-tab-group:") { return .group(String(text.dropFirst("genesis-tab-group:".count))) } + if text.hasPrefix("genesis-tab:") { return .tab(String(text.dropFirst("genesis-tab:".count))) } + return nil + } +} + +/// "Group Tabs By" and "Group From", in the tab bar's menu, every chip's and every tab's. +struct MarkdownTabGroupingMenu: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + + var body: some View { + Picker("Group Tabs By", selection: $configuration.tabGrouping) { + ForEach(MarkdownTabGroupRule.allCases, id: \.self) { Text($0.title).tag($0) } + } + Picker("Group From", selection: $configuration.tabGroupFrom) { + Text("Always").tag(0) + Text("6 Open Tabs").tag(6) + Text("10 Open Tabs").tag(10) + Text("20 Open Tabs").tag(20) + } + Toggle("Each Group on Its Own Rows", isOn: $configuration.tabGroupRows) + Picker("Wrapped Tabs Show", selection: $configuration.wrapTabsMaxRows) { + Text("2 Rows").tag(2) + Text("4 Rows").tag(4) + Text("6 Rows").tag(6) + Text("Every Row").tag(0) + } + if configuration.tabGroupState != MarkdownTabGroupState() { + Button("Reset Group Names, Order and Moves") { configuration.tabGroupState = MarkdownTabGroupState() } + } + } +} + +/// "Move to Group" in a tab's menu: the groups there are, a new group, or back to the rule. +private struct MarkdownTabMoveMenu: View { + let doc: MarkdownDocument + let model: MarkdownDocumentsModel + + var body: some View { + let groups = model.tabGroups + let current = model.group(of: doc.id)?.id + Menu("Move to Group") { + ForEach(groups.filter { $0.id != current }) { group in + Button(group.title) { model.dropTab(doc.id, onGroup: group.id) } + } + if groups.count > 1 { Divider() } + Button("New Group with This Tab") { model.newGroup(with: doc.id) } + if model.configuration.tabGroupState.assigned[doc.id] != nil { + Button("Back to Its Own Group") { model.assign(doc.id, to: nil) } + } + } + } } /// "Wrap Tabs", in the tab bar's menu and in every tab's. @@ -332,8 +629,20 @@ private struct MarkdownDocumentTab: View, Equatable { } MarkdownTabMenu(doc: doc, model: model) Divider() + if model.groupsTabs { + MarkdownTabMoveMenu(doc: doc, model: model) + } + MarkdownTabGroupingMenu(configuration: model.configuration) MarkdownWrapTabsToggle(configuration: model.configuration) } + .draggable(MarkdownTabDrag.tab(doc.id)) { + Text(doc.title).font(.system(size: 12, weight: .medium)).padding(.horizontal, 12).padding(.vertical, 7) + } + .dropDestination(for: String.self) { items, _ in + guard case .tab(let dragged) = items.first.flatMap(MarkdownTabDrag.parse) else { return false } + model.dropTab(dragged, before: doc.id) + return true + } .instantTooltip(previews ? "" : doc.url.path) .markdownHoverPreview(active ? nil : doc.url, model: model) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift new file mode 100644 index 0000000000..bc5b42d366 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift @@ -0,0 +1,248 @@ +// +// MarkdownTabGrouping.swift +// GenesisKit +// +// Groups the open tabs into named, collapsible groups (Martin, 2026-10-23: 72 tabs wrapped into nine rows). The engine +// is pure: tabs, a rule and the reader's own edits in, ordered groups out. Both hosts (GenesisTools' markdown face and +// Genesis Markdown.app) get it through `MarkdownDocumentsView`, and a host may call it for its own lists. +// + +import Foundation + +/// How tabs fall into groups by themselves. The reader's own moves and names win over every rule. +public enum MarkdownTabGroupRule: String, CaseIterable, Codable, Sendable { + /// One flat row of tabs. + case off + /// The top folder under the folder every open tab shares; a group past `splitAbove` tabs splits by its next folder. + case smart + /// The folder each file sits in. + case folder + /// The date a note's name starts with (`2026-10-23-…`): today, yesterday, this week, this month, earlier, undated. + case day + /// The file's kind: notes, data, logs, diffs. + case kind + + public var title: String { + switch self { + case .off: return "Off" + case .smart: return "Project" + case .folder: return "Folder" + case .day: return "Date in Name" + case .kind: return "Kind" + } + } +} + +/// One open tab as the engine sees it. +public struct MarkdownTabGroupItem: Equatable, Sendable { + public var id: String + public var path: String + public var title: String + + public init(id: String, path: String, title: String) { + self.id = id + self.path = path + self.title = title + } +} + +/// A group of tabs, in the order the tab bar shows them. +public struct MarkdownTabGroup: Identifiable, Equatable, Sendable { + public var id: String + public var title: String + /// Where the group is, for a title two folders share ("Notes" in two projects) and for the chip's tooltip. + public var detail: String? + public var tabIDs: [String] + /// Made by the reader (a new group, or tabs moved into it); never merged into "Other". + public var manual: Bool +} + +/// What the reader changed in place: group order, collapsed groups, names, and tabs moved by hand. +public struct MarkdownTabGroupState: Codable, Equatable, Sendable { + public var order: [String] = [] + public var collapsed: Set = [] + public var names: [String: String] = [:] + /// Tab id to group id. A tab moved into a group stays there while that group exists under the current rule, or + /// always when the group is one the reader made (`manual:` ids). + public var assigned: [String: String] = [:] + + public init() {} +} + +public enum MarkdownTabGrouping { + public static let otherID = "other" + public static let manualPrefix = "manual:" + + /// The tabs as ordered groups. Empty for `.off`, which the tab bar shows as one flat row. + public static func groups( + _ items: [MarkdownTabGroupItem], rule: MarkdownTabGroupRule, state: MarkdownTabGroupState, + minimumSize: Int = 2, splitAbove: Int = 12, now: Date = Date(), calendar: Calendar = .current + ) -> [MarkdownTabGroup] { + guard rule != .off, !items.isEmpty else { return [] } + let automatic = keys(items, rule: rule, splitAbove: splitAbove, minimumSize: minimumSize, now: now, + calendar: calendar) + let automaticIDs = Set(automatic.values.map(\.id)) + var members: [String: [String]] = [:] + var info: [String: (title: String, detail: String?, manual: Bool)] = [:] + var firstSeen: [String] = [] + for item in items { + let key: (id: String, title: String, detail: String?, manual: Bool) + if let wanted = state.assigned[item.id], + wanted.hasPrefix(manualPrefix) || automaticIDs.contains(wanted) || wanted == otherID { + let known = automatic.values.first { $0.id == wanted } + key = (wanted, known?.title ?? (wanted == otherID ? "Other" : "Group"), known?.detail, + wanted.hasPrefix(manualPrefix)) + } else { + let auto = automatic[item.id] ?? (otherID, "Other", nil) + key = (auto.id, auto.title, auto.detail, false) + } + if members[key.id] == nil { firstSeen.append(key.id) } + members[key.id, default: []].append(item.id) + info[key.id] = (key.title, key.detail, key.manual || (info[key.id]?.manual ?? false)) + } + // A one-tab automatic group is noise in the bar: it joins "Other". The reader's own groups always stay. + for id in firstSeen where id != otherID { + guard let tabs = members[id], tabs.count < minimumSize, info[id]?.manual == false, + state.names[id] == nil, !tabs.contains(where: { state.assigned[$0] == id }) else { continue } + members[otherID, default: []].append(contentsOf: tabs) + members[id] = nil + if !firstSeen.contains(otherID) { firstSeen.append(otherID) } + info[otherID] = ("Other", nil, false) + } + let order = Dictionary(state.order.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first }) + let ids = firstSeen.filter { members[$0] != nil }.enumerated().sorted { left, right in + func rank(_ id: String, _ index: Int) -> (Int, Int) { + if let placed = order[id] { return (0, placed) } + return id == otherID ? (2, 0) : (1, index) + } + return rank(left.element, left.offset) < rank(right.element, right.offset) + }.map(\.element) + let position = Dictionary(items.enumerated().map { ($1.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ids.map { id in + MarkdownTabGroup( + id: id, title: state.names[id] ?? info[id]?.title ?? "Group", detail: info[id]?.detail, + tabIDs: (members[id] ?? []).sorted { (position[$0] ?? 0) < (position[$1] ?? 0) }, + manual: info[id]?.manual ?? false) + } + } + + /// The order Next Tab and Previous Tab walk: the groups' order, tabs in a collapsed group skipped unless selected. + public static func visualOrder(_ groups: [MarkdownTabGroup], collapsed: Set, selected: String?) -> [String] { + groups.flatMap { group in + collapsed.contains(group.id) ? group.tabIDs.filter { $0 == selected } : group.tabIDs + } + } + + // MARK: Automatic keys + + typealias Key = (id: String, title: String, detail: String?) + + static func keys( + _ items: [MarkdownTabGroupItem], rule: MarkdownTabGroupRule, splitAbove: Int, minimumSize: Int = 2, now: Date, + calendar: Calendar + ) -> [String: Key] { + switch rule { + case .off: return [:] + case .folder: + return Dictionary(items.map { item in + let folder = (item.path as NSString).deletingLastPathComponent + return (item.id, ("dir:" + folder, (folder as NSString).lastPathComponent, + parentName(folder))) + }, uniquingKeysWith: { first, _ in first }) + case .day: + return Dictionary(items.map { ($0.id, dayKey($0.title, now: now, calendar: calendar)) }, + uniquingKeysWith: { first, _ in first }) + case .kind: + return Dictionary(items.map { ($0.id, kindKey($0.path)) }, uniquingKeysWith: { first, _ in first }) + case .smart: + return smartKeys(items, splitAbove: splitAbove, minimumSize: minimumSize) + } + } + + /// Folders below the folder every tab shares: the first level names the group; a group larger than `splitAbove` + /// whose tabs sit in different folders one level deeper splits by that level, up to three times. + static func smartKeys(_ items: [MarkdownTabGroupItem], splitAbove: Int, minimumSize: Int = 2) -> [String: Key] { + let folders = items.map { item in + ((item.path as NSString).deletingLastPathComponent as NSString).pathComponents + } + var common = folders.first ?? [] + for components in folders.dropFirst() { + common = Array(zip(common, components).prefix { $0 == $1 }.map(\.0)) + } + // Depth of the component that names each tab's group; tabs right in the common folder stay at it. + var depth = Array(repeating: common.count, count: items.count) + func key(_ index: Int) -> String { + let components = folders[index] + return NSString.path(withComponents: Array(components.prefix(min(depth[index] + 1, components.count)))) + } + // A big group goes one level down when that level divides it into at least two real groups, or when the + // level holds one folder only (every note under `Vault/Notes/…`), which is passed through. A split into + // single tabs is refused: it would send the whole group to "Other" (72 vault notes did, 2026-10-23). A tab + // that sits higher than the level stays where it is. Bounded, as a path is. + for _ in 0 ..< 8 { + var byKey: [String: [Int]] = [:] + for index in items.indices { byKey[key(index), default: []].append(index) } + var moved = false + for (_, indexes) in byKey where indexes.count > splitAbove { + let deeper = indexes.filter { folders[$0].count > depth[$0] + 1 } + guard !deeper.isEmpty else { continue } + var sizes: [String: Int] = [:] + for index in deeper { sizes[folders[index][depth[index] + 1], default: 0] += 1 } + // A subfolder with tabs enough for a group of its own moves down; single tabs stay in the parent group. + let real = Set(sizes.filter { $0.value >= max(2, minimumSize) }.keys) + guard sizes.count == 1 || !real.isEmpty else { continue } + for index in deeper where sizes.count == 1 || real.contains(folders[index][depth[index] + 1]) { + depth[index] += 1 + } + moved = true + } + if !moved { break } + } + var result: [String: Key] = [:] + for (index, item) in items.enumerated() { + let path = key(index) + let components = (path as NSString).pathComponents + let title = components.last ?? path + let detail = components.count > 1 && depth[index] > common.count ? components[components.count - 2] : nil + result[item.id] = ("dir:" + path, title, detail) + } + return result + } + + static func dayKey(_ title: String, now: Date, calendar: Calendar) -> Key { + // The date in the name is a calendar day in the reader's calendar, not an instant in some other zone. + let parts = title.prefix(10).split(separator: "-").compactMap { Int($0) } + guard title.count >= 10, title.prefix(10).filter({ $0 == "-" }).count == 2, parts.count == 3, + let date = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) else { + return ("day:none", "Undated", nil) + } + let today = calendar.startOfDay(for: now) + let day = calendar.startOfDay(for: date) + let days = calendar.dateComponents([.day], from: day, to: today).day ?? 0 + switch days { + case ...0: return ("day:today", "Today", nil) + case 1: return ("day:yesterday", "Yesterday", nil) + case 2 ..< 7: return ("day:week", "This Week", nil) + case 7 ..< 31: return ("day:month", "This Month", nil) + default: return ("day:earlier", "Earlier", nil) + } + } + + static func kindKey(_ path: String) -> Key { + switch (path as NSString).pathExtension.lowercased() { + case "md", "markdown", "mdx", "txt": return ("kind:notes", "Notes", nil) + case "json", "jsonl", "yaml", "yml", "toml", "plist": return ("kind:data", "Data", nil) + case "log": return ("kind:logs", "Logs", nil) + case "diff", "patch": return ("kind:diffs", "Diffs", nil) + case "": return ("kind:other", "No Extension", nil) + case let ext: return ("kind:" + ext, ext.uppercased(), nil) + } + } + + private static func parentName(_ folder: String) -> String? { + let parent = (folder as NSString).deletingLastPathComponent + let name = (parent as NSString).lastPathComponent + return name.isEmpty || name == "/" ? nil : name + } + +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift new file mode 100644 index 0000000000..003d034986 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift @@ -0,0 +1,149 @@ +import AppKit +import SwiftUI +import XCTest +@testable import GenesisKit + +/// The tab groups: the engine's rules, the reader's own edits, and the model actions the tab bar's drags and menus call. +@MainActor +final class MarkdownTabGroupingTests: XCTestCase { + private func item(_ path: String) -> MarkdownTabGroupItem { + MarkdownTabGroupItem(id: path, path: path, title: ((path as NSString).lastPathComponent as NSString).deletingPathExtension) + } + + func testProjectGroupsByTheTopFolderAndSendsSingleTabsToOther() { + let items = ["/v/Alpha/a.md", "/v/Beta/b.md", "/v/Alpha/deep/c.md", "/v/Beta/d.md", "/v/Gamma/e.md"].map(item) + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: .init()) + XCTAssertEqual(groups.map(\.title), ["Alpha", "Beta", "Other"], "first appearance order, Other last") + XCTAssertEqual(groups[0].tabIDs, ["/v/Alpha/a.md", "/v/Alpha/deep/c.md"]) + XCTAssertEqual(groups[2].tabIDs, ["/v/Gamma/e.md"], "a one-tab group joins Other") + XCTAssertTrue(MarkdownTabGrouping.groups(items, rule: .off, state: .init()).isEmpty) + } + + func testABigProjectGroupSplitsByItsNextFolder() { + let big = (0 ..< 7).map { "/v/Big/P/p\($0).md" } + (0 ..< 7).map { "/v/Big/Q/q\($0).md" } + let items = (big + ["/v/Small/s1.md", "/v/Small/s2.md"]).map(item) + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: .init(), splitAbove: 12) + XCTAssertEqual(groups.map(\.title), ["P", "Q", "Small"]) + XCTAssertEqual(groups.first?.detail, "Big", "a split group names where it is") + XCTAssertEqual(MarkdownTabGrouping.groups(items, rule: .smart, state: .init(), splitAbove: 20).map(\.title), + ["Big", "Small"], "under the limit it stays one group") + } + + func testTheReadersMovesNamesOrderAndFoldsWin() { + let items = ["/v/A/1.md", "/v/A/2.md", "/v/B/3.md", "/v/B/4.md"].map(item) + var state = MarkdownTabGroupState() + state.assigned["/v/A/2.md"] = "manual:mine" + state.names["manual:mine"] = "Reading" + state.order = ["dir:/v/B"] + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: state, minimumSize: 1) + XCTAssertEqual(groups.map(\.title), ["B", "A", "Reading"], "the saved order first, then by first appearance") + XCTAssertEqual(groups.last?.manual, true) + XCTAssertEqual(groups.last?.tabIDs, ["/v/A/2.md"]) + let order = MarkdownTabGrouping.visualOrder(groups, collapsed: ["dir:/v/B"], selected: "/v/B/4.md") + XCTAssertEqual(order, ["/v/B/4.md", "/v/A/1.md", "/v/A/2.md"], "a folded group keeps only the selected tab") + } + + func testDayAndKindRules() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-10-23T12:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC")) + let items = ["/v/2026-10-23-A.md", "/v/2026-10-22-B.md", "/v/2026-10-19-C.md", "/v/2026-09-01-D.md", + "/v/Notes.md", "/v/setup.log"].map(item) + let days = MarkdownTabGrouping.groups(items, rule: .day, state: .init(), minimumSize: 1, now: now, + calendar: calendar) + XCTAssertEqual(days.map(\.title), ["Today", "Yesterday", "This Week", "Earlier", "Undated"]) + let kinds = MarkdownTabGrouping.groups(items, rule: .kind, state: .init(), minimumSize: 1) + XCTAssertEqual(kinds.map(\.title), ["Notes", "Logs"]) + } + + func testFoldedGroupsNextToEachOtherShareARow() { + func group(_ id: String) -> MarkdownTabGroup { + MarkdownTabGroup(id: id, title: id, detail: nil, tabIDs: [id + "1"], manual: false) + } + let rows = MarkdownTabGroupRows.rows([group("a"), group("b"), group("c"), group("d")], collapsed: ["a", "b", "d"]) + XCTAssertEqual(rows.map { $0.groups.map(\.id) }, [["a", "b"], ["c"], ["d"]]) + } + + func testDragsMoveTabsIntoGroupsAndReorderGroups() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("md-tab-groups-\(UUID().uuidString)") + let paths = ["A/1.md", "A/2.md", "B/3.md", "B/4.md"].map { folder.appendingPathComponent($0) } + for url in paths { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "# \(url.lastPathComponent)".write(to: url, atomically: true, encoding: .utf8) + } + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-groups-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-groups-config-\(UUID().uuidString)")!) + model.configuration.tabGroupFrom = 0 + model.open(paths) + let ids = model.docs.map(\.id) + XCTAssertEqual(model.tabGroups.map(\.title), ["A", "B"]) + + model.dropTab(ids[0], before: ids[3]) + XCTAssertEqual(model.group(of: ids[0])?.title, "B", "a tab dropped on a tab joins that tab's group") + XCTAssertEqual(model.docs.map(\.id), [ids[1], ids[2], ids[0], ids[3]], "and sits before it") + + model.moveGroup("dir:" + folder.appendingPathComponent("B").path, before: nil) + XCTAssertEqual(model.tabGroups.map(\.title).last, "B", "a group dragged to the end stays there") + + let mine = model.newGroup(with: ids[1]) + model.renameGroup(mine, to: "Reading") + XCTAssertEqual(model.group(of: ids[1])?.title, "Reading") + model.toggleGroup(mine) + XCTAssertFalse(model.visibleTabOrder.contains(ids[1]) && model.selected?.id != ids[1], + "a folded group's tabs leave Next Tab's order unless selected") + model.ungroup(mine) + XCTAssertNotEqual(model.group(of: ids[1])?.title, "Reading", "ungrouping gives the tab back to its rule") + + model.configuration.tabGroupFrom = 10 + XCTAssertTrue(model.tabGroups.isEmpty, "below the threshold the bar stays one flat row") + } + + /// Renders a 72-tab bar of real vault notes (read only) to a PNG, for the eye: + /// MARKDOWN_TAB_GROUPS_RENDER= [MARKDOWN_TAB_GROUPS_ROOT=] swift test --filter MarkdownTabGrouping + func testRenderSeventyTwoTabsGrouped() throws { + let env = ProcessInfo.processInfo.environment + guard let out = env["MARKDOWN_TAB_GROUPS_RENDER"] else { throw XCTSkip("set MARKDOWN_TAB_GROUPS_RENDER") } + let root = URL(fileURLWithPath: env["MARKDOWN_TAB_GROUPS_ROOT"] ?? NSHomeDirectory()) + // Spread over the root's top folders, as a reader's tabs are: at most 3 notes per folder, 12 per top folder. + var notes: [URL] = [] + var perFolder: [String: Int] = [:] + var perTop: [String: Int] = [:] + let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + while let url = walker?.nextObject() as? URL, notes.count < 72 { + let folder = url.deletingLastPathComponent().path + let top = url.pathComponents.dropFirst(root.pathComponents.count).first ?? "" + guard url.pathExtension == "md", perFolder[folder, default: 0] < 3, perTop[top, default: 0] < 12 else { + continue + } + perFolder[folder, default: 0] += 1 + perTop[top, default: 0] += 1 + notes.append(url) + } + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-render-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-render-config-\(UUID().uuidString)")!) + model.configuration.wrapTabs = env["MARKDOWN_TAB_GROUPS_WRAP"] != "0" + model.open(notes) + if let folded = model.tabGroups.dropFirst(2).first { model.toggleGroup(folded.id) } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1900, height: 1150), + styleMask: [.titled, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = MarkdownDocumentWindowController.contentView(model: model) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { + window.orderOut(nil) + window.close() + } + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.05)) } + let view = try XCTUnwrap(window.contentView) + let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: out)) + print("groups:", model.tabGroups.map { "\($0.title)(\($0.tabIDs.count))" }.joined(separator: " ")) + } +} From 1ac711d2a94ea4869de1390678ac711c0949e890 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:29:12 +0200 Subject: [PATCH 027/251] feat(GenesisKit): Claude, Codex and Grok show their marks instead of letters everywhere, drawn by one AIProviderMark behind AIProviderGlyph, ProviderBadge and the transcript avatar, with SVG arcs in the path parser --- .../GenesisKit/Controls/ForgeBadge.swift | 93 +++++++++++++++++- .../GenesisKit/Controls/ProviderBadge.swift | 9 +- .../GenesisKit/Providers/AIProviderMark.swift | 97 +++++++++++++++++++ .../GenesisKit/Providers/AIProviderMeta.swift | 15 ++- .../Transcript/SessionTranscriptList.swift | 22 +++-- .../GenesisKitTests/ForgeBadgeTests.swift | 49 +++++++++- 6 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift index 5cd3544c3b..7f183692f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift @@ -96,8 +96,9 @@ public struct ForgeBadge: View { } } -/// The subset of SVG path data the forge marks use: M, L, H, V, C, S and Z, absolute and relative, with -/// implicit repeats and the compact number forms (`.4.07`, `-.17`). Anything else ends the path there. +/// The subset of SVG path data the forge and provider marks use: M, L, H, V, C, S, A and Z, absolute and relative, +/// with implicit repeats and the compact number forms (`.4.07`, `-.17`, arc flags packed as `01-.104`). Anything else +/// ends the path there. enum SVGPath { static func parse(_ data: String) -> Path { var path = Path() @@ -159,6 +160,13 @@ enum SVGPath { path.addCurve(to: target, control1: first, control2: second) current = target lastControl = second + case "A": + guard let rx = tokens.number(), let ry = tokens.number(), let rotation = tokens.number(), + let large = tokens.flag(), let sweep = tokens.flag(), let target = point() else { return path } + addArc(to: &path, from: current, radii: CGSize(width: rx, height: ry), rotation: rotation, + large: large, sweep: sweep, end: target) + current = target + lastControl = nil default: return path } @@ -166,9 +174,68 @@ enum SVGPath { return path } + /// An elliptical arc as cubic curves, at most a quarter turn each (SVG 1.1 implementation notes F.6.5 and F.6.6). + static func addArc( + to path: inout Path, from start: CGPoint, radii: CGSize, rotation: CGFloat, large: Bool, sweep: Bool, + end: CGPoint + ) { + var rx = abs(radii.width) + var ry = abs(radii.height) + guard rx > 0, ry > 0, start != end else { + path.addLine(to: end) + return + } + let phi = rotation * .pi / 180 + let cosPhi = cos(phi), sinPhi = sin(phi) + let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2 + let x1 = cosPhi * dx + sinPhi * dy + let y1 = -sinPhi * dx + cosPhi * dy + // Radii too small to reach the end point grow until they just do. + let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry) + if lambda > 1 { + rx *= sqrt(lambda) + ry *= sqrt(lambda) + } + let numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1 + let denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1 + var factor = sqrt(max(0, numerator / denominator)) + if large == sweep { factor = -factor } + let cx1 = factor * rx * y1 / ry + let cy1 = -factor * ry * x1 / rx + let center = CGPoint(x: cosPhi * cx1 - sinPhi * cy1 + (start.x + end.x) / 2, + y: sinPhi * cx1 + cosPhi * cy1 + (start.y + end.y) / 2) + func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat { + let value = atan2(ux * vy - uy * vx, ux * vx + uy * vy) + return value + } + let theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry) + var delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry) + if !sweep, delta > 0 { delta -= 2 * .pi } + if sweep, delta < 0 { delta += 2 * .pi } + let segments = max(1, Int(ceil(abs(delta) / (.pi / 2)))) + let step = delta / CGFloat(segments) + let k = 4 / 3 * tan(step / 4) + func onEllipse(_ t: CGFloat) -> CGPoint { + CGPoint(x: center.x + rx * cos(t) * cosPhi - ry * sin(t) * sinPhi, + y: center.y + rx * cos(t) * sinPhi + ry * sin(t) * cosPhi) + } + func tangent(_ t: CGFloat) -> CGPoint { + CGPoint(x: -rx * sin(t) * cosPhi - ry * cos(t) * sinPhi, y: -rx * sin(t) * sinPhi + ry * cos(t) * cosPhi) + } + var t = theta + for index in 0 ..< segments { + let next = t + step + let from = onEllipse(t), to = index == segments - 1 ? end : onEllipse(next) + let d1 = tangent(t), d2 = tangent(next) + path.addCurve(to: to, control1: CGPoint(x: from.x + k * d1.x, y: from.y + k * d1.y), + control2: CGPoint(x: to.x - k * d2.x, y: to.y - k * d2.y)) + t = next + } + } + private enum Token: Equatable { case command(Character) - case number(CGFloat) + case number(CGFloat, raw: String) } private struct Tokens { @@ -178,7 +245,7 @@ enum SVGPath { init(_ text: String) { var number = "" func flush() { - if let value = Double(number) { items.append(.number(CGFloat(value))) } + if let value = Double(number) { items.append(.number(CGFloat(value), raw: number)) } number = "" } for char in text { @@ -206,9 +273,25 @@ enum SVGPath { mutating func advance() { index += 1 } mutating func number() -> CGFloat? { - guard case .number(let value) = peek() else { return nil } + guard case .number(let value, _) = peek() else { return nil } index += 1 return value } + + /// An arc flag: one digit, which may share its token with what follows (`01-.104` is flags 0 and 1). + mutating func flag() -> Bool? { + guard case .number(_, let raw) = peek(), let first = raw.first, first == "0" || first == "1" else { + return nil + } + let rest = String(raw.dropFirst()) + if rest.isEmpty { + index += 1 + } else if let value = Double(rest) { + items[index] = .number(CGFloat(value), raw: rest) + } else { + return nil + } + return first == "1" + } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift index 9e4c3cf14e..28c7b06f8b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift @@ -1,6 +1,7 @@ import SwiftUI -/// An agent's initial in its colour: C (Claude), X (Codex), G (Grok), else the first letter. +/// An agent's mark on a square in its colour (`AIProviderMark`): Claude, Codex, Grok; any other agent keeps its first +/// letter. public struct ProviderBadge: View { let provider: String var size: CGFloat @@ -23,11 +24,7 @@ public struct ProviderBadge: View { public var body: some View { let style = Self.style(for: provider) - Text(verbatim: style.letter) - .font(.system(size: (size * 0.56).rounded(), weight: .bold, design: .rounded)) - .foregroundColor(.black.opacity(0.8)) - .frame(width: size, height: size) - .background(RoundedRectangle(cornerRadius: size * 0.28).fill(style.color)) + AIProviderGlyphBody(provider: provider, letter: style.letter, color: style.color, size: size, style: .filled) .instantTooltip(tooltip ?? provider) .accessibilityLabel(Text(tooltip ?? provider)) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift new file mode 100644 index 0000000000..ce6054df4d --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// A coding agent's logo as a shape: Claude's starburst, Codex's terminal cloud, Grok's slashed ring. Every surface +/// that names an agent by its mark draws it through this (`AIProviderGlyph`, `ProviderBadge`), so a change lands +/// everywhere at once. Paths from LobeHub's icon set (MIT, `@lobehub/icons-static-svg`), 24 × 24, even-odd fill. +public struct AIProviderMark: Shape { + public let kind: Kind + + public enum Kind: String, CaseIterable, Sendable { + case claude, codex, grok + + /// The mark for a provider id or alias (`claude`, `anthropic-sub`, `codex`, `openai-sub`, `grok`, `grok-sub`); + /// nil for one with no mark, which keeps its letter. + public init?(provider: String) { + switch provider.lowercased() { + case "claude", "anthropic", "anthropic-sub": self = .claude + case "codex", "openai", "openai-sub": self = .codex + case "grok", "xai", "grok-sub": self = .grok + default: return nil + } + } + + /// The brand colour of the mark itself (Claude's terracotta; Codex and Grok are drawn in white). + public var brandColor: Color { + switch self { + case .claude: return Color(red: 0.851, green: 0.467, blue: 0.341) + case .codex, .grok: return Color.white.opacity(0.92) + } + } + } + + public init(_ kind: Kind) { + self.kind = kind + } + + public func path(in rect: CGRect) -> Path { + let unit = Self.paths[kind] ?? Path() + let scale = min(rect.width, rect.height) / 24 + let dx = rect.minX + (rect.width - 24 * scale) / 2 + let dy = rect.minY + (rect.height - 24 * scale) / 2 + return unit.applying(CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: dx, ty: dy)) + } + + /// Parsed once: these paths are drawn in every session row. + static let paths: [Kind: Path] = [ + .claude: SVGPath.parse( + "M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"), + .codex: SVGPath.parse( + "M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"), + .grok: SVGPath.parse( + "M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"), + ] +} + +/// How a provider glyph is drawn. +public enum AIProviderGlyphStyle: Sendable { + /// The mark in its provider colour on a tinted rounded square: row leaders, rails. + case tinted + /// The mark alone, in its provider colour: inline in text and tight spaces. + case plain + /// The mark in dark on a solid provider-coloured square (`ProviderBadge`). + case filled +} + +/// One provider glyph in any style; a provider with no mark keeps its letter. +struct AIProviderGlyphBody: View { + let provider: String + let letter: String + let color: Color + let size: CGFloat + let style: AIProviderGlyphStyle + + var body: some View { + let kind = AIProviderMark.Kind(provider: provider) + let inset = style == .plain ? 0 : size * 0.18 + ZStack { + switch style { + case .tinted: + RoundedRectangle(cornerRadius: size / 4).fill(color.opacity(0.18)) + case .filled: + RoundedRectangle(cornerRadius: size * 0.28).fill(color) + case .plain: + EmptyView() + } + if let kind { + AIProviderMark(kind) + .fill(style == .filled ? Color.black.opacity(0.8) : color, style: FillStyle(eoFill: true)) + .padding(inset) + } else { + Text(verbatim: letter) + .font(.system(size: size * 0.6, weight: .bold, design: .rounded)) + .foregroundStyle(style == .filled ? Color.black.opacity(0.8) : color) + } + } + .frame(width: size, height: size) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift index f76b8b309b..641e8ea70b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift @@ -51,23 +51,22 @@ public enum AIProviders { } } -/// The provider's one-letter glyph in its tinted box: the leader of an account row, and -/// the marker on a session row that is not Claude's. +/// The provider's mark (`AIProviderMark`) in its tinted box: the leader of an account row, and the marker on a session +/// row. A provider with no mark keeps its letter. `style` picks the tinted box, the bare mark or a solid badge. public struct AIProviderGlyph: View { public var meta: AIProviderMeta public var size: CGFloat + public var style: AIProviderGlyphStyle - public init(meta: AIProviderMeta, size: CGFloat = 16) { + public init(meta: AIProviderMeta, size: CGFloat = 16, style: AIProviderGlyphStyle = .tinted) { self.meta = meta self.size = size + self.style = style } public var body: some View { - Text(meta.glyph) - .font(.system(size: size * 0.625, weight: .bold, design: .monospaced)) - .frame(width: size, height: size) - .background(meta.color.opacity(0.18), in: RoundedRectangle(cornerRadius: size / 4)) - .foregroundStyle(meta.color) + AIProviderGlyphBody(provider: meta.alias, letter: meta.glyph, color: meta.color, size: size, style: style) + .accessibilityElement() .accessibilityLabel(meta.displayName) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift index 06b2b9ce0d..7be437f335 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift @@ -1241,13 +1241,23 @@ private struct Avatar: View { let glyph: String let color: Color var solid = false + /// An agent's id or alias: its mark (`AIProviderMark`) replaces the letter. + var provider: String? var body: some View { - Text(verbatim: glyph) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(solid ? Color.black.opacity(0.85) : color) - .frame(width: 22, height: 22) - .background(Circle().fill(solid ? color : color.opacity(0.18))) + Group { + if let kind = provider.flatMap(AIProviderMark.Kind.init(provider:)) { + AIProviderMark(kind) + .fill(solid ? Color.black.opacity(0.85) : color, style: FillStyle(eoFill: true)) + .padding(5) + } else { + Text(verbatim: glyph) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(solid ? Color.black.opacity(0.85) : color) + } + } + .frame(width: 22, height: 22) + .background(Circle().fill(solid ? color : color.opacity(0.18))) } } @@ -1467,7 +1477,7 @@ private struct ReplyBlock: View { private var authorRow: some View { HStack(spacing: 8) { - Avatar(glyph: provider.glyph, color: provider.color) + Avatar(glyph: provider.glyph, color: provider.color, provider: provider.alias) Text(verbatim: provider.displayName) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(SessionPalette.text) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift index e54ea1ecae..8f346c22d6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import XCTest @testable import GenesisKitBase @@ -20,7 +21,7 @@ final class ForgeBadgeTests: XCTestCase { } func testAnUnknownCommandEndsThePathInsteadOfMisdrawing() { - let path = SVGPath.parse("M0 0L4 4A1 1 0 0 0 9 9") + let path = SVGPath.parse("M0 0L4 4Q1 1 9 9") XCTAssertEqual(path.boundingRect, CGRect(x: 0, y: 0, width: 4, height: 4)) } @@ -31,4 +32,50 @@ final class ForgeBadgeTests: XCTestCase { XCTAssertNil(Forge(kind: "bitbucket")) XCTAssertNil(Forge(kind: nil)) } + + func testProviderMarksParseToTheEndAndFillTheirBox() { + for kind in AIProviderMark.Kind.allCases { + let box = (AIProviderMark.paths[kind] ?? Path()).boundingRect + XCTAssertGreaterThan(box.width, 20, "\(kind) spans its 24-unit box (a path cut short by an arc stays small)") + XCTAssertGreaterThan(box.height, 20, "\(kind)") + XCTAssertLessThanOrEqual(box.maxX, 24.5, "\(kind)") + XCTAssertGreaterThanOrEqual(box.minY, -0.5, "\(kind)") + } + XCTAssertEqual(AIProviderMark.Kind(provider: "anthropic-sub"), .claude) + XCTAssertEqual(AIProviderMark.Kind(provider: "openai-sub"), .codex) + XCTAssertEqual(AIProviderMark.Kind(provider: "Grok"), .grok) + XCTAssertNil(AIProviderMark.Kind(provider: "gemini"), "an agent with no mark keeps its letter") + } + + func testAnArcWithPackedFlagsReachesItsEndPoint() { + // `a1 1 0 012 0` is flags 0 and 1, then the point (2, 0): a half circle from (0, 0) to (2, 0). + let path = SVGPath.parse("M0 0a1 1 0 012 0") + XCTAssertEqual(path.currentPoint?.x ?? 0, 2, accuracy: 0.001) + XCTAssertEqual(path.boundingRect.height, 1, accuracy: 0.05, "the half circle bulges by its radius") + } + + /// Renders every provider glyph style to a PNG, for the eye: PROVIDER_MARKS_RENDER=. + @MainActor + func testRenderProviderMarks() throws { + guard let out = ProcessInfo.processInfo.environment["PROVIDER_MARKS_RENDER"] else { + throw XCTSkip("set PROVIDER_MARKS_RENDER") + } + let view = VStack(alignment: .leading, spacing: 10) { + ForEach(["claude", "codex", "grok", "gemini"], id: \.self) { provider in + HStack(spacing: 12) { + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 32) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 18) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 11) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 24, style: .plain) + ProviderBadge(provider: provider, size: 24) + Text(provider).foregroundStyle(.white) + } + } + }.padding(16).background(Color(white: 0.1)) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let rep = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation))) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: out)) + } } From 917e29d5bd2b25a9d1668f2bd154c505b49c0cb0 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:47:36 +0200 Subject: [PATCH 028/251] feat(agents, hooks): a press on another session's cache chip in the mods band focuses the cmux pane that session runs in --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../genesis-tools-mods/hooks/cache-band.tsx | 39 +++++++++++++++---- plugins/genesis-tools-mods/hooks/register.tsx | 1 + plugins/genesis-tools-mods/hooks/shared.ts | 2 + src/agents/README.md | 2 +- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 26a078ff51..2a20ff9b76 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, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Install with: tools agents hooks mods install --write.", - "version": "1.9.0", + "version": "1.9.1", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 6ef373030b..4d4db7d314 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools-mods", - "version": "1.9.0", + "version": "1.9.1", "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools-mods/hooks/cache-band.tsx b/plugins/genesis-tools-mods/hooks/cache-band.tsx index 58a66f684c..c700e30ed6 100644 --- a/plugins/genesis-tools-mods/hooks/cache-band.tsx +++ b/plugins/genesis-tools-mods/hooks/cache-band.tsx @@ -150,6 +150,21 @@ export async function markEnded(host: Host): Promise { await mark(host, true).catch(() => undefined); } +/** Focuses the cmux pane the row's session runs in (`tools claude cmux focus`); says so when no pane has it. */ +async function focusSession(host: Host, row: CacheRow): Promise { + try { + const run = await host.run(["tools", "claude", "cmux", "focus", row.sessionId, "--first"], { + timeoutMs: 15_000, + }); + + if (run.exitCode !== 0) { + host.toast(`No cmux pane runs ${row.label}`); + } + } catch { + host.toast(`Could not focus ${row.label}: tools claude cmux focus did not run`); + } +} + /** The band's cache row, or null with nothing to show. */ export async function cacheRow(host: Host, ui: ElementTable): Promise { const held = rows; @@ -159,7 +174,7 @@ export async function cacheRow(host: Host, ui: ElementTable): Promise { @@ -174,14 +189,22 @@ export async function cacheRow(host: Host, ui: ElementTable): Promise - {` ${name} ${shortDuration(left)} `} - + const text = + left <= warnMs ? ( + + {` ${name} ${shortDuration(left)} `} + + ) : ( + {`${name} ${Math.ceil(left / 60_000)}m`} + ); + + // Another session's chip is a Button: a press focuses the cmux pane that session runs in. + return row.own ? ( + {text} ) : ( - - {`${name} ${Math.ceil(left / 60_000)}m`} - + ); }; diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 332d7078f4..5843008b94 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -356,6 +356,7 @@ function hostOf($: EngineInterface): Host { closePane: (id) => $.ui.close({ id }), copy: (text) => $.ui.copy({ text }), openUrl: (url) => $.process.run(["open", url], { timeoutMs: 10_000 }), + toast: (text) => $.ui.toast(text), redraw: () => $.ui.invalidate("ui.render"), }; } diff --git a/plugins/genesis-tools-mods/hooks/shared.ts b/plugins/genesis-tools-mods/hooks/shared.ts index 4551fd224c..8fb3a3fd62 100644 --- a/plugins/genesis-tools-mods/hooks/shared.ts +++ b/plugins/genesis-tools-mods/hooks/shared.ts @@ -49,6 +49,8 @@ export interface Host { copy: (text: string) => Promise; /** Opens a URL with macOS `open`: a browser page, or a genesis.tools route. */ openUrl: (url: string) => Promise; + /** A short notice that fades by itself. */ + toast: (text: string) => void; /** Draws this plugin's `ui.render` hooks again, so a band or pane shows the state just changed. */ redraw: () => void; } diff --git a/src/agents/README.md b/src/agents/README.md index e5cb003a9f..37ac4ee84e 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -254,7 +254,7 @@ checked against real use. - `mods.askRow`: `[ask]` beside a finished tool row puts the row, quoted, into the prompt. - `mods.spinner`: the spinner line names the running tool, its time and the working subagents. - `mods.cacheBand` (`warnMinutes` 10, `others`): the prompt cache countdown, red under `warnMinutes`, and the other - sessions whose cache runs out within it. + sessions whose cache runs out within it. A press on another session's chip focuses the cmux pane it runs in. - `mods.tmpUsage` (`minutes` 5): the size of the session's `/tmp/cc` folder and the free disk. - `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls; more than three fold into one `▸ 8 agents` line. A press on an agent, or `/mod-agents`, opens the Agents sidebar: From 0bf25e6ace27c80b4c8d254860bf6270ede479ab Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:49:19 +0200 Subject: [PATCH 029/251] =?UTF-8?q?feat(agents,=20hooks):=20the=20git=20ba?= =?UTF-8?q?nd=20says=20what=20its=20counts=20are=20(=E2=86=9114=20to=20pus?= =?UTF-8?q?h=20=C2=B7=20=E2=86=932=20to=20pull=204=20files=20changed)=20an?= =?UTF-8?q?d=20shows=20the=20added=20and=20removed=20lines,=20with=20mods.?= =?UTF-8?q?git.labels=20minimal=20for=20the=20bare=20symbols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- plugins/genesis-tools-mods/hooks/git-band.tsx | 50 +++++++++++++++++-- plugins/genesis-tools-mods/hooks/settings.ts | 10 ++-- .../tests/features.test.tsx | 4 +- plugins/genesis-tools-mods/types/index.d.ts | 3 ++ src/agents/README.md | 7 +-- src/agents/commands/hooks.ts | 2 +- src/agents/lib/hooks/config.ts | 10 ++-- src/agents/lib/hooks/hooks.test.ts | 2 + src/agents/lib/hooks/set-config.ts | 7 +++ 11 files changed, 81 insertions(+), 18 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2a20ff9b76..ba20b5231e 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, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Install with: tools agents hooks mods install --write.", - "version": "1.9.1", + "version": "1.9.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 4d4db7d314..2ed0afaca4 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools-mods", - "version": "1.9.1", + "version": "1.9.2", "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools-mods/hooks/git-band.tsx b/plugins/genesis-tools-mods/hooks/git-band.tsx index 4430ec848b..a12dbf10e3 100644 --- a/plugins/genesis-tools-mods/hooks/git-band.tsx +++ b/plugins/genesis-tools-mods/hooks/git-band.tsx @@ -6,7 +6,7 @@ import type { ElementTable, RenderElement } from "claude-code"; import type { BandCheck, BandPr, GitBand, GitBandLine, GitBandSwitch } from "../types"; import { type Feature, type Host, isRecord, logMod, settingsOf, shortDuration } from "./shared"; -import { actionRow, dimNote, divider, headerRow, type Kit, sidebarFrame } from "./sidebar"; +import { actionRow, dimNote, divider, headerRow, type Kit, plural, sidebarFrame } from "./sidebar"; export const GIT_PANE = "mods-git"; @@ -122,6 +122,20 @@ export function parseStatus(text: string): Pick { + let added = 0; + let removed = 0; + + for (const row of text.split("\n")) { + const [plus = "", minus = ""] = row.split("\t"); + added += /^\d+$/.test(plus) ? Number(plus) : 0; + removed += /^\d+$/.test(minus) ? Number(minus) : 0; + } + + return { added, removed }; +} + async function isOn(host: Host): Promise { const held = turnedOn; @@ -144,6 +158,7 @@ async function refreshNow(host: Host, why: string): Promise { return; } + const numstat = parseNumstat((await run(host, ["git", "diff", "--numstat", "HEAD"], root)) ?? ""); const tracked = await trackedPrs(host, root); const own = await prOf(host, root, "", false); const others = await Promise.all( @@ -152,7 +167,7 @@ async function refreshNow(host: Host, why: string): Promise { const prs = [own ? { ...own, tracked: tracked.includes(own.number) } : null, ...others].filter( (pr): pr is BandPr => pr !== null ); - const band: GitBand = { at: Date.now(), ...parseStatus(status), prs }; + const band: GitBand = { at: Date.now(), ...parseStatus(status), ...numstat, prs }; line = band; host.redraw(); @@ -164,6 +179,8 @@ async function refreshNow(host: Host, why: string): Promise { ahead: band.ahead, behind: band.behind, dirty: band.dirty, + added: band.added, + removed: band.removed, prs: prs.map((pr) => ({ n: pr.number, passed: pr.passed, failed: pr.failed, pending: pr.pending })), ms: Date.now() - started, }); @@ -313,8 +330,22 @@ export async function gitRow(host: Host, ui: ElementTable): Promise void host.togglePane(GIT_PANE, "Git"); + const lines = + band.added || band.removed ? ( + + {`+${band.added}`} + + {`−${band.removed}`} + + ) : null; return ( @@ -323,7 +354,12 @@ export async function gitRow(host: Host, ui: ElementTable): Promise{band.branch} {sync ? {sync} : null} - {band.dirty ? ●{band.dirty} : clean} + {band.dirty ? ( + {labelled ? `${plural(band.dirty, "file")} changed` : `●${band.dirty}`} + ) : ( + clean + )} + {band.dirty ? lines : null} {band.prs.map((pr) => ( {sync ? {sync} : null} {band.dirty ? ( diff --git a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx index 5e7506ce4e..aa1062abfe 100644 --- a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx +++ b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx @@ -250,7 +250,7 @@ export const hookLedger: Feature = { host.redraw(); } }, - compacted(agentId) { + compacted(_host, agentId) { seen.delete(agentId ?? "main"); }, sidebars: [{ id: HOOKS_PANE, title: "Hooks", draw: hooksPane }], diff --git a/plugins/genesis-tools-mods/hooks/idle.ts b/plugins/genesis-tools-mods/hooks/idle.ts index c40c990ded..0ef9fa36b0 100644 --- a/plugins/genesis-tools-mods/hooks/idle.ts +++ b/plugins/genesis-tools-mods/hooks/idle.ts @@ -16,6 +16,17 @@ export interface IdleCompactSettings { */ export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minutes: 56, minTokens: 200_000 }; +let dueAt: number | undefined; + +/** When the armed idle compaction fires (ms since the epoch), or `undefined` while none will: the cache band shows it. */ +export function idleDueAt(): number | undefined { + return dueAt; +} + +export function setIdleDueAt(at: number | undefined): void { + dueAt = at; +} + /** 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"; diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 5843008b94..a50c38b5d3 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -18,7 +18,7 @@ import { cacheBand, cacheRow, markEnded } from "./cache-band"; import { decisions } from "./decisions"; import { gitBand, gitRow } from "./git-band"; import { hookLedger, hooksChip, ledgerClassic } from "./hook-ledger"; -import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; +import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice, setIdleDueAt } from "./idle"; import { linksStep, rememberToolPaths } from "./links"; import { type CallSection, @@ -104,10 +104,12 @@ let idleTimer: Timer | undefined; function stopIdleTimer(): void { idleTimer?.cancel(); idleTimer = undefined; + setIdleDueAt(undefined); } async function compactIfIdle($: EngineInterface): Promise { idleTimer = undefined; + setIdleDueAt(undefined); // Read again at the fire time, so `config set mods.idleCompact.enabled false` stops a timer already armed. const host = hostOf($); @@ -485,9 +487,14 @@ export const register: Register = (on) => { const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host))); if (settings.enabled) { - idleTimer = $.clock.after(settings.minutes * 60_000, () => { + const ms = settings.minutes * 60_000; + idleTimer = $.clock.after(ms, () => { void compactIfIdle($); }); + + // The band shows the countdown only when the timer will compact: a small context is skipped at fire time. + const tokens = (await $.session.usage().catch(() => undefined))?.context.tokens; + setIdleDueAt(tokens === undefined || tokens >= settings.minTokens ? Date.now() + ms : undefined); } } @@ -498,8 +505,9 @@ export const register: Register = (on) => { return done; }); - on("session.compact", async (_$, e, next) => { + on("session.compact", async ($, e, next) => { const result = await next(e); + const host = hostOf($); // The person's /compact, the engine's own auto or idle compaction: nothing is left to save. if (compactionEndsIdleWait({ agentId: e.agentId, trigger: e.trigger, skipped: result.skip !== undefined })) { @@ -508,7 +516,7 @@ export const register: Register = (on) => { if (result.skip === undefined) { for (const feature of FEATURES) { - feature.compacted?.(e.agentId); + await feature.compacted?.(host, e.agentId); } } @@ -592,9 +600,13 @@ function topRow(ui: ElementTable, git: RenderElement | null, hooks: RenderElemen return ( - {git} + + {git} + - {hooks} + + {hooks} + ); } diff --git a/plugins/genesis-tools-mods/hooks/shared.ts b/plugins/genesis-tools-mods/hooks/shared.ts index 8fb3a3fd62..50df180cb1 100644 --- a/plugins/genesis-tools-mods/hooks/shared.ts +++ b/plugins/genesis-tools-mods/hooks/shared.ts @@ -139,7 +139,7 @@ export interface Feature { /** After a turn completed, the main loop's or a subagent's (`e.agentId`). */ afterTurn?: (host: Host, e: TurnCompleteInput) => Promise; /** After a loop compacted (the main loop when `agentId` is undefined). */ - compacted?: (agentId: string | undefined) => void; + compacted?: (host: Host, agentId: string | undefined) => void | Promise; /** The sidebars it opens from the band (`sidebar.tsx`). */ sidebars?: Sidebar[]; } diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx index 2d358b915b..6f13625b71 100644 --- a/plugins/genesis-tools-mods/tests/features.test.tsx +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -4,7 +4,7 @@ import { bareDescription, lastSaidOf } from "../hooks/agents-band"; import { quoteOf } from "../hooks/ask-row"; import { cacheRowsOf } from "../hooks/cache-band"; import { openDecisions, stagedMessage } from "../hooks/decisions"; -import { checkCounts, checksOf, hubUrlOf, parseNumstat, parseStatus } from "../hooks/git-band"; +import { checkCounts, checksOf, hubUrlOf, middleCut, parseNumstat, parseStatus } from "../hooks/git-band"; import { dedupeEntry, ledgerOf, tokensText } from "../hooks/hook-ledger"; import { targetFor } from "../hooks/links"; import { toolLabel } from "../hooks/spinner"; @@ -41,6 +41,8 @@ test("git band: status line and CI counts", async () => { expect(parseStatus("## HEAD (no branch)\n").branch).toBe("detached"); expect(parseNumstat("12\t3\ta.ts\n-\t-\tlogo.png\n0\t9\tb.ts\n")).toEqual({ added: 12, removed: 12 }); expect(parseNumstat("")).toEqual({ added: 0, removed: 0 }); + expect(middleCut("feat/2026-10-13-enhancements-unified", 30)).toBe("feat/2026-10-13…ements-unified"); + expect(middleCut("main", 30)).toBe("main"); expect( checkCounts([ { conclusion: "SUCCESS" }, @@ -126,6 +128,15 @@ test("cache band: own row first, others only inside the warning window", async ( "other-soon", "other-late", ]); + + // A compaction after the last turn: the own row says so, and another session gives no warning. + const compacted = [ + { sessionId: "own", label: "a", lastAt: now - 50 * minute, ttlMs: 60 * minute, compactedAt: now - 2 * minute }, + { sessionId: "other", label: "b", lastAt: now - 55 * minute, ttlMs: 60 * minute, compactedAt: now - minute }, + ]; + expect(cacheRowsOf(compacted, "own", 10 * minute, now)).toEqual([ + { sessionId: "own", label: "a", expiresAt: now + 10 * minute, own: true, compacted: true }, + ]); }); test("ask quote and spinner label", async () => { diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts index e0a2595156..7a25028ebf 100644 --- a/plugins/genesis-tools-mods/types/index.d.ts +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -68,6 +68,8 @@ export interface CacheRow { label: string; expiresAt: number; own: boolean; + /** The session compacted after its last turn: its next prompt starts a new, small context, so no countdown. */ + compacted: boolean; } /** The rows of the cache band: this session's, then other sessions under the warning threshold. */ diff --git a/src/agents/README.md b/src/agents/README.md index 4a31acb3c9..11058b06a0 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -256,6 +256,8 @@ checked against real use. - `mods.spinner`: the spinner line names the running tool, its time and the working subagents. - `mods.cacheBand` (`warnMinutes` 10, `others`): the prompt cache countdown, red under `warnMinutes`, and the other sessions whose cache runs out within it. A press on another session's chip focuses the cmux pane it runs in. + The own chip adds `AC in 1m 22s` while the idle compaction is armed, and says `cache compacted` after a compaction, + since the next prompt then starts a new, small context; another session that compacted gives no warning. - `mods.tmpUsage` (`minutes` 5): the size of the session's `/tmp/cc` folder and the free disk. - `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls; more than three fold into one `▸ 8 agents` line. A press on an agent, or `/mod-agents`, opens the Agents sidebar: From 0b128ba1e0bdebf574632972a83815c3a9d29ed4 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 17:07:55 +0200 Subject: [PATCH 031/251] feat(agents, hooks): the cache chip turns yellow under mods.cacheBand.cautionMinutes (20) before it turns red under warnMinutes, and other sessions show from the yellow window --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../genesis-tools-mods/hooks/cache-band.tsx | 39 +++++++++++++------ plugins/genesis-tools-mods/hooks/settings.ts | 5 ++- .../tests/features.test.tsx | 9 ++++- src/agents/README.md | 4 +- src/agents/commands/hooks.ts | 2 +- src/agents/lib/hooks/config.ts | 15 +++++-- src/agents/lib/hooks/hooks.test.ts | 1 + src/agents/lib/hooks/set-config.ts | 3 +- 10 files changed, 58 insertions(+), 24 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 520a1f6a4f..eda00c7cdd 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, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Install with: tools agents hooks mods install --write.", - "version": "1.9.3", + "version": "1.9.4", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index cc374f26f0..0a9f36f701 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools-mods", - "version": "1.9.3", + "version": "1.9.4", "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Configured with: tools agents hooks config set mods.. --write.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools-mods/hooks/cache-band.tsx b/plugins/genesis-tools-mods/hooks/cache-band.tsx index 0c7e639901..99fb0c1672 100644 --- a/plugins/genesis-tools-mods/hooks/cache-band.tsx +++ b/plugins/genesis-tools-mods/hooks/cache-band.tsx @@ -1,8 +1,8 @@ // The cache countdown. Each session writes `/mods-state/cache/.json` when its main loop finishes a // turn (the prompt cache was written then), and an `ended` mark when it ends. The band shows this session's minutes -// left dim, and red with a background under `mods.cacheBand.warnMinutes`; with `others` on, it also shows every -// other session whose cache runs out within that threshold, so a reply that would rewrite a cold cache is visible -// from any open session. Reading the folder is a file listing, no process; it runs every 15 seconds. +// left dim, yellow under `mods.cacheBand.cautionMinutes` and red under `mods.cacheBand.warnMinutes`; with `others` +// on, it also shows every other session whose cache runs out inside the yellow window, so a reply that would rewrite +// a cold cache is visible from any open session. Reading the folder is a file listing, no process; it runs every 15 seconds. import type { ElementTable, RenderElement } from "claude-code"; import type { CacheRow, CacheRows } from "../types"; import { idleDueAt } from "./idle"; @@ -143,7 +143,7 @@ async function scan(host: Host): Promise { } } - rows = cacheRowsOf(marks, ownId, settings.warnMinutes * 60_000, Date.now()); + rows = cacheRowsOf(marks, ownId, alertMs(settings), Date.now()); // Redrawn on every pass, so a countdown under the warning moves each 15 s. host.redraw(); } @@ -174,6 +174,20 @@ export async function markEnded(host: Host): Promise { await mark(host, "ended").catch(() => undefined); } +/** The wider of the two windows: a chip turns yellow, or red, from this much time left. */ +function alertMs(settings: { warnMinutes: number; cautionMinutes: number }): number { + return Math.max(settings.warnMinutes, settings.cautionMinutes) * 60_000; +} + +/** `red` under the warning, `yellow` under the caution, undefined above both. */ +export function cacheTone(left: number, warnMs: number, cautionMs: number): "red" | "yellow" | undefined { + if (left <= warnMs) { + return "red"; + } + + return left <= cautionMs ? "yellow" : undefined; +} + /** Focuses the cmux pane the row's session runs in (`tools claude cmux focus`); says so when no pane has it. */ async function focusSession(host: Host, row: CacheRow): Promise { try { @@ -201,6 +215,7 @@ export async function cacheRow(host: Host, ui: ElementTable): Promise { const left = row.expiresAt - now; const name = row.own ? "cache" : `${row.label} cache`; @@ -221,14 +236,14 @@ export async function cacheRow(host: Host, ui: ElementTable): Promise - {` ${name} ${shortDuration(left)} `} - - ) : ( - {`${name} ${Math.ceil(left / 60_000)}m`} - ); + const tone = cacheTone(left, warnMs, cautionMs); + const text = tone ? ( + + {` ${name} ${shortDuration(left)} `} + + ) : ( + {`${name} ${Math.ceil(left / 60_000)}m`} + ); // The own chip also counts down to the idle compaction, which comes before the cache runs out. const due = row.own ? idleDueAt() : undefined; diff --git a/plugins/genesis-tools-mods/hooks/settings.ts b/plugins/genesis-tools-mods/hooks/settings.ts index 50e9eb0971..52cc81b7b7 100644 --- a/plugins/genesis-tools-mods/hooks/settings.ts +++ b/plugins/genesis-tools-mods/hooks/settings.ts @@ -19,7 +19,7 @@ export interface ModsSettings { git: { enabled: boolean; labels: "full" | "minimal" }; askRow: { enabled: boolean }; spinner: { enabled: boolean }; - cacheBand: { enabled: boolean; warnMinutes: number; others: boolean }; + cacheBand: { enabled: boolean; warnMinutes: number; cautionMinutes: number; others: boolean }; tmpUsage: { enabled: boolean; minutes: number }; agentsBand: { enabled: boolean; warnMinutes: number }; hooks: { tokens: boolean; dedupe: boolean }; @@ -35,7 +35,7 @@ export const MODS_DEFAULTS: ModsSettings = { git: { enabled: false, labels: "full" }, askRow: { enabled: false }, spinner: { enabled: false }, - cacheBand: { enabled: false, warnMinutes: 10, others: true }, + cacheBand: { enabled: false, warnMinutes: 10, cautionMinutes: 20, others: true }, tmpUsage: { enabled: false, minutes: 5 }, agentsBand: { enabled: false, warnMinutes: 60 }, hooks: { tokens: false, dedupe: false }, @@ -112,6 +112,7 @@ export function modsSettings(hooksFile: string | undefined): ModsSettings { cacheBand: { enabled: bool(cache.enabled, d.cacheBand.enabled), warnMinutes: whole(cache.warnMinutes, 1, 59, d.cacheBand.warnMinutes), + cautionMinutes: whole(cache.cautionMinutes, 1, 59, d.cacheBand.cautionMinutes), others: bool(cache.others, d.cacheBand.others), }, tmpUsage: { diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx index 6f13625b71..c4209bb26b 100644 --- a/plugins/genesis-tools-mods/tests/features.test.tsx +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -2,7 +2,7 @@ import type { On, TurnStepChunk } from "claude-code"; import { expect, mock, test } from "claude-code/testing"; import { bareDescription, lastSaidOf } from "../hooks/agents-band"; import { quoteOf } from "../hooks/ask-row"; -import { cacheRowsOf } from "../hooks/cache-band"; +import { cacheRowsOf, cacheTone } from "../hooks/cache-band"; import { openDecisions, stagedMessage } from "../hooks/decisions"; import { checkCounts, checksOf, hubUrlOf, middleCut, parseNumstat, parseStatus } from "../hooks/git-band"; import { dedupeEntry, ledgerOf, tokensText } from "../hooks/hook-ledger"; @@ -137,6 +137,13 @@ test("cache band: own row first, others only inside the warning window", async ( expect(cacheRowsOf(compacted, "own", 10 * minute, now)).toEqual([ { sessionId: "own", label: "a", expiresAt: now + 10 * minute, own: true, compacted: true }, ]); + + // Dim above the caution window, yellow inside it, red inside the warning. + expect(cacheTone(25 * minute, 10 * minute, 20 * minute)).toBeUndefined(); + expect(cacheTone(15 * minute, 10 * minute, 20 * minute)).toBe("yellow"); + expect(cacheTone(4 * minute, 10 * minute, 20 * minute)).toBe("red"); + // A caution under the warning means no yellow stage. + expect(cacheTone(15 * minute, 10 * minute, 5 * minute)).toBeUndefined(); }); test("ask quote and spinner label", async () => { diff --git a/src/agents/README.md b/src/agents/README.md index 11058b06a0..ba01d6d724 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -254,8 +254,8 @@ checked against real use. and `track` buttons. `/mod-git on|off|toggle|refresh`. - `mods.askRow`: `[ask]` beside a finished tool row puts the row, quoted, into the prompt. - `mods.spinner`: the spinner line names the running tool, its time and the working subagents. -- `mods.cacheBand` (`warnMinutes` 10, `others`): the prompt cache countdown, red under `warnMinutes`, and the other - sessions whose cache runs out within it. A press on another session's chip focuses the cmux pane it runs in. +- `mods.cacheBand` (`cautionMinutes` 20, `warnMinutes` 10, `others`): the prompt cache countdown, yellow under + `cautionMinutes` and red under `warnMinutes`, and the other sessions whose cache runs out within the yellow window. A press on another session's chip focuses the cmux pane it runs in. The own chip adds `AC in 1m 22s` while the idle compaction is armed, and says `cache compacted` after a compaction, since the next prompt then starts a new, small context; another session that compacted gives no warning. - `mods.tmpUsage` (`minutes` 5): the size of the session's `/tmp/cc` folder and the free disk. diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index f8aad767e0..08fc51581d 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -787,7 +787,7 @@ function registerMods(hooks: Command): void { [ "mods.cacheBand", mods.cacheBand.enabled, - `red under ${mods.cacheBand.warnMinutes} min · others ${mods.cacheBand.others ? "on" : "off"}`, + `yellow under ${mods.cacheBand.cautionMinutes} min · red under ${mods.cacheBand.warnMinutes} min · others ${mods.cacheBand.others ? "on" : "off"}`, ], ["mods.tmpUsage", mods.tmpUsage.enabled, `every ${mods.tmpUsage.minutes} min`], ["mods.agentsBand", mods.agentsBand.enabled, `red past ${mods.agentsBand.warnMinutes} min`], diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index 866d872bc2..c015a7ba23 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -301,8 +301,11 @@ export interface ModsConfig { askRow: { enabled: boolean }; /** The running tool, its time and the working subagents on the spinner line. */ spinner: { enabled: boolean }; - /** The prompt cache countdown in the band, red under `warnMinutes`; `others` adds other sessions under it. */ - cacheBand: { enabled: boolean; warnMinutes: number; others: boolean }; + /** + * The prompt cache countdown in the band: yellow under `cautionMinutes`, red under `warnMinutes`; `others` adds + * the other sessions inside the yellow window. + */ + cacheBand: { enabled: boolean; warnMinutes: number; cautionMinutes: number; others: boolean }; /** The size of the session's /tmp/cc scratch folder and the free disk, every `minutes`. */ tmpUsage: { enabled: boolean; minutes: number }; /** One band row per working subagent: model, age, idle time, tool calls; red past `warnMinutes`. */ @@ -467,7 +470,7 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { git: { enabled: false, labels: "full" }, askRow: { enabled: false }, spinner: { enabled: false }, - cacheBand: { enabled: false, warnMinutes: 10, others: true }, + cacheBand: { enabled: false, warnMinutes: 10, cautionMinutes: 20, others: true }, tmpUsage: { enabled: false, minutes: 5 }, agentsBand: { enabled: false, warnMinutes: 60 }, hooks: { tokens: false, dedupe: false }, @@ -1106,6 +1109,12 @@ export function mergeMods(stored: unknown): ModsConfig { cacheBand: { enabled: boolOr(cache.enabled, base.cacheBand.enabled), warnMinutes: minutesOr("mods.cacheBand.warnMinutes", cache.warnMinutes, base.cacheBand.warnMinutes, 59), + cautionMinutes: minutesOr( + "mods.cacheBand.cautionMinutes", + cache.cautionMinutes, + base.cacheBand.cautionMinutes, + 59 + ), others: boolOr(cache.others, base.cacheBand.others), }, tmpUsage: { diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 4a6ecee5cf..7c015c2571 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -685,6 +685,7 @@ describe("applySetting", () => { expect(set("mods.links", "true").links.enabled).toBe(true); expect(set("mods.cacheBand.warnMinutes", "15").cacheBand.warnMinutes).toBe(15); expect(() => set("mods.cacheBand.warnMinutes", "75")).toThrow("1 to 59"); + expect(set("mods.cacheBand.cautionMinutes", "30").cacheBand.cautionMinutes).toBe(30); expect(set("mods.hooks.dedupe", "true").hooks).toEqual({ tokens: false, dedupe: true }); expect(() => set("mods.nonsense.enabled", "true")).toThrow("Unknown mods key"); expect(() => set("mods.links.colour", "red")).toThrow("Unknown mods key"); diff --git a/src/agents/lib/hooks/set-config.ts b/src/agents/lib/hooks/set-config.ts index 8396123d7f..98b564a13a 100644 --- a/src/agents/lib/hooks/set-config.ts +++ b/src/agents/lib/hooks/set-config.ts @@ -76,7 +76,7 @@ export const SETTABLE_KEYS = [ "mods.agentModel.", "mods..enabled", "mods.git.labels", - "mods.cacheBand.", + "mods.cacheBand.", "mods.tmpUsage.", "mods.agentsBand.", "mods.hooks.", @@ -95,6 +95,7 @@ const MOD_NUMBER_LIMITS: Record Date: Fri, 23 Oct 2026 17:10:16 +0200 Subject: [PATCH 032/251] fix(GenesisKit, markdown): a press on a tab group chip no longer leaves the whole window deaf, since every click count is read through MouseClick, which never asks a key or accessibility event for clickCount (an Objective-C raise), with a lint rule against the bare read and an exact-click test --- .claude/skills/swift/references/gotchas.md | 24 +++++ .claude/skills/ux/references/checklist.md | 5 ++ .claude/skills/ux/references/findings.md | 7 ++ .claude/skills/ux/references/live-testing.md | 6 ++ scripts/ci/lint-rules.ts | 46 +++++++--- .../GenesisKit/Controls/FrameProbe.swift | 29 ++++++ .../GenesisKit/Controls/MouseClick.swift | 21 +++++ .../Sources/GenesisKit/Files/FileTree.swift | 4 +- .../Window/MarkdownDocumentsView.swift | 5 +- .../Markdown/MarkdownTabGroupingTests.swift | 90 +++++++++++++++++++ .../Parts/Hub/Review/PRThreadsPanel.swift | 2 +- 11 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Controls/FrameProbe.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Controls/MouseClick.swift diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index 97878181d9..65be4a185e 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -406,3 +406,27 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not modifiers come from `NSEvent.modifierFlags` inside the button action. - **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file. + +## A whole window ignores clicks after one exception in an action — 2026-10-23 16:58 + +- **`NSEvent.clickCount` raises for any event that is not a mouse click.** It is an Objective-C exception + (`NSInternalInconsistencyException`), not a Swift error, and `NSApp.currentEvent` inside a SwiftUI button action is + not always a mouse event: a key press, an accessibility press or a system-defined event can be current. The raise + unwound through SwiftUI's event dispatch and left the Genesis Markdown window ignoring every click, tab bar and + sidebar included, until relaunch (2026-10-23 16:36, the tab group chip's double-click check). Read the count through + GenesisKit `MouseClick.current` or `MouseClick.count(of:)`, which checks the event type first. lint-rules + `swift-current-event-click-count` fails `currentEvent?.clickCount`. Inside `mouseDown(with:)` or a monitor + filtered to mouse types, `event.clickCount` is safe. +- **When "nothing is clickable", read the app's unified log for an exception first.** `command log show --predicate + 'processID == ' --last 10m | rg -i "exception|raise|terminat"` found the line in one query; hit tests, AX + walks and layout theories had found nothing. +- **A test window's synthesized click always makes a mouse event current.** `NSApp.postEvent` down and up gives the + action a real `leftMouseUp`, so a test can never reproduce a crash that needs a key or AX event as the current + one. A unit test of the reader with a key event (`NSEvent.keyEvent(...)`) covers it; a passing click test does not. +- **An AX hit test over a `WKWebView` names the web area, not the view on top.** `tools control hittest` at a tab + that sat above the page reported `AXWebArea` (the web document's rect), while real clicks reached the tab. Judge + delivery by the app's own action log, never by the AX hit test. +- **A hidden test window has no accessibility tree, so find click targets with `FrameProbe`.** GenesisKit + `.frameProbe("name")` records a view's window frame only while `FrameProbe.enabled` (a test sets it); click the + centre of `FrameProbe.frames["name"]`. Never scan a grid of clicks to find a target: the scan pressed the toolbar's + Obsidian and Editor buttons and opened dialogs and editor tabs in the user's real apps. diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 134cd00968..7670419e9b 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -15,6 +15,11 @@ Walk it for every view you change. Each line exists because it was missed at lea view: SwiftUI `Text(.init(markdown))` never draws an image. - Items that can be acted on can be acted on in place: tick a task, expand an answer, create a new one. Opening another window is the exception, not the default. +- 🛑 A render is not a click. After any change to a clickable view, press it once in the REAL installed app + (`tools control click --app --desc