SLOPSHOPPER

genesis-tools-mods

Claude Code mods for GenesisTools, each switched by mods.<feature> in the hooks file: the whole diff of a cut diff-hook message under the call's row, the…

panebandspinnerrowsguardcommand
★ 8v1.16.3no licenseupdated 2026-10-26genesiscz/GenesisTools/plugins/genesis-tools-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · genesis-tools-mods
│ ┃ Git ✕ › fix the failing auth test and add an audit log call │ ┃ Git · no reading yet[ refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ add a folder: a path, or part of a folder na ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ Click in this pane (or open it with its ⎿ Added 2 lines, removed 1 line │ ┃ slash command), then Tab: this line says ⏺ Bash(bun test) │ ┃ what each button does. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /decisions │ ┃ ⎿ genesis-tools-mods: The decisions mod is off: tools agents hooks │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git
Git · no reading yet[ refresh ] add a folder: a path, or part of a folder name ⏎ add ─────────────────────────────────────────────────────── Click in this pane (or open it with its slash command), then Tab: this line says what each button does.
Pane · Session files
Session files · measuring…[ refresh ] ─────────────────────────────────────────────────────── Click in this pane (or open it with its slash command), then Tab: this line says what each button does.
Pane · Agents
No agents in this session yet.
Pane · Hooks
~0 tokens from hooks · 0 fires [ dedupe: off ] [ show log ] main ~0 · 0 agents ~0 · 0 deduped ~0 · 0 Counted for this session; tokens are characters / 4. By event No hook added context yet. Latest (press one to read it)
README

🌟 GenesisTools

<a href="https://deepwiki.com/genesiscz/GenesisTools"><img src="https://img.shields.io/badge/DeepWiki-AI_Docs-blue?style=for-the-badge&logo=readthedocs&logoColor=white" alt="DeepWiki" /></a> <img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /> <img src="https://img.shields.io/badge/Bun-000000?style=for-the-badge&logo=bun&logoColor=white" alt="Bun" /> <img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="MIT License" />

<h3>One <code>tools</code> command, 97 self-contained CLIs.</h3>

<strong>A personal-scale toolkit for AI-assisted development: LLM plumbing, agent orchestration, git surgery, MCP server management, macOS automation, and time tracking.</strong>


📚 Table of Contents


What this is

GenesisTools is a TypeScript monorepo that Bun executes directly, with no build step. Every tool lives in its own folder under src/ and runs in its own process. The tools executable is the only entry point you need.

The catalogue below is taken from what actually exists in this checkout: 91 discoverable entries, of which src/utils is the shared package barrel (@genesiscz/utils, not a runnable tool) and src/Internal holds private tools. That leaves 89 usable tools. Running tools with no arguments prints the same total of discovered entries in its header.


🚀 Quick Start

Prerequisites

📌 Bun is required. Many tools use Bun-only APIs (Bun.spawn, Bun.file, bun:sqlite), so Node.js is not a substitute.

curl -fsSL https://bun.sh/install | bash

Installation

git clone https://github.com/genesiscz/GenesisTools.git
cd GenesisTools

bun install && ./install.sh

install.sh checks for Bun, installs dependencies if node_modules is missing, then puts the repo on your PATH. On macOS and Linux it updates whichever of ~/.zshrc / ~/.bashrc already exist, plus the rc files of your login shell ($SHELL) and of the shell you run it from (each created if it is missing) — it never creates ~/.bashrc on a zsh-only Mac unless you run it from bash. On Windows (Git Bash, MSYS, Cygwin) it sets GENESIS_TOOLS_PATH and extends the user PATH via setx and PowerShell.

On macOS, install.sh also offers to build GenesisTools.app (a signed launcher that lets tools own macOS privacy grants — Calendars, Reminders, Full Disk Access — instead of your terminal owning them). This is optional: say no, or run it later with bun run build:app. Building it needs the full Xcode app, not just the Command Line Tools (SwiftUI macros ship only with Xcode); on a Command Line Tools-only Mac, install.sh explains that instead of asking.

Reload your shell afterward: open a new terminal, or source the rc file of the shell you are typing in (only that one; the other may not exist). In zsh:

source ~/.zshrc

In bash:

source ~/.bashrc

First command

tools

That opens the Tools Browser: a searchable list of every tool, with per-tool actions (run, view README, list subcommands, copy the command to the clipboard).

Staying current

tools update       # git pull, bun install with a clean retry, optional plugin refresh

🧭 How the CLI works

Discovery. tools scans src/ and treats as a tool:

  • any directory containing index.ts or index.tsx (tool name is the directory name),
  • any standalone .ts or .tsx file directly in src/ (tool name is the filename).

Nothing is registered by hand, so adding a folder with an index.ts adds a tool.

Invocation.

tools                       # interactive browser
tools <name> [args...]      # run a tool
tools <name> --help         # commander help for that tool
tools <name> --readme       # print the tool's README.md, when it ships one
tools <name> -v             # promote debug logging to the console
tools <partial-name>        # fuzzy match, then pick from a shortlist

Global flags are added by the shared runTool wrapper, so they exist on every commander entrypoint: -v, --verbose, --readme, -h, --help. Tools that opt in also accept --trace (and -vv as a shorthand).

Output contract. out.result() and out.print() are the only writers to stdout, so piping a tool's machine-readable output is always safe. Diagnostics, spinners, prompts, and status lines go to stderr. Every run also appends structured JSON to a day-stamped log file.

Recovery. If a tool dies with a module-resolution error (a stale or partial node_modules), the wrapper recognises the error shape and offers to run bun install for you. Set TOOLS_SKIP_AUTOINSTALL=1 to suppress that.


🎯 Claude Code plugin

The repo ships two Claude Code plugins through the marketplace file at .claude-plugin/marketplace.json. The main one, genesis-tools (currently version 1.0.77), contains 6 commands, 31 skills, 2 subagents, and 6 hook registrations.

The plugin does not register an MCP server — plugin.json has no mcpServers entry. The genesis-tools MCP server described further down is a separate opt-in step (tools genesis-tools-mcp install; registration is never automatic).

Commands are invoked explicitly as /gt:<name>, which comes from each command file's own name: frontmatter. Skills load themselves when the conversation matches their trigger description, and they resolve by plugin and directory (genesis-tools:<skill>), so the gt: prefix that 18 of the 31 skill files still carry in their frontmatter has no effect on how they are addressed.

Installation for Claude Code

Add this repository as a plugin marketplace, then install the plugin:

/plugin marketplace add genesiscz/GenesisTools
/plugin install genesis-tools

Because the plugin is installed from the GitHub remote, local edits to plugins/ only take effect after you push and run /plugin update.

That installs commands, skills, subagents and hooks only. To also register the genesis-tools MCP server with Claude Code, run its canonical command — tools update also offers this interactively the first time it notices the server is missing:

tools genesis-tools-mcp install

Commands (6)

CommandArgument hintWhat it does
/gt:setup[optional: setup details]Install GenesisTools so tools works globally. Checks Bun, asks where to clone, runs install.sh, verifies.
/gt:github-pr<pr-number-or-url> [-u] [-w] [--save] [--open] [--open-only]Fix PR review comments: fetch threads, verify each claim against the source, pick which to fix, implement, commit, reply.
/gt:automate`[run\list\show\create] [name-or-args]`Build or run multi-step tools CLI automation presets.
/gt:timelog[date or range]Sync Timely to Azure DevOps time logs, then fill Clarity PPM timesheets.
/gt:git-recommit`[<count> \--min <n> --max <n>]`Recompose the current branch's commits into N clean, scope-organised commits with the same final tree.
/gt:git-recompose-branches[<commit-count>] [<source-branch>] [<pattern,pattern,…>]Split a branch's commits into separate branches by file-pattern groups, with a verified split.

/gt:github-pr also handles multiple PRs in one invocation: pass several URLs and it works them in parallel, writes a per-PR plan, and presents one consolidated report.

/gt:automate and /gt:timelog are deliberately commands rather than skills. Their guidance is large, and as skills they would load into sessions that never needed them.

Skills (31)

SkillWhat it does
agents-talkCross-agent messaging protocol via tools agents. Invoke before spawning subagents that must talk to each other.
analyze-harToken-efficient HAR analysis. The rule it enforces: never cat or jq a HAR file.
artifactCreate, serve, and build local dashboards and single-file HTML artifacts via tools artifact, with a 40+ component kit (React, Tailwind, charts, diffs).
azure-devopsWork items, queries, sprints, dashboards. Defers time logging to /gt:timelog.
browser-routerRoute non-web links so a click on this Mac runs a saved command instead of opening a page.
chrome-devtoolsDrive a real running browser over the Chrome DevTools Protocol to debug auth loops, cookie/session issues, and captured HAR files.
claude-historyFind native Claude, Codex or Grok conversations by topic, file or date.
debugging-masterHypothesis-driven runtime debugging with temporary, auto-cleanable instrumentation (Node/TS, PHP, browser).
fable-replaceVerified, transactional find/replace for code and docs, instead of sed/perl/repeated edits, for a change spanning more than one file or spot.
gitBranch mechanics with proof: is it merged (by content), rebase one branch or a fleet, the oracle merge, cascade a parent and its children, clean up after a merge, recommit, split a branch, merge a PR. Drives tools git merged, rebase-cascade, base, config.
githubRead or search GitHub, and analyze GitHub Actions runs, failures, and billing.
handoff-toOffload work to another model or agent and pick which one (Codex/GPT, grok, Claude on another account, sonnet, opus, fable); per-backend mechanics in its references/.
improve-agents-mdEmpirically evaluate and trim CLAUDE.md / AGENTS.md by testing which rules a clean model already follows.
jev-grepFind the source code for a behavior when you don't know the symbol or file, via tools jev grep.
json2mdGenerate markdown efficiently from JSON data.
living-docsSelf-maintaining docs system: bootstrap, validate, refine minimal doc chunks.
macos-controlDrive native macOS apps through the Accessibility API, and record short screen captures reviewed frame by frame.
mcp-scriptingCall MCP tools from a plain TypeScript script via tools scripts, with types generated from each server's live tools/list.
plan-itWrite implementation plans hardened with an executor-proof contract, so a weaker model can execute them.
questionAnswer a question, then preserve the question and answer for later review.
react-compiler-debugInspect babel-plugin-react-compiler output and explain memoization decisions.
researchAnswer questions that need information from outside the local codebase.
review-proposalReview a GitHub PR or GitLab MR and push the result into the GenesisTools.app review window as a proposal.
stashSave, apply, and unapply named code overlays across projects with tools stash.
summarizeSummarize a Claude Code session into learnings, a postmortem, a changelog, or onboarding docs.
taskRun long-lived interactive commands (dev servers, Metro, Vite) with PTY capture and an agent-friendly log tail.
tddTest-first workflow for implementing any feature or bugfix, with mechanical RED/GREEN evidence gates.
timelyTurn a day of Timely auto-tracked memories into time-log entries via a plan/apply workflow.
todoProject-scoped task tracking through tools todo, including timed todos synced to Apple Calendar.
typescript-error-fixerFix TypeScript compile errors and eliminate any, one agent per file.
wrap-upWrite the state doc that lets a fresh agent resume cold: an Obsidian wrap-up or a repo handoff.

Agents and hooks

Two subagents ship with the plugin:

  • agent-driver drives one external worker session end to end (spawn, watch, steer, resolve approvals, verify, tear down) so the worker's event stream stays out of the orchestrator's context. One instance per worker session.
  • explore does deep codebase exploration and writes a persistent report, so findings survive the subagent exiting.

Six hook registrations in plugins/genesis-tools/hooks/hooks.json:

EventScriptPurpose
SessionStarttrack-session-files.tsStart the per-session record of modified files.
SessionStartagents-talk-hint.tsSurface the agents-talk protocol hint at session start.
SessionStartrecord-session-account.tsRecord which Claude account the session is billing.
SessionStartrecord-session-cmux.tsRecord the cmux pane/surface the session is running in.
UserPromptSubmitrecord-session-cmux.tsKeep the cmux pane record current as the session continues.
PostToolUse (`Edit\Write\MultiEdit\NotebookEdit`)track-session-files.tsAppend each edited file to that session's record.

The session file record lands in ~/.genesis-tools/claude-code/sessions/<session-id>.json, which is what makes "commit only the files you touched this session" possible.

The genesis-tools MCP server

Not registered by the plugin install above — plugin.json has no mcpServers entry, so Claude Code never starts it on its own, and nothing registers it automatically: tools update offers to in a TTY, but the choice is always yours. The canonical command is tools genesis-tools-mcp install; tools claude mcp install is only an alias of it (same code, registered a second time under the claude subcommand tree for discoverability):

tools genesis-tools-mcp install   # canonical — registers the stdio server with Claude Code
tools claude mcp install          # alias of the line above

The server itself runs as:

tools genesis-tools-mcp   # stdio MCP server (tools claude mcp is the same, as an alias)

It registers 17 tools across 5 capability groups:

CapabilityToolsPurpose
question_answerquestion_answerCapture a question and your complete answer to the local question store, reviewable later with tools question log / tools question tail.
question_askquestion_post, question_wait, question_poll, question_respond, question_cancel, question_update, question_tokensAsk the user: question_post creates a pending form and returns its id at once, and blocks only with wait: true. Then wait, poll, respond, or cancel.
handoffhandoff_post, handoff_get, handoff_list, handoff_actionCross-agent task handoff: post a task list, address it to a session or a harness (target.agent: claude / codex / grok / copilot), fetch it by id or name, and claim and check off tasks.
annotateannotate_imageAnnotate an image (arrows, boxes, labels) for review.
jevjev_route, jev_compact, jev_verify, jev_verify_templatesRead-only Jev tools.

All groups are enabled when GENESIS_TOOLS_MCP_CAPABILITIES is unset. Set it to a comma-delimited list of capability names to restrict registration, for example in ~/.claude.json under mcpServers.genesis-tools.env:

GENESIS_TOOLS_MCP_CAPABILITIES=question_answer,handoff   # only these two groups

The dev-dashboard annotation boards used to be a sixth group (boards_*). They were removed from the MCP server; a leftover boards entry in the capability list now enables nothing.

Second plugin: genesis-tools-server

Despite the name, this is not an MCP server. genesis-tools-server (version 1.0.0, category security) ships exactly one command: a comprehensive Linux server security audit that analyzes logs, detects attack patterns, identifies malicious IPs, checks security tool status, runs malware and rootkit scans, and generates the matching fail2ban commands.


🛠️ Tool catalogue

Every row is a real tool in this checkout, described from its own --help. Each name links to that tool's own README.md, which you can also print in the terminal with tools <name> --readme.

AI and LLM

ToolWhat it doesKey subcommands
aiUnified AI toolkit: translate, summarize, classify, generate images, manage accounts and models.translate summarize image classify models config
ai-proxyOpenAI-compatible local proxy in front of Grok, GitHub Copilot, and other providers, with a client ledger.up down serve status models calls clients usage link config
ai-spendToken and cost analytics across every local Claude Code, Codex and Grok session, reported per source.summary sessions today daily weekly monthly series monitor blocks statusline
askMulti-provider LLM chat, one-shot or interactive, with optional audio input via --sst, plus token and cost analytics of its own calls.models usage configure, or flags (-m, -p, -f, -o)
sayText to speech with pluggable backends (macOS, xAI Grok, OpenAI) and per-app config profiles.voices models config
transcribeTranscribe audio files with AI, locally or in the cloud.flags only
darwinkitApple on-device ML from the terminal: NLP, embeddings, OCR, clustering, biometry, iCloud.40+ verbs, see --help
jsonConvert between JSON and TOON (30 to 60 percent fewer tokens), or infer a schema (schema, with --compact for one-line output).convert (default) schema
mcp-web-readerFetch a page and return its main content as Markdown, its raw HTML, or Jina Reader output. Works as CLI and MCP server.flags only (--mode, --depth, --server)
indexerSemantic code indexer with AST-aware chunking and hybrid search.add search sync watch graph context mcp-serve

Agents and AI coding sessions

ToolWhat it doesKey subcommands
claudeEverything around Claude Code: history, resume, accounts and OAuth, desktop sync, usage, MCP server, cmux restore.history resume tail summarize usage config login start mcp code cmux teams doctor
ccResume a Claude Code session by short ID, name, or content search.(single command)
codexRun the native Codex terminal on a named account with shared configuration, search indexed history, and manage app-server workers.login run history usage spawn steer read review approve deny tail sessions
grokRun isolated Grok workers, resume native sessions, and search indexed conversation history.login run resume history usage steer read tail sessions
cursorAsk Cursor Agent a question about the codebase and stream the answer, tool calls on stderr and answer on stdout.flags only (--mode, --model, --raw)
agentsCross-agent communication: register, message, request, discover, listen across a swarm.login message request discover listen
questionCapture and review the questions fired at agents mid-session, with their answers.record log tail config
taskPTY-aware command wrapper with ordered log capture, built for long-lived dev servers.run get logs tail wait sessions dashboard
scriptsScript MCP tool calls directly, with no agent loop, using types generated from each server.servers tools call create run regen remote doctor
debugging-masterInstrumentation primitives plus a token-efficient log reader for runtime debugging.start get expand diff sessions cleanup dashboard
stashGlobal cross-project code-overlay manager: save a chunk of working-tree changes and reapply it anywhere.save apply unapply list diff versions doctor
learn-from-fableStaged pipeline that distills Fable 5's working style from local session transcripts into the Fable Pack.bootstrap mine eval consolidate spec skill

Git and code history

ToolWhat it doesKey subcommands
gitCommit analysis plus branch mechanics: is a branch merged (by content, not sha), cascade-rebase a parent with its children, split a messy branch into clean ones by path group and prove nothing was lost (`rebranch plan\apply\verify), detect the base branch, per-repo git policy, and what you touched when (changes`).commits merged rebase-cascade rebranch rename-commits changes base config configure-authors configure-workitem-patterns health monster
locCount files and code, blank, and comment lines by language, respecting .gitignore.flags only

GitHub and CI

ToolWhat it doesKey subcommands
githubToken-efficient GitHub client: issues, PRs, review threads, code search, notifications, activity, raw files, release notes as one Markdown document, stack-safe merges.issue pr merge comments search code get review notifications activity releases status
gitlabGitLab client for any instance (host, token and project from flags, env, glab or the origin remote): per-day activity, user and project reports, MR review threads and drafts, batch comments and labels, open MRs by file, two-phase stale-MR cleanup.activity analyze-user analyze-project fetch-review discussions draft-reply drafts batch-comment batch-label search-by-file stale-branches
jenkinsJenkins CLI and MCP server: paste a job path or full Jenkins URL, read stages, logs, changes, and monitor the queue.stages log info changes jobs monitor

tools github merge exists specifically because gh pr merge --delete-branch closes the children of a PR stack instead of retargeting them (`cli/cli#1

Source 25 files
hooks/register.tsx 856 lines
1import type {
2    ElementTable,
3    EngineInterface,
4    HookStream,
5    Register,
6    RenderElement,
7    Timer,
8    TurnStepChunk,
9    TurnStepResult,
10    UiPane,
11} from "claude-code";
12import { atom, read, update } from "claude-code";
13import type { FlippedFiles, ShownCalls } from "../types";
14import { agentModelStep, spawnWithModel } from "./agent-model";
15import { agentsBand, agentsRow, noteTranscript, recordSpawn } from "./agents-band";
16import { askOnRow } from "./ask-row";
17import { bandTree } from "./band";
18import { cacheBand, cacheRow, markEnded } from "./cache-band";
19import { decisions, decisionsChip } from "./decisions";
20import { primeGenesisProbe } from "./genesis";
21import { gitBand, gitRows } from "./git-band";
22import { hookLedger, hooksChip, hooksChipWidth, ledgerClassic } from "./hook-ledger";
23import {
24    compactionEndsIdleWait,
25    idleCompactSettings,
26    idleCompactSkip,
27    idleNotice,
28    publishIdleDue,
29    setIdleDueAt,
30} from "./idle";
31import { inbox, inboxChip } from "./inbox";
32import { linksStep, rememberToolPaths } from "./links";
33import { startLiveClock } from "./live";
34import {
35    type CallSection,
36    collapseGitSyncEnabled,
37    commandOf,
38    fullDiffEnabled,
39    gitSyncOf,
40    isDeletion,
41    openByDefault,
42    sectionsOfCalls,
43} from "./sections";
44import {
45    afterTools,
46    agentsDir,
47    type Feature,
48    type Host,
49    logMod,
50    readHooksFile,
51    settingsNow,
52    settingsOf,
53} from "./shared";
54import { focusOf, forgetFocus, noteFocus } from "./sidebar";
55import { progressSuffix, spinner } from "./spinner";
56import { clearStall, noteStop, stallCatcher, stallRow } from "./stall";
57import { tmpRow, tmpUsage } from "./tmp-usage";
58
59// The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the
60// whole diff to ~/.genesis-tools/agents/hook-diffs/<tool_use_id>.md. This draws those hunks with Claude
61// Code's own Code element under the call's row, so the colours, the gutters and the wrapping are the
62// engine's and nothing counts against the 10,000-character hook message.
63//
64// 🛑 Two rows, two hooks. Outside `--verbose` and the ctrl+o transcript the engine draws a group of calls
65// as ONE `ToolGroup` line ("ran 3 shell commands") and no `ToolUse` row at all (`ToolGroup.isExpanded`
66// in the engine types), so the diffs go under that line. An expanded group draws each call as its own
67// `ToolUse` row, and the diff goes under the call. A spike that hooked only `ToolResult`, then only
68// `ToolUse`, drew nothing in the normal view (checked on screen 2026-10-15).
69
70// One drawing may hold 100,000 characters in all (CodeProps.source); stay well inside it.
71const MAX_CHARS = 60_000;
72
73interface Diffs {
74    sections: CallSection[];
75    /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.diff.collapseGitSync` is on. */
76    syncs: Map<string, string>;
77    /** The notes of the calls past `MAX_CHARS` (`sectionsOfCalls`), named under the diffs so none goes without a trace. */
78    omitted: string[];
79}
80
81/**
82 * A call's diff note never changes once the diff hook wrote it, and a tool row draws again on every redraw (the live
83 * clock's included), so a note read once is kept; a missing one is asked for again. The hooks file is read at most
84 * every 5 seconds.
85 */
86const noteText = new Map<string, string>();
87const NOTES_KEPT = 80;
88let hooksFileHeld: { at: number; dir: string; text: string | undefined } | undefined;
89
90async function hooksFileOf(host: Host, dir: string): Promise<string | undefined> {
91    const held = hooksFileHeld;
92
93    if (held && held.dir === dir && Date.now() - held.at < 5_000) {
94        return held.text;
95    }
96
97    const text = await readHooksFile(host, dir);
98    hooksFileHeld = { at: Date.now(), dir, text };
99
100    return text;
101}
102
103async function fullDiffs($: EngineInterface, calls: readonly { id: string; input: unknown }[]): Promise<Diffs> {
104    const none: Diffs = { sections: [], syncs: new Map(), omitted: [] };
105    const host = hostOf($);
106    const dir = calls.length > 0 ? await agentsDir(host) : undefined;
107
108    if (!dir) {
109        return none;
110    }
111
112    const hooksFile = await hooksFileOf(host, dir);
113
114    if (!fullDiffEnabled(hooksFile)) {
115        return none;
116    }
117
118    const ids = calls.map((call) => call.id);
119    const note = (id: string) => `${dir}/hook-diffs/${id}.md`;
120    const { sections, omitted } = await sectionsOfCalls({
121        ids,
122        read: async (id) => {
123            const held = noteText.get(id);
124
125            if (held !== undefined) {
126                return held;
127            }
128
129            const text = await $.fs.read(note(id));
130            noteText.set(id, text);
131
132            // The rows on screen are a few dozen; the oldest note goes first.
133            if (noteText.size > NOTES_KEPT) {
134                noteText.delete(noteText.keys().next().value ?? id);
135            }
136
137            return text;
138        },
139        maxChars: MAX_CHARS,
140    });
141    const syncs = new Map<string, string>();
142
143    if (collapseGitSyncEnabled(hooksFile)) {
144        for (const call of calls) {
145            const sync = gitSyncOf(commandOf(call.input));
146
147            if (sync !== undefined) {
148                syncs.set(call.id, sync);
149            }
150        }
151    }
152
153    return { sections, syncs, omitted: omitted.map(note) };
154}
155
156// The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last
157// request, and gives up at the first gate that refuses (`near_limit` refuses while the account's last reply
158// carried a rate-limit warning, even after the window reset). Then the 1-hour cache expires and the next
159// prompt pays for the whole context again. This timer compacts at `mods.idleCompact.minutes` (56) after the
160// last main-loop turn, unless a compaction already happened. A reload of the mod drops the timer; the next
161// turn arms it again.
162let idleTimer: Timer | undefined;
163
164function stopIdleTimer(): void {
165    idleTimer?.cancel();
166    idleTimer = undefined;
167    setIdleDueAt(undefined);
168}
169
170async function compactIfIdle($: EngineInterface): Promise<void> {
171    idleTimer = undefined;
172    setIdleDueAt(undefined);
173
174    // Read again at the fire time, so `config set mods.idleCompact.enabled false` stops a timer already armed.
175    const host = hostOf($);
176    const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host)));
177    const [surfaces, usage] = await Promise.all([$.session.surfaces(), $.session.usage()]);
178
179    if (idleCompactSkip({ settings, surfaces: surfaces.length, tokens: usage.context.tokens }) !== undefined) {
180        return;
181    }
182
183    try {
184        // A full compaction: only the engine's threshold path uses a ready precomputed summary, so this one
185        // makes its own summary request (over the still-warm cache) and a ready precompute is dropped.
186        const compacted = await $.session.compact({});
187
188        if (compacted.skip !== undefined) {
189            return;
190        }
191
192        const text = idleNotice({
193            minutes: settings.minutes,
194            tokensBefore: compacted.tokensBefore,
195            tokensAfter: compacted.tokensAfter,
196        });
197
198        await $.session.append({ message: { type: "system", content: [{ type: "text", text }] } });
199    } catch {
200        // `compact` rejects while a turn runs: the person came back, and that turn arms the timer again.
201    }
202}
203
204/**
205 * The files the person folded or opened against their default (`openByDefault`), each as `fileKey`: its row
206 * (a ToolGroup's `requestId`, or a call's `tool_use_id` in an expanded group), its call and its path. Session
207 * state, so a fold survives a redraw and a reload of the mod. The newest 500 are kept.
208 */
209const flipped = atom({ plugin: "genesis-tools-mods", key: "flipped" } as const, [] as FlippedFiles);
210
211function fileKey(row: string, section: CallSection): string {
212    return `${row}|${section.call}|${section.path}`;
213}
214
215/** The git-sync calls whose hidden diff the person opened, as `<row>|<call>`; the newest 500. */
216const shownCalls = atom({ plugin: "genesis-tools-mods", key: "shown" } as const, [] as ShownCalls);
217
218/**
219 * The full diffs under a row. The diff of a call that runs a git sync (`git pull`, …) starts hidden behind one
220 * `show diff from git pull` Button, and a `hide diff from git pull` Button folds it back. Each file has its own `[-]`/`[+]` Button that folds its diff to its name and
221 * back; the row's Button folds every file, or opens them all once every file is folded. A click elsewhere on the
222 * drawing reaches the engine's row, which opens the group's calls (the person's "expansion in expansion").
223 *
224 * The inner Box's `width="100%"` stretches the diffs to the transcript's width: without it they were as wide as
225 * the engine's row line, and the Code element wrapped them there. The width cannot sit on the outer Box: the
226 * engine refuses a tree that puts its own row under a Box with a `width` (seen live 2026-10-18).
227 */
228async function drawDiffs(
229    $: EngineInterface,
230    ui: ElementTable,
231    row: string,
232    diffs: Diffs,
233    drawn: RenderElement
234): Promise<RenderElement> {
235    const { Box, Button, Code, Text } = ui;
236    const shown = new Set(await read($, shownCalls));
237    const callKey = (call: string) => `${row}|${call}`;
238    const isHidden = (call: string) => diffs.syncs.has(call) && !shown.has(callKey(call));
239    const sections = diffs.sections.filter((section) => !isHidden(section.call));
240    const hidden = [...new Set(diffs.sections.map((section) => section.call))].filter(isHidden);
241    const setShown = (call: string, on: boolean) => {
242        const key = callKey(call);
243
244        void update($, shownCalls, (list) =>
245            [...list.filter((item) => item !== key), ...(on ? [key] : [])].slice(-500)
246        );
247    };
248    const flips = new Set(await read($, flipped));
249    const keys = sections.map((section) => fileKey(row, section));
250    const defaults = openByDefault(sections);
251    const isOpen = (index: number) => (defaults[index] ?? true) !== flips.has(keys[index] ?? "");
252    const isAllFolded = keys.every((_, index) => !isOpen(index));
253    const totals = (of: readonly CallSection[]) => ({
254        files: new Set(of.map((section) => section.path)).size,
255        added: of.reduce((sum, section) => sum + section.added, 0),
256        removed: of.reduce((sum, section) => sum + section.removed, 0),
257    });
258    const counts = ({ files, added, removed }: ReturnType<typeof totals>) => [
259        <Text key="files" dimColor>
260            {files} {files === 1 ? "file" : "files"}
261        </Text>,
262        <Text key="added" color="diffAdded">
263            +{added}
264        </Text>,
265        <Text key="removed" color="diffRemoved">
266            -{removed}
267        </Text>,
268    ];
269    /** Opens or folds the files at `indexes`: a file is flipped exactly when that differs from its default. */
270    const setOpen = (indexes: readonly number[], open: boolean) => {
271        const targets = indexes.map((index) => keys[index] ?? "");
272        const flip = indexes.filter((index) => open !== (defaults[index] ?? true)).map((index) => keys[index] ?? "");
273
274        void update($, flipped, (list) => [...list.filter((key) => !targets.includes(key)), ...flip].slice(-500));
275    };
276    const all = keys.map((_, index) => index);
277
278    return (
279        <Box flexDirection="column">
280            {drawn}
281            <Box flexDirection="column" width="100%">
282                {hidden.map((call) => (
283                    <Box key={`${callKey(call)}:sync`} flexDirection="row" gap={1}>
284                        <Button key={`${callKey(call)}:show`} dimColor onPress={() => setShown(call, true)}>
285                            show diff from {diffs.syncs.get(call)}
286                        </Button>
287                        {counts(totals(diffs.sections.filter((section) => section.call === call)))}
288                    </Box>
289                ))}
290                {sections.length === 0 ? null : (
291                    <Box flexDirection="row" gap={1}>
292                        <Button key={`${row}:fold`} dimColor onPress={() => setOpen(all, isAllFolded)}>
293                            {isAllFolded ? "show diff" : "files only"}
294                        </Button>
295                        {counts(totals(sections))}
296                    </Box>
297                )}
298                {sections.map((section, index) => {
299                    const key = keys[index] ?? fileKey(row, section);
300                    const isFolded = !isOpen(index);
301                    const sync = diffs.syncs.get(section.call);
302                    const isFirstOfCall = sections.findIndex((other) => other.call === section.call) === index;
303
304                    return (
305                        <Box key={key} flexDirection="column">
306                            {sync !== undefined && isFirstOfCall ? (
307                                <Button
308                                    key={`${callKey(section.call)}:hide`}
309                                    dimColor
310                                    onPress={() => setShown(section.call, false)}
311                                >
312                                    hide diff from {sync}
313                                </Button>
314                            ) : null}
315                            <Box flexDirection="row" gap={1}>
316                                <Button key={`${key}:fold`} plain dimColor onPress={() => setOpen([index], isFolded)}>
317                                    {isFolded ? "[+]" : "[-]"}
318                                </Button>
319                                <Text dimColor={!isFolded}>{section.path}</Text>
320                                <Text color="diffAdded">+{section.added}</Text>
321                                <Text color="diffRemoved">-{section.removed}</Text>
322                                {isDeletion(section) ? <Text dimColor>deleted</Text> : null}
323                            </Box>
324                            {isFolded ? null : <Code source={section.patch} format="diff" path={section.path} />}
325                        </Box>
326                    );
327                })}
328                {diffs.omitted.map((path) => (
329                    <Text key={`${row}|${path}:omitted`} dimColor>
330                        past the view's budget, full diff: {path}
331                    </Text>
332                ))}
333            </Box>
334        </Box>
335    );
336}
337
338/**
339 * The diff hook leaves a call's diff to this mod only in a session that set `GENESIS_TOOLS_MODS_SESSION` to its own
340 * id (`MODS_SESSION_VAR` in src/agents/lib/hooks/diff/run.ts; mods-plugin.test.ts keeps the two equal). The hooks of
341 * the session see a mod's `env.set`, so a session that never loaded the mod keeps the hook's own diff message. The
342 * engine wants the name as a literal, so it can list the variables a module writes.
343 */
344let announcedSession: string | undefined;
345
346/** Marks this session as one the mod draws in; again when the id changes (a resumed or forked session). */
347async function announceSession($: EngineInterface): Promise<void> {
348    try {
349        const id = String(await $.session.id());
350
351        if (id !== announcedSession) {
352            await $.env.set("GENESIS_TOOLS_MODS_SESSION", id);
353            announcedSession = id;
354        }
355    } catch {
356        // No mark means the hook draws the diff itself: never worse than before the mark existed.
357    }
358}
359
360/** A stage's generator as a stream the next stage reads, with the `result` a `next(e)` stream carries. */
361function streamOf<C, R>(stage: AsyncGenerator<C, R>): HookStream<C, R> {
362    let settle: (value: R) => void = () => undefined;
363    let fail: (error: unknown) => void = () => undefined;
364    const result = new Promise<R>((resolve, reject) => {
365        settle = resolve;
366        fail = reject;
367    });
368
369    // An unread result must not surface as an unhandled rejection; a stage that reads it still sees the error.
370    result.catch(() => undefined);
371
372    async function* run(): AsyncGenerator<C, R> {
373        try {
374            const value = yield* stage;
375            settle(value);
376
377            return value;
378        } catch (error) {
379            fail(error);
380            throw error;
381        }
382    }
383
384    return Object.assign(run(), { result });
385}
386
387/**
388 * The engine as the feature files reach it. Built here because only this file may hand `$` on (see `shared.ts`);
389 * each closure spells the call it makes, so `claude plugin validate` reads every capability the plugin uses.
390 */
391function hostOf($: EngineInterface): Host {
392    return {
393        run: (argv, init) => $.process.run(argv, init),
394        read: (path) => $.fs.read(path),
395        write: (path, text) => $.fs.write(path, text),
396        // `$.fs.write` replaces a file and the engine has no append, so this is the one place an append runs `cat >>`
397        // in `sh` (making the folder first). When `$.fs` gets an append, only these lines change.
398        append: async (path, text) => {
399            const done = await $.process.run(
400                ["/bin/sh", "-c", 'mkdir -p "$(dirname "$1")" && cat >> "$1"', "sh", path],
401                {
402                    stdin: text,
403                    timeoutMs: 5_000,
404                }
405            );
406
407            if (done.exitCode !== 0) {
408                throw new Error(`append to ${path} failed (exit ${done.exitCode}): ${done.stderr.trim()}`);
409            }
410        },
411        list: (path) => $.fs.list(path),
412        exists: (path) => $.fs.exists(path),
413        stat: (path) => $.fs.stat(path),
414        env: (name) => (name === "HOME" ? $.env.get("HOME") : $.env.get("GENESIS_TOOLS_HOME")),
415        cwd: () => $.session.cwd(),
416        sessionId: () => $.session.id(),
417        messages: () => $.session.messages(),
418        agents: () => $.agent.list(),
419        every: (ms, fn) => {
420            $.clock.every(ms, fn);
421        },
422        storeGet: (key) => $.store.get(key),
423        storeSet: (key, value) => $.store.set(key, value),
424        status: (text) => $.ui.status(text),
425        fill: (args) => $.prompt.fill(args),
426        submit: (text) => $.prompt.submit({ text }),
427        openPane: (id, title, from) => showPane($, { id, title, from, toggle: false }),
428        togglePane: (id, title, from) => showPane($, { id, title, from, toggle: true }),
429        closePane: (id) => $.ui.close({ id }),
430        copy: (text) => $.ui.copy({ text }),
431        scroll: (requestId) => $.ui.scroll({ to: { requestId }, block: "start" }),
432        openUrl: (url) => $.process.run(["open", url], { timeoutMs: 10_000 }),
433        toast: (text) => $.ui.toast(text),
434        redraw: () => $.ui.invalidate("ui.render"),
435    };
436}
437
438/** Whether the terminal docks panes (fullscreen), and its height, as the band's last drawing said. */
439let docksPanes: boolean | undefined;
440let terminalRows = 30;
441/**
442 * The panes as the engine last listed them, read at every draw of the band and of a sidebar. A press decides from
443 * this list, because asking the engine first means an await, and an `$.ui.open` after an await is no longer the
444 * person's: the engine then places it only from 144 columns, and on a narrower terminal it waits undrawn.
445 */
446let knownPanes: readonly UiPane[] = [];
447
448async function notePanes($: EngineInterface): Promise<void> {
449    knownPanes = await $.ui.panes().catch(() => knownPanes);
450}
451
452/**
453 * Shows a sidebar, or with `toggle` closes it when it is the one shown. Above the prompt it takes the keys, and Esc
454 * closes it. Docked beside the transcript it takes them only as `mods.sidebar.focus` says (`command`: when a slash
455 * command opened it; `always`; `never`), so a click on the band leaves the prompt typing.
456 *
457 * Every engine call is made before the first await, so it answers the press. A sidebar open as a tab behind another
458 * is closed and opened again: an open of an open id only retitles it, and only `focus` raises it.
459 */
460function showPane(
461    $: EngineInterface,
462    pane: { id: string; title: string; from?: "command" | "band"; toggle: boolean }
463): Promise<unknown> {
464    const { id, title, from = "band" } = pane;
465    const held = knownPanes.find((known) => known.id === id);
466    const action = !held ? "open" : held.isShown && held.isPlaced ? (pane.toggle ? "close" : "keep") : "raise";
467
468    if (action === "close") {
469        knownPanes = knownPanes.filter((known) => known.id !== id);
470        return $.ui.close({ id }).finally(() => void logShow($, { id, from, action }));
471    }
472
473    if (action === "raise") {
474        void $.ui.close({ id });
475    }
476
477    forgetFocus(id);
478    const rows = Math.max(12, Math.floor(terminalRows * 0.6));
479    const focus = settingsNow().sidebar.focus;
480    const opened =
481        docksPanes !== true
482            ? $.ui.open({ id, title, focus: true, closeOnEscape: true, rows })
483            : $.ui.open(
484                  focus === "always" || (focus === "command" && from === "command")
485                      ? { id, title, focus: true }
486                      : { id, title }
487              );
488
489    return opened.then(async (result) => {
490        await notePanes($);
491        await logShow($, { id, from, action, ...result });
492
493        return result;
494    });
495}
496
497async function logShow($: EngineInterface, record: Record<string, unknown>): Promise<void> {
498    await logMod(hostOf($), "sidebar", { event: "show", ...record });
499}
500
501// The features that share events. The engine takes one unmatched hook per event from a plugin, so each event
502// below has one hook that calls every feature's part in this order.
503const FEATURES: Feature[] = [
504    decisions,
505    inbox,
506    gitBand,
507    spinner,
508    cacheBand,
509    tmpUsage,
510    agentsBand,
511    hookLedger,
512    stallCatcher,
513];
514
515export const register: Register = (on) => {
516    on("session.start", async ($, e, next) => {
517        const host = hostOf($);
518        // A reload runs `session.start` again in the middle of a turn: mark the session at once, not at the next turn.
519        await announceSession($);
520        // Times drawn with `liveAgo` / `liveLeft` redraw when their text changes.
521        startLiveClock(host);
522        // Which genesis.tools links open here, asked outside any render (a newer render aborts an older one's runs).
523        primeGenesisProbe(host);
524
525        for (const feature of FEATURES) {
526            for (const command of feature.commands ?? []) {
527                await $.command.register({
528                    name: command.name,
529                    description: command.description,
530                    ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
531                });
532            }
533
534            await feature.start?.(host);
535        }
536
537        return next(e);
538    });
539
540    on("session.end", async ($, e, next) => {
541        await markEnded(hostOf($));
542
543        return next(e);
544    });
545
546    on("command.run", async ($, e, next) => {
547        const command = FEATURES.flatMap((feature) => feature.commands ?? []).find((held) => held.name === e.command);
548
549        return command ? command.run(hostOf($), e.args ?? "") : next(e);
550    });
551
552    on("tool.call", async ($, e, next) => {
553        const host = hostOf($);
554        // Before the call runs, so its PostToolUse hook sees the mark; a no-op once this module set it.
555        await announceSession($);
556        rememberToolPaths(e);
557        const ends = FEATURES.flatMap((feature) => feature.beforeTool?.(host, e) ?? []);
558
559        try {
560            const result = await next(e);
561
562            afterTools({ host, event: e, features: FEATURES });
563
564            return result;
565        } finally {
566            for (const end of ends) {
567                end();
568            }
569        }
570    });
571
572    // Two stages over the model's stream: the Agent arguments get the model, the main loop's reply text its links.
573    on("turn.step", async function* ($, e, next) {
574        const host = hostOf($);
575
576        for (const feature of FEATURES) {
577            void feature.beforeStep?.(host, e).catch(() => undefined);
578        }
579
580        const settings = await settingsOf(host);
581        let stream: HookStream<TurnStepChunk, TurnStepResult | undefined> = next(e);
582
583        if (settings.agentModel.enabled) {
584            stream = streamOf(agentModelStep(stream, settings.agentModel.format));
585        }
586
587        if (settings.links.enabled && e.agentId === undefined) {
588            stream = streamOf(linksStep(host, e, stream));
589        }
590
591        return yield* stream;
592    });
593
594    on("agent.spawn", async ($, e, next) => {
595        const host = hostOf($);
596        const settings = (await settingsOf(host)).agentModel;
597        const result = await next(settings.enabled ? spawnWithModel(e, settings.format) : e);
598        recordSpawn(host, e, result);
599
600        return result;
601    });
602
603    on("classic.*", async ($, e, next) => {
604        noteTranscript(e);
605        await noteStop(hostOf($), e);
606        const result = await next(e);
607        const kept = await ledgerClassic(hostOf($), e, result);
608
609        return kept ? { ...result, additionalContext: kept } : result;
610    });
611
612    on("turn.start", async ($, e, next) => {
613        stopIdleTimer();
614        clearStall(hostOf($), "new turn");
615        await announceSession($);
616
617        return next(e);
618    });
619
620    on("turn.complete", async ($, e, next) => {
621        const done = await next(e);
622        const host = hostOf($);
623
624        if (e.agentId === undefined) {
625            stopIdleTimer();
626
627            const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host)));
628
629            if (settings.enabled) {
630                const ms = settings.minutes * 60_000;
631                const armed = $.clock.after(ms, () => {
632                    void compactIfIdle($);
633                });
634                idleTimer = armed;
635
636                // The band shows the countdown only when the timer will compact: a small context is skipped at fire time.
637                await publishIdleDue({
638                    current: () => idleTimer === armed,
639                    tokens: $.session
640                        .usage()
641                        .then((usage) => usage.context.tokens)
642                        .catch(() => undefined),
643                    minTokens: settings.minTokens,
644                    at: Date.now() + ms,
645                });
646            }
647        }
648
649        for (const feature of FEATURES) {
650            await feature.afterTurn?.(host, e);
651        }
652
653        return done;
654    });
655
656    on("session.compact", async ($, e, next) => {
657        const result = await next(e);
658        const host = hostOf($);
659
660        // The person's /compact, the engine's own auto or idle compaction: nothing is left to save.
661        if (compactionEndsIdleWait({ agentId: e.agentId, trigger: e.trigger, skipped: result.skip !== undefined })) {
662            stopIdleTimer();
663        }
664
665        if (result.skip === undefined) {
666            for (const feature of FEATURES) {
667                await feature.compacted?.(host, e.agentId);
668            }
669        }
670
671        return result;
672    });
673
674    on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
675        if (e.props.hasSurvey) {
676            return next(e);
677        }
678
679        docksPanes = e.viewport?.isFullscreen ?? docksPanes;
680        terminalRows = e.viewport?.rows ?? terminalRows;
681        await notePanes($);
682        const host = hostOf($);
683        const ui = $.ui.resolve(e);
684        const columns = e.props.bodyColumns;
685        const [hooks, decisionsAt, inboxAt, ...rest] = await Promise.all([
686            hooksChip(host, ui),
687            decisionsChip(host, ui),
688            inboxChip(host, ui),
689            stallRow(host, ui),
690            cacheRow(host, ui),
691            agentsRow(host, ui, columns),
692            tmpRow(host, ui),
693        ]);
694        const chips = [decisionsAt, inboxAt, hooks ? { element: hooks, width: hooksChipWidth() } : null].filter(
695            (chip) => chip !== null
696        );
697        // The first row gives the chips their text and a column before each. `bodyColumns` already leaves out the
698        // engine's five columns at the right end, which hold its `[-]`.
699        const reserve = chips.reduce((sum, chip) => sum + chip.width + 1, 0);
700        const git = await gitRows(host, ui, { columns, firstReserve: reserve });
701
702        // The chips share the first git row; every other checkout's row has the whole width.
703        return bandTree(
704            ui,
705            [
706                topRow(
707                    ui,
708                    git[0] ?? null,
709                    chips.map((chip) => chip.element)
710                ),
711                ...git.slice(1),
712                ...rest,
713            ],
714            await next(e)
715        );
716    });
717
718    // Every feature's sidebar (`Feature.sidebars`), by the pane's id; another plugin's panes pass on.
719    on("ui.render", { component: "Pane" }, async ($, e, next) => {
720        const sidebar = FEATURES.flatMap((feature) => feature.sidebars ?? []).find((held) => held.id === e.requestId);
721
722        if (!sidebar) {
723            return next(e);
724        }
725
726        const host = hostOf($);
727        await notePanes($);
728        const kit = {
729            ui: $.ui.resolve(e),
730            columns: Math.max(1, e.props.bodyColumns - (e.props.placement === "dock" ? 1 : 0)),
731            rows: e.props.scroll.bodyRows,
732            placement: e.props.placement,
733            isFocused: e.props.isFocused,
734            focused: focusOf(e.requestId).key,
735            isOnEngineStop: focusOf(e.requestId).onEngineStop,
736            hints: (await settingsOf(host)).sidebar.hints,
737            pressables: [],
738        };
739
740        return sidebar.draw(host, kit);
741    });
742
743    // Where a sidebar's focus ring is, so the line under a row of Buttons says what the focused one does.
744    on("ui.focus", { component: "Pane" }, async ($, e, next) => {
745        const result = await next(e);
746
747        if (!result.deny && FEATURES.some((feature) => feature.sidebars?.some((held) => held.id === e.requestId))) {
748            noteFocus(e.requestId, e.element);
749            $.ui.invalidate("ui.render");
750        }
751
752        return result;
753    });
754
755    on("prompt.autocomplete", async ($, e, next) => {
756        const host = hostOf($);
757        const mine = (await Promise.all(FEATURES.map((feature) => feature.autocomplete?.(host, e) ?? []))).flat();
758        const below = await next(e);
759
760        return mine.length > 0 ? { suggestions: [...mine, ...below.suggestions] } : below;
761    });
762
763    on("ui.render", { component: "Spinner" }, async ($, e, next) => {
764        if (!(await settingsOf(hostOf($))).spinner.enabled) {
765            return next(e);
766        }
767
768        return next({ ...e, props: { ...e.props, suffix: progressSuffix(e.props.suffix, Date.now()) } });
769    });
770
771    on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
772        const ui = $.ui.resolve(e);
773        const drawn = await withGroupDiffs($, e.props, e.requestId, ui, await next(e));
774        const calls = e.props.calls.map((call) => ({ tool: call.tool, input: call.input }));
775
776        return askOnRow(hostOf($), ui, drawn, {
777            id: e.requestId,
778            isRunning: e.props.isActive || e.props.isExpanded,
779            calls,
780        });
781    });
782
783    on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
784        const ui = $.ui.resolve(e);
785        const { tool, input, output, tool_use_id, isRunning } = e.props;
786        const drawn = await withCallDiffs($, e.props, ui, await next(e));
787
788        return askOnRow(hostOf($), ui, drawn, { id: tool_use_id, isRunning, calls: [{ tool, input, output }] });
789    });
790};
791/** The band's first row: the git row at the left, the chips (decisions, inbox, hooks) at the right edge. */
792function topRow(ui: ElementTable, git: RenderElement | null, chips: readonly RenderElement[]): RenderElement | null {
793    if (chips.length === 0) {
794        return git;
795    }
796
797    const { Box } = ui;
798
799    return (
800        <Box key="band-top" flexDirection="row">
801            <Box flexShrink={1} minWidth={0} overflow="hidden">
802                {git}
803            </Box>
804            <Box flexGrow={1} />
805            <Box flexShrink={0} flexDirection="row" columnGap={1} marginLeft={1}>
806                {chips}
807            </Box>
808        </Box>
809    );
810}
811
812async function withGroupDiffs(
813    $: EngineInterface,
814    props: {
815        isExpanded: boolean;
816        calls: readonly { tool: string; input: unknown; isRunning: boolean; tool_use_id?: string }[];
817    },
818    row: string,
819    ui: ElementTable,
820    drawn: RenderElement
821): Promise<RenderElement> {
822    if (props.isExpanded) {
823        return drawn;
824    }
825
826    const calls = props.calls.flatMap((call) =>
827        call.tool === "Bash" && !call.isRunning && call.tool_use_id ? [{ id: call.tool_use_id, input: call.input }] : []
828    );
829    const diffs = await fullDiffs($, calls);
830
831    if (diffs.sections.length === 0 && diffs.omitted.length === 0) {
832        return drawn;
833    }
834
835    return drawDiffs($, ui, row, diffs, drawn);
836}
837
838async function withCallDiffs(
839    $: EngineInterface,
840    props: { tool: string; isRunning: boolean; tool_use_id: string; input: unknown },
841    ui: ElementTable,
842    drawn: RenderElement
843): Promise<RenderElement> {
844    if (props.tool !== "Bash" || props.isRunning) {
845        return drawn;
846    }
847
848    const diffs = await fullDiffs($, [{ id: props.tool_use_id, input: props.input }]);
849
850    if (diffs.sections.length === 0 && diffs.omitted.length === 0) {
851        return drawn;
852    }
853
854    return drawDiffs($, ui, props.tool_use_id, diffs, drawn);
855}
856
hooks/agent-model.tsx 110 lines
1// The model a subagent runs on, put in front of its description, so the agents panel row reads
2// `general-purpose  sonnet · Pair one`: the model right after the agent's type, which no hook can rename (a
3// `subagentType` rewrite starts another agent). The row itself is Claude Code's `subagentStatusLine` setting, which
4// `mods.agentRow` uses to put the model beside the type (`agent-row.ts`); the engine also replaces a working agent's
5// description there with what it does now. Two hooks, because the places that draw a description read it at two
6// different moments (live probes, 2026-10-22):
7// - The `N background agents launched` block (`├ sonnet · Pair one`) and the Agent row draw the arguments the
8//   model's response recorded. A `tool.call` or `agent.spawn` rewrite comes too late for them, so `turn.step`
9//   rewrites the Agent call's argument JSON as it streams: what leaves the outermost hook is shown and recorded.
10// - The agents panel row and the `Agent "…" finished` line draw the description `agent.spawn` passes on, which
11//   also covers a fork (its model is the parent's, known only at the spawn) and a call a plugin made.
12//
13// Named only when the model is known before the spawn: the call's `model` parameter, or the parent's model for a
14// fork. Without either, the agent's own definition or the default subagent model decides later, so the
15// description stays as it was rather than guess.
16import type { AgentSpawnInput, HookStream, TurnStepChunk, TurnStepResult } from "claude-code";
17import { type Format, modelLabel, modelPrefix } from "./model-label";
18import { isRecord } from "./shared";
19
20/** `sonnet · Pair one`, or `(S) Pair one` in the short form; a description that already has it stays. */
21function withModel(description: string, label: string, format: Format): string {
22    const prefix = modelPrefix(label, format);
23
24    return description.startsWith(prefix) ? description : `${prefix}${description}`;
25}
26
27/** Agent arguments with the model in the description, or `undefined` when they stay as the model wrote them. */
28function labelled(input: unknown, format: Format): Record<string, unknown> | undefined {
29    if (!isRecord(input) || typeof input.description !== "string" || typeof input.model !== "string") {
30        return undefined;
31    }
32
33    if (input.subagent_type === "fork") {
34        return undefined;
35    }
36
37    return { ...input, description: withModel(input.description, modelLabel(input.model, format), format) };
38}
39
40/** The argument JSON of one Agent block, rewritten when it parses and names a model; else as streamed. */
41function rewrittenJson(json: string, format: Format): string {
42    try {
43        const next = labelled(JSON.parse(json), format);
44
45        return next ? JSON.stringify(next) : json;
46    } catch {
47        // Broken JSON stays broken: the engine reports it as the model wrote it.
48        return json;
49    }
50}
51
52/** The turn.step stage: each Agent call's argument JSON is held until it ends, then passed on with the model in it. */
53export async function* agentModelStep(
54    stream: HookStream<TurnStepChunk, TurnStepResult | undefined>,
55    format: Format
56): AsyncGenerator<TurnStepChunk, TurnStepResult | undefined> {
57    const agentBlocks = new Set<number>();
58    const held = new Map<number, string>();
59
60    function* release(): Generator<TurnStepChunk> {
61        for (const [index, json] of held) {
62            yield { kind: "input", index, json: rewrittenJson(json, format) };
63        }
64
65        held.clear();
66    }
67
68    for await (const chunk of stream) {
69        if (chunk.kind === "tool" && chunk.name === "Agent") {
70            yield* release();
71            agentBlocks.add(chunk.index);
72            held.set(chunk.index, "");
73            yield chunk;
74            continue;
75        }
76
77        if (chunk.kind === "input" && agentBlocks.has(chunk.index)) {
78            held.set(chunk.index, (held.get(chunk.index) ?? "") + chunk.json);
79            continue;
80        }
81
82        // The first chunk after an Agent block's arguments (its block end, the next block, the stop) releases them.
83        yield* release();
84        yield chunk;
85    }
86
87    yield* release();
88
89    // Patched too, so a hook above reads the arguments as recorded. A test stub at the bottom returns none.
90    const result: TurnStepResult | undefined = await stream.result;
91
92    if (!result) {
93        return result;
94    }
95
96    return {
97        ...result,
98        toolUses: result.toolUses.map((use) =>
99            use.name === "Agent" ? { ...use, input: labelled(use.input, format) ?? use.input } : use
100        ),
101    };
102}
103
104/** The agent.spawn input with the model in front of its description, when the model is known before the spawn. */
105export function spawnWithModel(e: AgentSpawnInput, format: Format): AgentSpawnInput {
106    const model = e.fork ? e.parentModel : e.model;
107
108    return model ? { ...e, description: withModel(e.description, modelLabel(model, format), format) } : e;
109}
110
hooks/agents-band.tsx 533 lines
1// The live agents band: the working subagents with their model, what they are for, how long they have run, how long
2// since their last tool call, and how many tool calls they made. The engine's agents panel shows none of the first
3// three, and a subagent past `mods.agentsBand.warnMinutes` turns red. Up to three agents show as rows; more fold
4// into one `▸ 8 agents` line that a press opens. A press on an agent opens the Agents sidebar on it: its recent tool
5// calls, what it said last, and actions (message it, ask about it, stop it, copy its id or transcript path).
6//
7// The model and start come from `agent.spawn`'s answer; for an agent spawned before this module loaded (a reload, or
8// a resumed session) from the `agent-<id>.meta.json` Claude Code writes beside its transcript. Activity comes from
9// the subagent's own `tool.call`s.
10import type { AgentInfo, AgentSpawnInput, AgentSpawnResult, ElementTable, RenderElement } from "claude-code";
11import type { SpawnedAgents } from "../types";
12import { liveAgo, liveSince } from "./live";
13import { bareDescription, modelLabel } from "./model-label";
14import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared";
15import {
16    actionRow,
17    dimNote,
18    divider,
19    headerRow,
20    type Kit,
21    listRow,
22    plural,
23    sectionTitle,
24    sidebarFrame,
25} from "./sidebar";
26import { toolLabel } from "./spinner";
27
28export const AGENTS_PANE = "mods-agents";
29
30let spawned: SpawnedAgents = {};
31const activity = new Map<string, { lastAt: number; lastTool: string; tools: number; recent: Recent[] }>();
32/** What `agent-<id>.meta.json` said; null once read and found without a model. */
33const metas = new Map<string, { model: string; at?: number } | null>();
34const pendingMetas = new Map<string, Promise<void>>();
35const transcripts = new Map<string, string>();
36let refreshing: Promise<void> | undefined;
37/** The end of each agent's transcript: its last words, read at most every 5 s. */
38const tails = new Map<string, { at: number; said: string }>();
39const BUSY = new Set(["pending", "running", "waiting"]);
40const MAX_ROWS = 6;
41const FOLD_OVER = 3;
42/** The session's transcript folder, `<projects>/<encoded cwd>/<session id>`, from a classic hook's `transcript_path`. */
43let sessionDir: string | undefined;
44/** The person's fold of the band; null folds it past `FOLD_OVER` agents. */
45let expanded: boolean | null = null;
46let selected: string | undefined;
47
48interface Recent {
49    label: string;
50    at: number;
51}
52
53/** Discover metadata and transcript tails from session start, a timer or a press, never a render. */
54export function refreshAgentDetails(host: Host): Promise<void> {
55    refreshing ??= (async () => {
56        if (!(await settingsOf(host)).agentsBand.enabled) {
57            return;
58        }
59
60        const list = (await host.agents().catch(() => [])).filter((agent) => !agent.teammateId);
61        await Promise.all(
62            list.map(async (agent) => {
63                const path = await transcriptOf(host, agent.id);
64
65                if (path) {
66                    transcripts.set(agent.id, path);
67                }
68
69                if (!spawned[agent.id]?.model) {
70                    await loadMeta(host, agent.id).catch(() => undefined);
71                }
72            })
73        );
74        const chosen = list.find((agent) => agent.id === selected) ?? list[0];
75
76        if (chosen) {
77            await loadTail(host, chosen.id).catch(() => undefined);
78            host.redraw();
79        }
80    })()
81        .catch((error: unknown) => logMod(host, "agents", { event: "details-failed", error: String(error) }))
82        .finally(() => {
83            refreshing = undefined;
84        });
85
86    return refreshing;
87}
88
89export const agentsBand: Feature = {
90    async start(host) {
91        host.every(10_000, () => {
92            void refreshAgentDetails(host);
93        });
94        await refreshAgentDetails(host);
95    },
96    beforeTool(_host, e) {
97        if (e.agentId !== undefined) {
98            const before = activity.get(e.agentId);
99            const now = Date.now();
100            activity.set(e.agentId, {
101                lastAt: now,
102                lastTool: e.tool,
103                tools: (before?.tools ?? 0) + 1,
104                recent: [...(before?.recent ?? []), { label: toolLabel(e.tool, e), at: now }].slice(-12),
105            });
106        }
107
108        return undefined;
109    },
110    sidebars: [{ id: AGENTS_PANE, title: "Agents", draw: agentsPane }],
111    commands: [
112        {
113            name: "mod-agents",
114            description: "The Agents sidebar: every subagent with its model, recent tools, last words and actions",
115            async run(host) {
116                await host.togglePane(AGENTS_PANE, "Agents", "command");
117                await refreshAgentDetails(host);
118
119                return {};
120            },
121        },
122    ],
123};
124
125/** Keeps the model and start of a spawned subagent, as `agent.spawn` answered. */
126export function recordSpawn(host: Host, e: AgentSpawnInput, result: AgentSpawnResult): void {
127    const agentId = result.agentId;
128
129    if (!agentId) {
130        return;
131    }
132
133    const model = result.model || e.model || e.parentModel;
134    spawned = { ...spawned, [agentId]: { model, description: bareDescription(e.description), at: Date.now() } };
135    host.redraw();
136}
137
138/** Remembers where the session's transcripts are, from any classic hook input. */
139export function noteTranscript(input: unknown): void {
140    if (isRecord(input) && typeof input.transcript_path === "string" && !input.agent_id) {
141        sessionDir = input.transcript_path.replace(/\.jsonl$/, "");
142    }
143}
144
145export async function sessionDirOf(host: Host): Promise<string | undefined> {
146    if (sessionDir) {
147        return sessionDir;
148    }
149
150    const home = await host.env("HOME");
151
152    if (!home) {
153        return undefined;
154    }
155
156    const cwd = await host.cwd();
157
158    return `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, "-")}/${await host.sessionId()}`;
159}
160
161async function transcriptOf(host: Host, agentId: string): Promise<string | undefined> {
162    const dir = await sessionDirOf(host);
163
164    return dir ? `${dir}/subagents/agent-${agentId}.jsonl` : undefined;
165}
166
167/** Only completed reads are cached; an aborted or failed read can be tried by the next background refresh. */
168function loadMeta(host: Host, agentId: string): Promise<void> {
169    if (metas.has(agentId)) {
170        return Promise.resolve();
171    }
172
173    const pending = pendingMetas.get(agentId);
174
175    if (pending) {
176        return pending;
177    }
178
179    const work = (async () => {
180        const transcript = transcripts.get(agentId) ?? (await transcriptOf(host, agentId));
181
182        if (!transcript) {
183            return;
184        }
185
186        const path = transcript.replace(/\.jsonl$/, ".meta.json");
187        const text = await host.read(path);
188        const parsed: unknown = text ? JSON.parse(text) : undefined;
189
190        if (isRecord(parsed) && typeof parsed.model === "string") {
191            const stat = await host.stat(path);
192            metas.set(agentId, { model: parsed.model, at: stat.mtimeMs });
193        } else {
194            metas.set(agentId, null);
195        }
196    })().finally(() => {
197        pendingMetas.delete(agentId);
198    });
199    pendingMetas.set(agentId, work);
200
201    return work;
202}
203
204/** The text of the last assistant message in a transcript's end, or "" without one. */
205export function lastSaidOf(tail: string): string {
206    const lines = tail.split("\n").reverse();
207
208    for (const line of lines) {
209        if (!line.includes('"assistant"')) {
210            continue;
211        }
212
213        try {
214            const row: unknown = JSON.parse(line);
215            const message = isRecord(row) && isRecord(row.message) ? row.message : undefined;
216            const content = message && Array.isArray(message.content) ? message.content : [];
217            const text = content
218                .flatMap((part) =>
219                    isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []
220                )
221                .join("\n")
222                .trim();
223
224            if (text) {
225                return text;
226            }
227        } catch {
228            // The first line of a byte-cut tail is half a row.
229        }
230    }
231
232    return "";
233}
234
235async function loadTail(host: Host, agentId: string): Promise<void> {
236    const held = tails.get(agentId);
237
238    if (held && Date.now() - held.at < 5_000) {
239        return;
240    }
241
242    tails.set(agentId, { at: Date.now(), said: held?.said ?? "" });
243    const path = transcripts.get(agentId);
244    const done = path ? await host.run(["tail", "-c", "131072", path], { timeoutMs: 5_000 }) : undefined;
245    const said = done?.exitCode === 0 ? lastSaidOf(done.stdout) : "";
246
247    if (said !== held?.said) {
248        tails.set(agentId, { at: Date.now(), said });
249        host.redraw();
250    }
251}
252
253interface AgentFacts {
254    agent: AgentInfo;
255    model: string | undefined;
256    description: string;
257    age: number | undefined;
258    /** When it started, for its live run time. */
259    startedAt: number | undefined;
260    late: boolean;
261    seen: ReturnType<typeof activity.get>;
262}
263
264export async function factsOf(host: Host, list: readonly AgentInfo[]): Promise<AgentFacts[]> {
265    const all = await settingsOf(host);
266    const now = Date.now();
267
268    return list.map((agent) => {
269        const known = spawned[agent.id];
270
271        const meta = metas.get(agent.id);
272        const model = known?.model ?? meta?.model;
273        const at = known?.at ?? meta?.at;
274        const age = at === undefined ? undefined : now - at;
275
276        return {
277            agent,
278            model: model ? modelLabel(model, all.agentModel.format) : undefined,
279            description: known?.description ?? bareDescription(agent.description),
280            age,
281            startedAt: at,
282            late: BUSY.has(agent.status) && age !== undefined && age > all.agentsBand.warnMinutes * 60_000,
283            seen: activity.get(agent.id),
284        };
285    });
286}
287
288function nameOf(facts: AgentFacts): string {
289    return `${facts.model ? `(${facts.model}) ` : ""}${facts.description}`;
290}
291
292function tailOf(facts: AgentFacts, now: number): string {
293    const parts = [facts.startedAt === undefined ? "" : liveSince(facts.startedAt, now)];
294
295    if (facts.seen) {
296        parts.push(`idle ${liveSince(facts.seen.lastAt, now)}`, plural(facts.seen.tools, "tool"));
297    }
298
299    return parts.filter(Boolean).join(" · ");
300}
301
302function cut(text: string, width: number): string {
303    return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text;
304}
305
306function openOn(host: Host, agentId: string | undefined): void {
307    selected = agentId;
308    void host.openPane(AGENTS_PANE, "Agents");
309}
310
311/** The band's agents rows, or null when no subagent works. */
312export async function agentsRow(host: Host, ui: ElementTable, columns: number): Promise<RenderElement | null> {
313    const settings = (await settingsOf(host)).agentsBand;
314
315    if (!settings.enabled) {
316        return null;
317    }
318
319    const list = (await host.agents().catch(() => [])).filter((agent) => BUSY.has(agent.status) && !agent.teammateId);
320
321    if (list.length === 0) {
322        return null;
323    }
324
325    const { Box, Button, Text } = ui;
326    const now = Date.now();
327    const all = await factsOf(host, list);
328    const isOpen = expanded ?? list.length <= FOLD_OVER;
329    const late = all.filter((facts) => facts.late).length;
330    const toggle = () => {
331        expanded = !isOpen;
332        host.redraw();
333    };
334    const row = (facts: AgentFacts) => {
335        const tail = tailOf(facts, now);
336        const label = cut(`${facts.late ? "⚠" : "●"} ${nameOf(facts)}`, Math.max(20, columns - tail.length - 3));
337
338        return (
339            <Box key={`agent-${facts.agent.id}`} flexDirection="row">
340                <Button key={`agent-${facts.agent.id}`} plain onPress={() => openOn(host, facts.agent.id)}>
341                    {facts.late ? <Text color="red">{label}</Text> : <Text dimColor>{label}</Text>}
342                </Button>
343                <Box flexGrow={1} />
344                <Text dimColor>{tail}</Text>
345            </Box>
346        );
347    };
348    const header =
349        list.length > FOLD_OVER || expanded === true ? (
350            <Box key="agents-head" flexDirection="row" gap={1}>
351                <Button key="agents-toggle" plain onPress={toggle}>
352                    <Text bold>{`${isOpen ? "▾" : "▸"} ${list.length} agents`}</Text>
353                </Button>
354                {late ? <Text color="red">{`${late} past ${settings.warnMinutes}m`}</Text> : null}
355                {isOpen ? null : (
356                    <Text dimColor wrap="truncate-end">
357                        {all.map(nameOf).join(" · ")}
358                    </Text>
359                )}
360                <Box flexGrow={1} />
361                <Button key="agents-open" plain dimColor onPress={() => openOn(host, selected)}>
362                    details
363                </Button>
364            </Box>
365        ) : null;
366    const shown = isOpen ? all.slice(0, MAX_ROWS) : [];
367
368    return (
369        <Box key="agents-band" flexDirection="column">
370            {header}
371            {shown.map(row)}
372            {isOpen && all.length > MAX_ROWS ? (
373                <Button key="agents-more" plain dimColor onPress={() => openOn(host, all[MAX_ROWS]?.agent.id)}>
374                    {`+${all.length - MAX_ROWS} more in the sidebar`}
375                </Button>
376            ) : null}
377        </Box>
378    );
379}
380
381/** The Agents sidebar: every agent of the session, the working ones first, and the chosen one in detail. */
382async function agentsPane(host: Host, kit: Kit): Promise<RenderElement> {
383    const { Box, Text } = kit.ui;
384    const list = (await host.agents().catch(() => [])).filter((agent) => !agent.teammateId);
385    const ordered = [...list.filter((agent) => BUSY.has(agent.status)), ...list.filter((a) => !BUSY.has(a.status))];
386    const all = await factsOf(host, ordered);
387    const now = Date.now();
388    const chosen = all.find((facts) => facts.agent.id === selected) ?? all[0];
389    const busy = all.filter((facts) => BUSY.has(facts.agent.status)).length;
390    const refresh = {
391        key: "agents-refresh",
392        label: "refresh",
393        isDim: true,
394        onPress: () => void refreshAgentDetails(host),
395    };
396
397    if (all.length === 0) {
398        return sidebarFrame(kit, dimNote(kit, "No agents in this session yet."));
399    }
400
401    const rows = all.map((facts) => {
402        const isChosen = facts === chosen;
403        const status = BUSY.has(facts.agent.status) ? tailOf(facts, now) : facts.agent.status;
404
405        return listRow(
406            kit,
407            {
408                key: `pick-${facts.agent.id}`,
409                label: cut(
410                    `${isChosen ? "▸" : facts.late ? "⚠" : " "} ${nameOf(facts)}`,
411                    kit.columns - status.length - 2
412                ),
413                isDim: !isChosen,
414                onPress: () => {
415                    selected = facts.agent.id;
416                    host.redraw();
417                },
418            },
419            <Text dimColor={!facts.late} color={facts.late ? "red" : undefined}>
420                {status}
421            </Text>
422        );
423    });
424
425    return sidebarFrame(
426        kit,
427        <Box flexDirection="column">
428            {headerRow(
429                kit,
430                <Text wrap="truncate-end">
431                    <Text bold>{`${all.length} agents`}</Text>
432                    <Text dimColor>{` · ${busy} working`}</Text>
433                </Text>,
434                [refresh]
435            )}
436            <Box flexDirection="column" marginTop={1} marginBottom={1}>
437                {rows}
438            </Box>
439            {chosen ? await detailOf(host, kit, chosen, now) : null}
440        </Box>
441    );
442}
443
444async function detailOf(host: Host, kit: Kit, facts: AgentFacts, now: number): Promise<RenderElement> {
445    const { Box, Text } = kit.ui;
446    const { agent, seen } = facts;
447    const transcript = transcripts.get(agent.id) ?? "";
448    const said = tails.get(agent.id)?.said ?? "";
449    const address = agent.name ?? agent.id;
450    const quoted = `"${facts.description}" (${agent.id})`;
451    const facts1 = [agent.type, agent.status, agent.name, agent.id].filter(Boolean).join(" · ");
452    const facts2 = [
453        facts.startedAt === undefined ? "" : `ran ${liveSince(facts.startedAt, now)}`,
454        seen ? plural(seen.tools, "tool call") : "",
455        seen ? `last ${seen.lastTool} ${liveAgo(seen.lastAt, { now })}` : "",
456    ]
457        .filter(Boolean)
458        .join(" · ");
459    const fill = (text: string) => void host.fill({ text, mode: "replace" });
460
461    return (
462        <Box flexDirection="column">
463            {divider(kit)}
464            {sectionTitle(kit, nameOf(facts))}
465            {dimNote(kit, facts1, "facts1")}
466            {facts2 ? dimNote(kit, facts2, "facts2") : null}
467            <Box marginTop={1}>
468                {actionRow(kit, [
469                    {
470                        key: "agent-message",
471                        label: "message",
472                        isPrimary: true,
473                        onPress: () => fill(`SendMessage to ${address}: `),
474                    },
475                    {
476                        key: "agent-ask",
477                        label: "ask about it",
478                        onPress: () => fill(`What is the agent ${quoted} doing now? Its transcript: ${transcript}`),
479                    },
480                    {
481                        key: "agent-stop",
482                        label: "stop",
483                        onPress: () => fill(`Stop the agent ${quoted} with TaskStop, then tell me what it finished.`),
484                    },
485                    { key: "agent-copy-id", label: "copy id", isDim: true, onPress: () => void host.copy(agent.id) },
486                    {
487                        key: "agent-copy-path",
488                        label: "copy transcript path",
489                        isDim: true,
490                        onPress: () => void host.copy(transcript),
491                    },
492                ])}
493            </Box>
494            {seen?.recent.length ? (
495                <Box flexDirection="column" marginTop={1}>
496                    {sectionTitle(kit, "Recent tools")}
497                    {[...seen.recent].reverse().map((call, index) => (
498                        <Box key={`recent-${index}`} flexDirection="row">
499                            <Text dimColor wrap="truncate-end">
500                                {call.label}
501                            </Text>
502                            <Box flexGrow={1} />
503                            <Text dimColor>{liveSince(call.at, now)}</Text>
504                        </Box>
505                    ))}
506                </Box>
507            ) : null}
508            {said ? (
509                <Box flexDirection="column" marginTop={1}>
510                    {sectionTitle(kit, "Said last")}
511                    <Text dimColor italic wrap="wrap">
512                        {said.split("\n").slice(-8).join("\n").slice(-900)}
513                    </Text>
514                </Box>
515            ) : null}
516        </Box>
517    );
518}
519
520/** A test starts a different session. */
521export function forgetAgentDetails(): void {
522    spawned = {};
523    activity.clear();
524    metas.clear();
525    pendingMetas.clear();
526    transcripts.clear();
527    tails.clear();
528    refreshing = undefined;
529    sessionDir = undefined;
530    expanded = null;
531    selected = undefined;
532}
533
hooks/ask-row.tsx 103 lines
1// `[ask]` on a finished tool row: a press puts the row, quoted, into the prompt where the cursor is, so a question
2// about a tool call, its output or the hook text in it needs no copy and no screenshot. The folded group line
3// ("ran 3 shell commands") quotes each call's input; a single row also quotes its output, cut to MAX_OUTPUT.
4// Each press goes to `mods-log/ask.jsonl`.
5import type { ElementTable, RenderElement } from "claude-code";
6import { type Host, isRecord, logMod, settingsOf } from "./shared";
7
8const MAX_OUTPUT = 800;
9const MAX_INPUT = 300;
10
11function textOf(value: unknown): string {
12    if (typeof value === "string") {
13        return value;
14    }
15
16    if (Array.isArray(value)) {
17        return value.map(textOf).join("\n");
18    }
19
20    if (isRecord(value)) {
21        if (typeof value.text === "string") {
22            return value.text;
23        }
24
25        if (typeof value.stdout === "string") {
26            return [value.stdout, typeof value.stderr === "string" ? value.stderr : ""].filter(Boolean).join("\n");
27        }
28    }
29
30    return value === undefined ? "" : JSON.stringify(value);
31}
32
33/** What a call was asked to do, in one short line: the command, the path, the pattern, else its JSON. */
34export function inputLine(tool: string, input: unknown): string {
35    const held = isRecord(input) ? input : {};
36    const main = [held.command, held.file_path, held.path, held.pattern, held.description, held.prompt].find(
37        (value): value is string => typeof value === "string"
38    );
39    const text = (main ?? JSON.stringify(input ?? {})).replace(/\s+/g, " ").trim();
40
41    return `${tool}: ${text.length > MAX_INPUT ? `${text.slice(0, MAX_INPUT)}…` : text}`;
42}
43
44function cut(text: string): string {
45    const trimmed = text.trim();
46
47    return trimmed.length > MAX_OUTPUT
48        ? `${trimmed.slice(0, MAX_OUTPUT)}\n… (${trimmed.length - MAX_OUTPUT} more characters)`
49        : trimmed;
50}
51
52/** The quote a press puts into the prompt: each line under `> `, then an empty line for the question. */
53export function quoteOf(rows: readonly { tool: string; input: unknown; output?: unknown }[]): string {
54    const body = rows
55        .map((row) => {
56            const output = row.output === undefined ? "" : cut(textOf(row.output));
57
58            return output ? `${inputLine(row.tool, row.input)}\n${output}` : inputLine(row.tool, row.input);
59        })
60        .join("\n\n");
61
62    return `${body
63        .split("\n")
64        .map((line) => `> ${line}`)
65        .join("\n")}\n\n`;
66}
67
68async function ask(host: Host, id: string, rows: Parameters<typeof quoteOf>[0]): Promise<void> {
69    const text = quoteOf(rows);
70
71    await host.fill({ text, mode: "insert" });
72    await logMod(host, "ask", { id, tools: rows.map((row) => row.tool), chars: text.length });
73}
74
75/** A finished tool row with `[ask]` beside it; the row as drawn while running, or with the mod off. */
76export async function askOnRow(
77    host: Host,
78    ui: ElementTable,
79    drawn: RenderElement,
80    row: { id: string; isRunning: boolean; calls: { tool: string; input: unknown; output?: unknown }[] }
81): Promise<RenderElement> {
82    if (row.isRunning || !(await settingsOf(host)).askRow.enabled) {
83        return drawn;
84    }
85
86    return withButton(ui, drawn, `ask-${row.id}`, () => ask(host, row.id, row.calls));
87}
88
89function withButton(ui: ElementTable, drawn: RenderElement, key: string, onPress: () => Promise<void>): RenderElement {
90    const { Box, Button } = ui;
91
92    return (
93        <Box key={`${key}-row`} flexDirection="row" gap={1} alignItems="flex-end">
94            <Box key={`${key}-drawn`} flexGrow={1} flexShrink={1}>
95                {drawn}
96            </Box>
97            <Button key={key} dimColor onPress={() => void onPress()}>
98                ask
99            </Button>
100        </Box>
101    );
102}
103
hooks/band.tsx 25 lines
1// The band above the prompt: every feature's row (git, cache, agents, /tmp) stacked in a fixed order above what
2// the engine and the plugins beneath draw there. A row is null when its feature is off or has nothing to say.
3import type { ElementTable, RenderElement } from "claude-code";
4
5export function bandTree(
6    ui: ElementTable,
7    rows: readonly (RenderElement | null)[],
8    below: RenderElement
9): RenderElement {
10    const shown = rows.filter((row) => row !== null);
11
12    if (shown.length === 0) {
13        return below;
14    }
15
16    const { Box } = ui;
17
18    return (
19        <Box key="genesis-band" flexDirection="column">
20            {shown}
21            {below}
22        </Box>
23    );
24}
25
hooks/cache-band.tsx 340 lines
1// The cache countdown. Each session writes `<agents>/mods-state/cache/<session>.json` when its main loop sends a model
2// request (each step of a turn reads and writes the prompt cache, so a turn that runs for an hour keeps it warm) and
3// when a turn finishes, and removes it when it ends. The band shows this session's minutes
4// left dim, yellow under `mods.cacheBand.cautionMinutes` and red under `mods.cacheBand.warnMinutes`; with `others`
5// on, it also shows every other session whose cache runs out inside the yellow window, so a reply that would rewrite
6// a cold cache is visible from any open session. A listing runs every 15 seconds; one prune every five minutes
7// removes marks older than the cache lifetime before history can accumulate in each session's reads.
8// The times are live (`live.ts`): a yellow or red chip counts each second, a dim one each minute. After a compaction
9// the chip says so and that the next reply starts a fresh cache, since there is nothing left to keep warm.
10import type { ElementTable, RenderElement } from "claude-code";
11import type { CacheRow, CacheRows } from "../types";
12import { genesisProbe } from "./genesis";
13import { idleDueAt } from "./idle";
14import { liveAgo, liveLeft } from "./live";
15import { agentsDir, type Feature, type Host, isRecord, settingsOf } from "./shared";
16
17let rows: CacheRows = [];
18
19/** Claude Code's prompt cache for a subscription session lives an hour after its last write. */
20const TTL_MS = 60 * 60_000;
21const EVERY_MS = 15_000;
22const PRUNE_MS = 5 * 60_000;
23let prunedAt = 0;
24
25interface CacheMark {
26    sessionId: string;
27    label: string;
28    lastAt: number;
29    ttlMs: number;
30    ended?: boolean;
31    /** When the main loop last compacted; at or after `lastAt` the old cache no longer matters. */
32    compactedAt?: number;
33}
34
35async function folder(host: Host): Promise<string | undefined> {
36    const dir = await agentsDir(host);
37
38    return dir ? `${dir}/mods-state/cache` : undefined;
39}
40
41async function labelOf(host: Host): Promise<string> {
42    const cwd = await host.cwd();
43    const id = await host.sessionId();
44
45    return `${cwd.split("/").filter(Boolean).pop() ?? "~"}·${id.slice(0, 6)}`;
46}
47
48async function mark(host: Host, why: "turn" | "compacted"): Promise<void> {
49    const dir = await folder(host);
50
51    if (!dir) {
52        return;
53    }
54
55    const sessionId = await host.sessionId();
56    const now = Date.now();
57    const held: CacheMark = {
58        sessionId,
59        label: await labelOf(host),
60        lastAt: now,
61        ttlMs: TTL_MS,
62        ...(why === "compacted" ? { compactedAt: now } : {}),
63    };
64
65    // A turn writes the cache again; an end or a compaction keeps the time of the last write.
66    if (why !== "turn") {
67        const before = await host.read(`${dir}/${sessionId}.json`).catch(() => undefined);
68
69        try {
70            const parsed: unknown = before ? JSON.parse(before) : undefined;
71
72            if (isRecord(parsed) && typeof parsed.lastAt === "number") {
73                held.lastAt = parsed.lastAt;
74            }
75        } catch {
76            // A broken mark is rewritten whole.
77        }
78    }
79
80    await host.write(`${dir}/${sessionId}.json`, JSON.stringify(held));
81}
82
83/** This session's row, then the other live sessions inside the warning window, the soonest first. */
84export function cacheRowsOf(marks: readonly CacheMark[], ownId: string, warnMs: number, now: number): CacheRows {
85    const out: CacheRow[] = [];
86
87    for (const held of marks) {
88        const expiresAt = held.lastAt + held.ttlMs;
89        const own = held.sessionId === ownId;
90        const compacted = held.compactedAt !== undefined && held.compactedAt >= held.lastAt;
91
92        if (own) {
93            out.unshift({
94                sessionId: held.sessionId,
95                label: held.label,
96                expiresAt,
97                own,
98                compacted,
99                ...(compacted ? { compactedAt: held.compactedAt } : {}),
100            });
101            continue;
102        }
103
104        if (!held.ended && !compacted && expiresAt > now && expiresAt - now <= warnMs) {
105            out.push({ sessionId: held.sessionId, label: held.label, expiresAt, own, compacted });
106        }
107    }
108
109    return [
110        ...out.filter((row) => row.own),
111        ...out.filter((row) => !row.own).sort((a, b) => a.expiresAt - b.expiresAt),
112    ];
113}
114
115async function scan(host: Host): Promise<void> {
116    const settings = (await settingsOf(host)).cacheBand;
117    const dir = await folder(host);
118
119    if (!settings.enabled || !dir) {
120        rows = [];
121        host.redraw();
122        return;
123    }
124
125    const now = Date.now();
126
127    if (settings.others && now - prunedAt >= PRUNE_MS) {
128        const done = await host
129            .run(["find", dir, "-type", "f", "-name", "*.json", "-mmin", "+60", "-delete"], { timeoutMs: 5_000 })
130            .catch(() => undefined);
131
132        if (done?.exitCode === 0) {
133            prunedAt = now;
134        }
135    }
136
137    const ownId = await host.sessionId();
138    const entries = settings.others
139        ? await host.list(dir).catch(() => [])
140        : [{ name: `${ownId}.json`, kind: "file" as const }];
141    const marks: CacheMark[] = [];
142
143    for (const entry of entries) {
144        if (entry.kind !== "file" || !entry.name.endsWith(".json")) {
145            continue;
146        }
147
148        // Expired historical sessions need no JSON read, even when pruning could not run.
149        if (entry.name !== `${ownId}.json` && "mtimeMs" in entry && entry.mtimeMs + TTL_MS < now) {
150            continue;
151        }
152
153        const text = await host.read(`${dir}/${entry.name}`).catch(() => undefined);
154
155        try {
156            const parsed: unknown = text ? JSON.parse(text) : undefined;
157
158            if (isRecord(parsed) && typeof parsed.sessionId === "string" && typeof parsed.lastAt === "number") {
159                marks.push({
160                    sessionId: parsed.sessionId,
161                    label: String(parsed.label ?? parsed.sessionId.slice(0, 6)),
162                    lastAt: parsed.lastAt,
163                    ttlMs: typeof parsed.ttlMs === "number" ? parsed.ttlMs : TTL_MS,
164                    ended: parsed.ended === true,
165                    ...(typeof parsed.compactedAt === "number" ? { compactedAt: parsed.compactedAt } : {}),
166                });
167            }
168        } catch {
169            // A mark half written by another session is read on the next pass.
170        }
171    }
172
173    const own = rows.find((row) => row.own && row.sessionId === ownId);
174    rows = cacheRowsOf(marks, ownId, alertMs(settings), now);
175
176    // Another session may prune our cold mark; keep the own cold/compacted row while this session stays open.
177    if (own && !rows.some((row) => row.own)) {
178        rows.unshift(own);
179    }
180
181    host.redraw();
182}
183
184export const cacheBand: Feature = {
185    async start(host) {
186        host.every(EVERY_MS, () => {
187            void scan(host);
188        });
189        await scan(host);
190    },
191    async beforeStep(host, e) {
192        if (e.agentId === undefined && (await settingsOf(host)).cacheBand.enabled) {
193            await mark(host, "turn");
194        }
195    },
196    async afterTurn(host, e) {
197        if (e.agentId === undefined && (await settingsOf(host)).cacheBand.enabled) {
198            await mark(host, "turn");
199            void scan(host);
200        }
201    },
202    async compacted(host, agentId) {
203        if (agentId === undefined && (await settingsOf(host)).cacheBand.enabled) {
204            await mark(host, "compacted");
205            void scan(host);
206        }
207    },
208};
209
210/** Remove this session's mark, including when the cache feature was disabled before the session ended. */
211export async function markEnded(host: Host): Promise<void> {
212    const dir = await folder(host);
213
214    if (dir) {
215        await host
216            .run(["find", dir, "-type", "f", "-name", `${await host.sessionId()}.json`, "-delete"], {
217                timeoutMs: 5_000,
218            })
219            .catch(() => undefined);
220    }
221}
222
223/** The wider of the two windows: a chip turns yellow, or red, from this much time left. */
224function alertMs(settings: { warnMinutes: number; cautionMinutes: number }): number {
225    return Math.max(settings.warnMinutes, settings.cautionMinutes) * 60_000;
226}
227
228/** `red` under the warning, `yellow` under the caution, undefined above both. */
229export function cacheTone(left: number, warnMs: number, cautionMs: number): "red" | "yellow" | undefined {
230    if (left <= warnMs) {
231        return "red";
232    }
233
234    return left <= cautionMs ? "yellow" : undefined;
235}
236
237/** Focuses the cmux pane the row's session runs in (`tools claude cmux focus`); says so when no pane has it. */
238async function focusSession(host: Host, row: CacheRow): Promise<void> {
239    try {
240        // `--exact`: a pane that only shows the id on screen (ncdu listing /tmp, an agent that discussed it) is not
241        // the session's; without it a press once focused `ncdu /tmp` (2026-10-25).
242        const run = await host.run(["tools", "claude", "cmux", "focus", row.sessionId, "--first", "--exact"], {
243            timeoutMs: 15_000,
244        });
245
246        if (run.exitCode !== 0) {
247            host.toast(`No cmux pane is known to run ${row.label}; it may have continued as another session`);
248        }
249    } catch {
250        host.toast(`Could not focus ${row.label}: tools claude cmux focus did not run`);
251    }
252}
253
254/** The band's cache row, or null with nothing to show. */
255export async function cacheRow(host: Host, ui: ElementTable): Promise<RenderElement | null> {
256    const held = rows;
257    const settings = (await settingsOf(host)).cacheBand;
258
259    if (!settings.enabled || held.length === 0) {
260        return null;
261    }
262
263    const { Box, Button, Text } = ui;
264    // A press runs `tools claude cmux focus`: without the GenesisTools CLI the chip is plain text.
265    const canFocus = (await genesisProbe(host)).tools;
266    const now = Date.now();
267    const warnMs = settings.warnMinutes * 60_000;
268    const cautionMs = settings.cautionMinutes * 60_000;
269    const cell = (row: CacheRow) => {
270        const left = row.expiresAt - now;
271        const name = row.own ? "cache" : `${row.label} cache`;
272
273        // After a compaction the next prompt is a new, small context: there is no cache worth keeping warm.
274        if (row.compacted) {
275            return (
276                <Text key={row.sessionId} dimColor>
277                    {`compacted ${row.compactedAt ? liveAgo(row.compactedAt, { unit: "minute" }) : ""} · cache restarts`}
278                </Text>
279            );
280        }
281
282        if (left <= 0) {
283            return row.own ? (
284                <Text key={row.sessionId} dimColor>
285                    {`${name} cold`}
286                </Text>
287            ) : null;
288        }
289
290        const tone = cacheTone(left, warnMs, cautionMs);
291        const text = tone ? (
292            <Text backgroundColor={tone} color={tone === "red" ? "white" : "black"} bold>
293                {` ${name} ${liveLeft(row.expiresAt)} `}
294            </Text>
295        ) : (
296            <Text dimColor>{`${name} ${liveLeft(row.expiresAt, { unit: "minute" })}`}</Text>
297        );
298
299        // The own chip also counts down to the idle compaction, which comes before the cache runs out.
300        const due = row.own ? idleDueAt() : undefined;
301        const idle =
302            due !== undefined && due > now ? (
303                <Text color={due - now <= warnMs ? "cyan" : undefined} dimColor={due - now > warnMs}>
304                    {` · AC in ${liveLeft(due)}`}
305                </Text>
306            ) : null;
307
308        // Another session's chip is a Button: a press focuses the cmux pane that session runs in.
309        if (!row.own && !canFocus) {
310            return <Box key={row.sessionId}>{text}</Box>;
311        }
312
313        return row.own ? (
314            <Box key={row.sessionId}>
315                {text}
316                {idle}
317            </Box>
318        ) : (
319            <Button key={row.sessionId} plain onPress={() => void focusSession(host, row)}>
320                {text}
321            </Button>
322        );
323    };
324
325    // Every chip keeps its width and the row is cut at its right edge: a full row once squeezed the own chip to nothing.
326    return (
327        <Box key="cache-band" flexDirection="row" gap={1} overflow="hidden">
328            {held.map((row) => {
329                const drawn = cell(row);
330
331                return drawn ? (
332                    <Box key={`chip-${row.sessionId}`} flexShrink={0}>
333                        {drawn}
334                    </Box>
335                ) : null;
336            })}
337        </Box>
338    );
339}
340
hooks/decisions.tsx 822 lines
1// The Decisions sidebar (`/decisions`, or the `decisions` chip in the band): every `❓ DECISION N` of this session,
2// as the answer MCP's decision log has it (`answers.ts`), grouped by what it waits for. A waiting one opens to its
3// prompt, its options as Buttons and a field for an answer in the person's own words. A press stages the answer in
4// the decision store (`tools question inbox draft`; a second press unstages), so the widget shows the same pick, and a
5// pick made in the widget shows here. [send] sends the session's staged answers through its own prompt, the store
6// recording them as sent; [put in prompt] leaves them in the prompt to edit first. [jump to] scrolls the transcript to
7// where the decision was first asked. A decision the store cannot take (asked in the chat before the last reply) is
8// answered by putting `D16: a)` in the prompt.
9//
10// The session's own messages count too, so the sidebar works without the `tools` CLI and before the log has a new
11// decision: a first ask is a `❓ DECISION N` that is not a link (a repeat is `[❓ DECISION N](…)`), its options are
12// the `a)` lines under it, and a later user message answers it (`D16: a)`, `D16 b)`, `16 a)`, `D16: own words`) or a
13// later assistant message says it replaces it.
14import type { ElementTable, RenderElement } from "claude-code";
15import type { DecisionOption, OpenDecision, OpenDecisions, StagedDecisions } from "../types";
16import {
17    afterQuestionTool,
18    answersOf,
19    type DecisionStatus,
20    findAskRows,
21    jumpTo,
22    refreshAnswers,
23    sendStaged,
24    stageAnswer,
25    startAnswers,
26} from "./answers";
27import { liveAgo } from "./live";
28import { type Feature, type Host, logMod, settingsOf } from "./shared";
29import {
30    actionRow,
31    dimNote,
32    headerRow,
33    type Kit,
34    listRow,
35    type Pressable,
36    plural,
37    pressed,
38    sectionTitle,
39    sidebarFrame,
40    wrapWords,
41} from "./sidebar";
42
43export const DECISIONS_PANE = "genesis-decisions";
44/** What the session's messages say: the decisions still open there, and the numbers a user message answered. */
45let chat: { open: OpenDecisions; answered: Set<number> } = { open: [], answered: new Set() };
46/** Rows opened or closed against `mods.decisions.expand`. */
47const flipped = new Set<number>();
48/** The answered, done and closed decisions shown or folded against `mods.decisions.history`. */
49let historyFlipped = false;
50/**
51 * The fields whose drawn value carries a trailing zero-width mark. The engine empties a field after Enter and keeps
52 * it empty until a drawing passes a `value` different from the one drawn before; the mark flips on each Enter, so the
53 * next drawing differs and the field shows the staged text again. What the field reports is read without it.
54 */
55const marked = new Set<number>();
56const MARK = "\u2060";
57/** What each answer field holds before Enter stages it, so a redraw or a press elsewhere keeps the typed text. */
58const typed = new Map<number, string>();
59
60/** The answer field's local text, including edits not yet staged in the store. */
61export function typedAnswer(number: number): string | undefined {
62    return typed.get(number);
63}
64
65export function editAnswer(number: number, text: string): void {
66    typed.set(number, text);
67}
68
69const ASK = /(^|[^[])❓\s*\**\s*DECISION\s+(\d+)\**\s*[:.]?\s*(.*)$/;
70const OPTION = /^\s*(?:[-*]\s+)?\**\(?([a-z])\)\**\s*(.+)$/;
71const ANSWER_LETTER = /\bD(?:ECISION)?\s*(\d+)\s*[:-]?\s*\(?([a-z])\)/gi;
72const ANSWER_BARE = /^\s*(\d+)\s+([a-z])\)/gm;
73const ANSWER_OWN = /^\s*D(\d+)\s*:\s*(.+)$/gm;
74const REPLACES = /replaces?\s+\[?❓\s*DECISION\s+(\d+)/gi;
75
76function clean(text: string): string {
77    return text
78        .replace(/\*\*/g, "")
79        .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
80        .trim();
81}
82
83/** The decisions an assistant text asks for the first time, with the options listed under each. */
84export function asksIn(text: string): OpenDecision[] {
85    const lines = text.split("\n");
86    const found: OpenDecision[] = [];
87
88    for (let i = 0; i < lines.length; i++) {
89        const match = ASK.exec(lines[i] ?? "");
90
91        if (!match) {
92            continue;
93        }
94
95        let title = clean(match[3] ?? "");
96        const options: DecisionOption[] = [];
97        let j = i + 1;
98
99        for (; j < lines.length && j <= i + 14; j++) {
100            const line = lines[j] ?? "";
101
102            if (ASK.test(line)) {
103                break;
104            }
105
106            const option = OPTION.exec(line);
107
108            if (option) {
109                options.push({ letter: option[1] ?? "", text: clean(option[2] ?? "") });
110                continue;
111            }
112
113            if (!title && line.trim()) {
114                title = clean(line);
115                continue;
116            }
117
118            if (options.length > 0 && line.trim() && !/^\s/.test(line)) {
119                break;
120            }
121        }
122
123        found.push({ n: Number(match[2]), title, options });
124        i = j - 1;
125    }
126
127    return found;
128}
129
130/** The decision numbers a user text answers. */
131export function answersIn(text: string): Set<number> {
132    const answered = new Set<number>();
133
134    for (const pattern of [ANSWER_LETTER, ANSWER_BARE, ANSWER_OWN]) {
135        for (const match of text.matchAll(pattern)) {
136            answered.add(Number(match[1]));
137        }
138    }
139
140    return answered;
141}
142
143/** The decisions still open after reading the messages in order: asked, and since then neither answered nor replaced. */
144export function openDecisions(messages: readonly { role: string; text: string }[]): OpenDecisions {
145    return chatDecisions(messages).open;
146}
147
148/** The open decisions of the messages, and every number a user message answered (asked here or before a compaction). */
149export function chatDecisions(messages: readonly { role: string; text: string }[]): {
150    open: OpenDecisions;
151    answered: Set<number>;
152} {
153    const asked = new Map<number, OpenDecision>();
154    const answered = new Set<number>();
155
156    for (const message of messages) {
157        if (message.role === "assistant") {
158            for (const decision of asksIn(message.text)) {
159                if (!asked.has(decision.n)) {
160                    asked.set(decision.n, decision);
161                }
162            }
163
164            for (const match of message.text.matchAll(REPLACES)) {
165                asked.delete(Number(match[1]));
166            }
167
168            continue;
169        }
170
171        for (const n of answersIn(message.text)) {
172            asked.delete(n);
173            answered.add(n);
174        }
175    }
176
177    return { open: [...asked.values()].sort((a, b) => a.n - b.n), answered };
178}
179
180/** The message `[send]` submits: one `D<n>: <answer>` line per staged decision, in number order. */
181export function stagedMessage(answers: StagedDecisions): string {
182    return Object.entries(answers)
183        .sort(([a], [b]) => Number(a) - Number(b))
184        .map(([n, answer]) => `D${n}: ${answer}`)
185        .join("\n");
186}
187
188/** One decision as the sidebar shows it: the log's row, a chat ask the log does not have yet, or both merged. */
189export interface ShownDecision {
190    n: number;
191    title: string;
192    prompt: string;
193    status: DecisionStatus | "chat-answered";
194    options: { letter: string; text: string }[];
195    recommended?: string;
196    option?: string;
197    answer?: string;
198    /** The staged answer the store holds (state `drafted`): picked letters and a note. */
199    draftOption?: string;
200    draft?: string;
201    at?: number;
202}
203
204/** A staged answer in words: `b)`, `my words`, `b) my words`. */
205export function stagedText(decision: Pick<ShownDecision, "draftOption" | "draft">): string {
206    const letters = [...(decision.draftOption ?? "")].map((letter) => `${letter})`).join(" ");
207
208    return [letters, decision.draft?.trim() ?? ""].filter(Boolean).join(" ");
209}
210
211/**
212 * The log's decisions, plus the chat's open ones it lacks, by number. A decision the log still has waiting that a
213 * user message already answered reads `chat-answered`: the agent has the answer and has not recorded it yet.
214 */
215export function shownDecisions(
216    logged: readonly ShownDecision[],
217    fromChat: { open: OpenDecisions; answered: ReadonlySet<number> }
218): ShownDecision[] {
219    const numbers = new Set(logged.map((decision) => decision.n));
220    const merged = logged.map((decision) =>
221        (decision.status === "waiting" || decision.status === "drafted") && fromChat.answered.has(decision.n)
222            ? { ...decision, status: "chat-answered" as const }
223            : decision
224    );
225    const asked = fromChat.open
226        .filter((decision) => !numbers.has(decision.n))
227        .map((decision) => ({ ...decision, prompt: "", status: "waiting" as const }));
228
229    return [...merged, ...asked].sort((a, b) => a.n - b.n);
230}
231
232const GROUPS: { title: string; statuses: ShownDecision["status"][] }[] = [
233    { title: "Waiting for you", statuses: ["waiting", "drafted"] },
234    { title: "Answered", statuses: ["chat-answered", "answered", "sent", "acknowledged"] },
235    { title: "Done", statuses: ["implemented"] },
236    { title: "Closed", statuses: ["superseded", "dismissed"] },
237];
238
239function isWaiting(decision: ShownDecision): boolean {
240    return decision.status === "waiting" || decision.status === "drafted";
241}
242
243/** The decisions the sidebar and the chip count, newest read. */
244function currentDecisions(): ShownDecision[] {
245    return shownDecisions(answersOf()?.decisions ?? [], chat);
246}
247
248async function readChat(host: Host): Promise<void> {
249    chat = chatDecisions(await host.messages());
250    host.redraw();
251    await findAskRows(
252        host,
253        currentDecisions().map((decision) => decision.n)
254    );
255}
256
257async function refresh(host: Host, why: string): Promise<void> {
258    await Promise.all([readChat(host), refreshAnswers(host, why, true)]);
259}
260
261export const decisions: Feature = {
262    async start(host) {
263        await startAnswers(host);
264
265        if ((await settingsOf(host)).decisions.enabled) {
266            await readChat(host);
267        }
268    },
269    async afterTool(host, e) {
270        if ((await settingsOf(host)).decisions.enabled) {
271            afterQuestionTool(host, e);
272        }
273    },
274    async afterTurn(host, e) {
275        if (e.agentId === undefined && (await settingsOf(host)).decisions.enabled) {
276            await readChat(host);
277        }
278    },
279    sidebars: [{ id: DECISIONS_PANE, title: "Decisions", draw: decisionsPane }],
280    commands: [
281        {
282            name: "decisions",
283            description:
284                "The Decisions sidebar: every decision of this session, a summary by state, stage and send answers, jump to where each was asked",
285            async run(host) {
286                if (!(await settingsOf(host)).decisions.enabled) {
287                    return {
288                        text: "The decisions mod is off: tools agents hooks config set mods.decisions.enabled true --write",
289                    };
290                }
291
292                void startAnswers(host);
293                await host.togglePane(DECISIONS_PANE, "Decisions", "command");
294                await refresh(host, "command");
295                await logMod(host, "decisions", { event: "open", shown: currentDecisions().map((d) => d.n) });
296
297                return {};
298            },
299        },
300    ],
301};
302
303function chipText(list: readonly ShownDecision[], mode: "any" | "waiting" | "off"): string | undefined {
304    const waiting = list.filter(isWaiting).length;
305
306    if (mode === "off" || list.length === 0 || (mode === "waiting" && waiting === 0)) {
307        return undefined;
308    }
309
310    return waiting > 0 ? `decisions ${waiting} waiting` : `decisions ${list.length}`;
311}
312
313/** The band chip, `decisions 1 waiting` or `decisions 4`, with the columns it takes; null when it has nothing to say. */
314export async function decisionsChip(
315    host: Host,
316    ui: ElementTable
317): Promise<{ element: RenderElement; width: number } | null> {
318    const settings = (await settingsOf(host)).decisions;
319
320    if (!settings.enabled) {
321        return null;
322    }
323
324    const list = currentDecisions();
325    const label = chipText(list, settings.chip);
326
327    if (!label) {
328        return null;
329    }
330
331    const { Button, Text } = ui;
332    const waiting = list.some(isWaiting);
333
334    return {
335        element: (
336            <Button key="decisions-chip" plain onPress={() => void host.togglePane(DECISIONS_PANE, "Decisions")}>
337                <Text color={waiting ? "yellow" : undefined} dimColor={!waiting}>
338                    {label}
339                </Text>
340            </Button>
341        ),
342        width: [...label].length,
343    };
344}
345
346/** `text` cut to `cells` characters with `…` where it was cut. */
347function cut(text: string, cells: number): string {
348    const chars = [...text];
349
350    return chars.length <= cells ? text : `${chars.slice(0, cells - 1).join("")}…`;
351}
352
353function tailOf(decision: ShownDecision): { text: string; color?: string } {
354    const ago = decision.at ? ` · ${liveAgo(decision.at, { unit: "minute" })}` : "";
355    const picked = decision.option ? `${decision.option}) ` : "";
356
357    switch (decision.status) {
358        case "waiting":
359            return { text: `waiting${ago}`, color: "yellow" };
360        case "drafted":
361            // The whole answer is in the field below; a long one here took the row and squeezed the title.
362            return { text: `staged ${cut(stagedText(decision), 24)}`, color: "cyan" };
363        case "chat-answered":
364            return { text: "answered in chat", color: "green" };
365        case "answered":
366        case "sent":
367        case "acknowledged":
368            return { text: `${picked}${decision.status}${ago}`, color: "green" };
369        case "implemented":
370            return { text: `${picked}done${ago}` };
371        case "superseded":
372            return { text: "replaced" };
373        case "dismissed":
374            return { text: "dismissed" };
375    }
376}
377
378/** Both actions use the same outgoing batch as the preview, including answered rows with no drafts. */
379export async function sendDecisionAnswers(
380    host: Host,
381    request: { outgoing: string; how: "send" | "fill" }
382): Promise<void> {
383    if (!request.outgoing) {
384        return;
385    }
386
387    if (request.how === "fill") {
388        await host.fill({ text: request.outgoing, mode: "replace" });
389        await pressed(host, "decisions-fill", "In the prompt; the answers stay staged until the agent records them.");
390        return;
391    }
392
393    const fields = new Map(
394        [...typed].filter(([number, text]) => {
395            const decision = answersOf()?.decisions.find((held) => held.n === number);
396            return text === (decision?.draft ?? decision?.answer);
397        })
398    );
399    await pressed(host, "decisions-send", "Sending…", "busy");
400    const sent = await sendStaged(host);
401
402    if (sent.error || !sent.text) {
403        await pressed(host, "decisions-send", `Not sent: ${sent.error ?? "nothing staged"}`, "error");
404        return;
405    }
406
407    for (const number of sent.numbers ?? []) {
408        const before = fields.get(number);
409
410        if (before !== undefined && typed.get(number) === before) {
411            typed.delete(number);
412        }
413    }
414
415    await pressed(
416        host,
417        "decisions-send",
418        `Sent ${plural(sent.numbers?.length ?? 0, "answer")}; the decision log marks them sent.`
419    );
420}
421
422/** The Decisions sidebar: a summary line, the decisions by state, each opening to its options and [jump to]. */
423async function decisionsPane(host: Host, kit: Kit): Promise<RenderElement> {
424    const { Box, Button, Text } = kit.ui;
425    const elements = kit.ui;
426    // The mobile app draws no field yet; there an answer is staged from the options alone.
427    const Input = "Input" in elements ? elements.Input : undefined;
428    const settings = (await settingsOf(host)).decisions;
429    const list = currentDecisions();
430    const read = answersOf();
431    const waiting = list.filter(isWaiting).length;
432    const staged = list.filter((decision) => decision.status === "drafted");
433    const message = stagedMessage(
434        Object.fromEntries(staged.map((decision) => [String(decision.n), stagedText(decision)]))
435    );
436    // Preview, fill and send all use the store's complete outgoing batch, including answered rows.
437    const outgoing = read?.staged ?? message;
438    const width = Math.max(10, kit.columns - 4);
439    const isOpen = (decision: ShownDecision) => {
440        const byDefault = settings.expand === "all" || (settings.expand === "waiting" && isWaiting(decision));
441
442        return flipped.has(decision.n) ? !byDefault : byDefault;
443    };
444    /**
445     * Stages in the decision store: an option toggles, the answer field stores its text (Enter on an empty field
446     * clears it). A decision the store cannot take gets its answer put in the prompt instead.
447     */
448    const stage = async (decision: ShownDecision, answer: { option?: string; text?: string }, key: string) => {
449        await pressed(host, key, "Staging in the decision log…", "busy");
450        const done = await stageAnswer(host, decision.n, answer);
451        const words = answer.option ? `${answer.option})` : (answer.text ?? "");
452
453        if (!done.error) {
454            await pressed(host, key, words ? `Staged ${words}; the widget shows it too.` : "Unstaged.");
455            return;
456        }
457
458        if (/not waiting/.test(done.error) && words) {
459            await host.fill({ text: `D${decision.n}: ${words}\n`, mode: "append" });
460            await pressed(
461                host,
462                key,
463                `The decision log does not have DECISION ${decision.n}; "D${decision.n}: ${words}" is in the prompt instead.`
464            );
465            return;
466        }
467
468        await pressed(host, key, `Not staged: ${done.error}`, "error");
469    };
470    const send = (how: "send" | "fill") => sendDecisionAnswers(host, { outgoing, how });
471
472    /**
473     * The own-answer field. While the ring is elsewhere it sits in a rounded border, so it reads as a field: cyan
474     * while its text is the staged answer. It keeps what was typed (`typed`); Enter stages the text, Enter on an
475     * empty field unstages it, and with `mods.decisions.enter` `stage-then-send` Enter on the staged, unchanged answer
476     * sends every staged answer (a terminal passes no Cmd+Enter to a mod's field).
477     *
478     * While the engine's Input has the keys it takes the pane's whole body width, whatever box holds it, and wraps a
479     * long text itself; inside the border it ran 6 columns over and cut the text. So with the keys the field drops
480     * its border and margin for two cyan rules across the pane. Without the keys the engine draws one cut line, so a
481     * longer answer is a Button under the field's key holding the answer wrapped over as many lines as it takes;
482     * Tab onto it brings the Input back.
483     */
484    const ownField = (decision: ShownDecision): RenderElement | null => {
485        if (!Input) {
486            return null;
487        }
488
489        const key = `d${decision.n}-own`;
490        const stored = decision.draft ?? "";
491        const draft = typedAnswer(decision.n) ?? stored;
492        const unmarked = (value: string) => value.replaceAll(MARK, "");
493        const isOwnStaged = stored !== "" && stored === draft;
494        const sendsOnEnter = isOwnStaged && settings.enter === "stage-then-send";
495        const isEditing = kit.isFocused === true && kit.focused === key;
496        // Inside the margin (2), the border (2) and the padding (2).
497        const inner = Math.max(10, kit.columns - 6);
498        const isLong = [...draft].length > inner;
499        const lines = isLong && !isEditing ? wrapWords(draft, inner, 40) : [];
500        // Only for the status bar's line while the field has focus; Enter is the field's own.
501        kit.pressables?.push({
502            key,
503            label: "answer",
504            hint: sendsOnEnter
505                ? `Enter sends every staged answer now; change the text to stage it again instead.`
506                : `Your own answer to DECISION ${decision.n}. Enter stages it; Enter on an empty field unstages it.`,
507            onPress: () => undefined,
508        });
509        const wrapped = lines.map((line, index) => (
510            <Text key={`${key}-l${index}`} color={isOwnStaged ? "cyan" : undefined}>
511                {line}
512            </Text>
513        ));
514        const field = (
515            <Input
516                key={key}
517                value={marked.has(decision.n) ? `${draft}${MARK}` : draft}
518                placeholder="your own answer, Enter stages it"
519                submitLabel={sendsOnEnter ? "send all" : isOwnStaged ? "staged" : "stage"}
520                onInput={(value: string) => {
521                    editAnswer(decision.n, unmarked(value));
522                }}
523                onSubmit={(value: string) => {
524                    const words = unmarked(value).trim();
525                    editAnswer(decision.n, words);
526
527                    if (marked.has(decision.n)) {
528                        marked.delete(decision.n);
529                    } else {
530                        marked.add(decision.n);
531                    }
532
533                    if (settings.enter === "stage-then-send" && words !== "" && words === stored) {
534                        void send("send");
535                        return;
536                    }
537
538                    void stage(decision, { text: words }, key);
539                }}
540            />
541        );
542
543        if (isEditing) {
544            const rule = (side: string) => (
545                <Text key={`${key}-${side}`} color="cyan" wrap="truncate-end">
546                    {"─".repeat(Math.max(1, kit.columns))}
547                </Text>
548            );
549
550            return (
551                <Box key={`${key}-box`} flexDirection="column">
552                    {rule("top")}
553                    {/* A docked pane keeps its last column; the engine sizes the field to the column before it. */}
554                    <Box marginRight={kit.placement === "dock" ? -1 : 0}>{field}</Box>
555                    {rule("bottom")}
556                </Box>
557            );
558        }
559
560        return (
561            <Box
562                key={`${key}-box`}
563                flexDirection="column"
564                marginLeft={2}
565                borderStyle="round"
566                borderColor={isOwnStaged ? "cyan" : undefined}
567                borderDimColor={!isOwnStaged}
568                paddingX={1}
569            >
570                {isLong ? (
571                    <Button key={key} plain onPress={() => undefined}>
572                        {wrapped}
573                    </Button>
574                ) : (
575                    field
576                )}
577            </Box>
578        );
579    };
580
581    /** The letters of the answer given: the recorded option, else letters the answer starts with (`b)`, `ac)`). */
582    const pickedOf = (decision: ShownDecision): string => {
583        if (decision.option) {
584            return decision.option;
585        }
586
587        return /^\s*([a-z]{1,4})\)/.exec(decision.answer ?? "")?.[1] ?? "";
588    };
589
590    const body = (decision: ShownDecision): RenderElement => {
591        const key = `d${decision.n}`;
592        const jump: Pressable = {
593            key: `${key}-jump`,
594            label: "jump to",
595            hint: `Scrolls the transcript to where DECISION ${decision.n} was first asked.`,
596            onPress: () => void jumpTo(host, `${key}-jump`, { decision: decision.n }),
597        };
598        const picked = pickedOf(decision);
599        // The answer in words, when it says more than the letters picked.
600        const said = (decision.answer ?? "").replace(/^\s*[a-z]{1,4}\)\s*/, "").trim();
601
602        return (
603            <Box key={`${key}-body`} flexDirection="column" marginBottom={1}>
604                <Box flexDirection="column" marginLeft={2}>
605                    {wrapWords(decision.prompt.trim() === decision.title.trim() ? "" : decision.prompt, width, 4).map(
606                        (text, index) => (
607                            <Text key={`${key}-p${index}`} dimColor wrap="truncate-end">
608                                {text}
609                            </Text>
610                        )
611                    )}
612                    {decision.options.map((option) => {
613                        const isStaged = (decision.draftOption ?? "").includes(option.letter);
614                        const isPicked = picked.includes(option.letter);
615                        const mark = isStaged ? "● " : isPicked ? "✓ " : "  ";
616                        const star = decision.recommended === option.letter ? " (recommended)" : "";
617                        const label = `${mark}${option.letter}) ${option.text}${star}`;
618
619                        if (!isWaiting(decision)) {
620                            return (
621                                <Text
622                                    key={`${key}-${option.letter}`}
623                                    color={isPicked ? "green" : undefined}
624                                    bold={isPicked}
625                                    dimColor={!isPicked}
626                                    wrap="truncate-end"
627                                >
628                                    {label}
629                                </Text>
630                            );
631                        }
632
633                        return listRow(kit, {
634                            key: `${key}-${option.letter}`,
635                            label,
636                            ...(isStaged ? { color: "cyan" } : {}),
637                            hint: isStaged
638                                ? `Unstages ${option.letter}) for DECISION ${decision.n}.`
639                                : `Stages ${option.letter}) as the answer to DECISION ${decision.n}; [send] sends every staged answer.`,
640                            onPress: () =>
641                                void stage(
642                                    decision,
643                                    { option: isStaged ? "" : option.letter },
644                                    `d${decision.n}-${option.letter}`
645                                ),
646                        });
647                    })}
648                    {!isWaiting(decision) && said
649                        ? wrapWords(`answer: ${said}`, width, 8).map((text, index) => (
650                              <Text key={`${key}-a${index}`} color="green" wrap="truncate-end">
651                                  {text}
652                              </Text>
653                          ))
654                        : null}
655                </Box>
656                {isWaiting(decision) && Input ? ownField(decision) : null}
657                <Box marginLeft={2}>{actionRow(kit, [jump])}</Box>
658            </Box>
659        );
660    };
661
662    /** One state group: its title and each decision's row, opening to its body. */
663    const group = (title: string, rows: ShownDecision[]): RenderElement | null => {
664        if (rows.length === 0) {
665            return null;
666        }
667
668        return (
669            <Box key={`group-${title}`} flexDirection="column" marginTop={1}>
670                {sectionTitle(kit, title, `(${rows.length})`)}
671                {rows.map((decision) => {
672                    const open = isOpen(decision);
673                    const tail = tailOf(decision);
674
675                    return (
676                        <Box key={`row-d${decision.n}`} flexDirection="column">
677                            {listRow(
678                                kit,
679                                {
680                                    key: `d${decision.n}-row`,
681                                    label: `${open ? "▾" : "▸"} D${decision.n} ${decision.title}`,
682                                    isDim: !isWaiting(decision),
683                                    hint: open
684                                        ? "Folds this decision."
685                                        : "Opens this decision: its options and [jump to].",
686                                    onPress: () => {
687                                        if (flipped.has(decision.n)) {
688                                            flipped.delete(decision.n);
689                                        } else {
690                                            flipped.add(decision.n);
691                                        }
692
693                                        host.redraw();
694                                    },
695                                },
696                                <Text color={tail.color} dimColor={!tail.color} wrap="truncate-end">
697                                    {tail.text}
698                                </Text>
699                            )}
700                            {open ? body(decision) : null}
701                        </Box>
702                    );
703                })}
704            </Box>
705        );
706    };
707
708    const [first, ...rest] = GROUPS;
709    const waitingRows = first ? list.filter((decision) => first.statuses.includes(decision.status)) : [];
710    const history = rest.map((held) => ({
711        title: held.title,
712        rows: list.filter((decision) => held.statuses.includes(decision.status)),
713    }));
714    const historyCount = history.reduce((sum, held) => sum + held.rows.length, 0);
715    const showsHistory = (settings.history === "shown") !== historyFlipped;
716
717    return sidebarFrame(
718        kit,
719        <Box flexDirection="column">
720            {headerRow(
721                kit,
722                <Text wrap="truncate-end">
723                    <Text bold>{plural(list.length, "decision")}</Text>
724                    <Text dimColor>{waiting > 0 ? ` · ${waiting} waiting` : ""}</Text>
725                </Text>,
726                [
727                    {
728                        key: "decisions-refresh",
729                        label: "refresh",
730                        isDim: true,
731                        hint: "Reads the decision log and this session's messages again.",
732                        onPress: () => void refresh(host, "button"),
733                    },
734                ]
735            )}
736            {dimNote(
737                kit,
738                read
739                    ? `From the decision log, read ${liveAgo(read.at, { unit: "minute" })}, and this session's messages.`
740                    : "From this session's messages (the decision log needs the tools CLI)."
741            )}
742            {list.length === 0 ? dimNote(kit, "No decisions in this session yet.") : null}
743            {first ? group(first.title, waitingRows) : null}
744            {outgoing ? (
745                <Box flexDirection="column" marginTop={1}>
746                    {sectionTitle(kit, "Staged", `(${staged.length})`)}
747                    {dimNote(kit, "send submits exactly this:")}
748                    {outgoing.split("\n").flatMap((line, index) =>
749                        wrapWords(line, width, 6).map((text, part) => (
750                            <Text key={`staged-${index}-${part}`} color="cyan" wrap="truncate-end">
751                                {text}
752                            </Text>
753                        ))
754                    )}
755                    {actionRow(kit, [
756                        {
757                            key: "decisions-send",
758                            label: "send",
759                            isPrimary: true,
760                            hotkey: "s",
761                            hint: "Sends every staged answer as one message through this session's prompt (hotkey s); the decision log marks them sent.",
762                            onPress: () => void send("send"),
763                        },
764                        {
765                            key: "decisions-fill",
766                            label: "put in prompt",
767                            hint: "Puts the staged answers in the prompt to edit; they stay staged until the agent records them.",
768                            onPress: () => void send("fill"),
769                        },
770                        {
771                            key: "decisions-clear",
772                            label: "clear",
773                            isDim: true,
774                            hint: "Unstages every answer in the decision store and empties the answer fields.",
775                            onPress: () => {
776                                typed.clear();
777                                // One `tools` start at a time, then one read: never a process per row at once.
778                                void (async () => {
779                                    for (const decision of staged) {
780                                        await stageAnswer(
781                                            host,
782                                            decision.n,
783                                            { option: "", text: "" },
784                                            { refresh: false }
785                                        );
786                                    }
787
788                                    await refreshAnswers(host, "clear", true);
789                                    await pressed(host, "decisions-clear", "Unstaged every answer.");
790                                })();
791                            },
792                        },
793                    ])}
794                </Box>
795            ) : waiting > 0 ? (
796                <Box marginTop={1}>
797                    {dimNote(kit, "Nothing staged: press an option, or type an answer and press Enter.")}
798                </Box>
799            ) : null}
800            {historyCount > 0 ? (
801                <Box flexDirection="column" marginTop={1}>
802                    {listRow(kit, {
803                        key: "decisions-history",
804                        label: showsHistory
805                            ? `▾ hide the ${historyCount} answered, done and closed`
806                            : `▸ show ${plural(historyCount, "answered decision")}`,
807                        isDim: true,
808                        hint: showsHistory
809                            ? "Folds the answered, done and closed decisions into this one row."
810                            : "Shows the answered, done and closed decisions; an opened one marks the answer given.",
811                        onPress: () => {
812                            historyFlipped = !historyFlipped;
813                            host.redraw();
814                        },
815                    })}
816                    {showsHistory ? history.map((held) => group(held.title, held.rows)) : null}
817                </Box>
818            ) : null}
819        </Box>
820    );
821}
822
hooks/genesis.ts 165 lines
1// The plugin's one door to GenesisTools: whether its CLI runs here, and the links to its apps a click really opens.
2// A link to the link host opens something only when GenesisTools.app is installed, macOS gives it https links, and
3// the router has a route for that path; otherwise the click lands in the browser on a page that does not exist. The
4// marketplace installs this plugin without the GenesisTools checkout, so `tools` itself may be missing.
5//
6// The answer comes from `tools genesis-tools probe --json` (GenesisTools `src/utils/genesis-tools/probe.ts`, which
7// this file cannot import): the spawn proves that `tools` runs, and its JSON says which link kinds open. One run per
8// 30 minutes per session, about 0.2 s, started by `session.start` and a timer (`primeGenesisProbe`), never by a
9// render: the engine aborts a render hook's process when a newer render replaces it, and on 2026-10-23 that left
10// every session believing no link kind opened. Every link this plugin writes is built here, from the probe's link
11// host, and only for a kind that opens; any other file asks for one and gets undefined when it would be dead.
12import type { Host } from "./shared";
13
14export const LINK_KINDS = ["md", "finder", "hub", "claude-find"] as const;
15export type LinkKind = (typeof LINK_KINDS)[number];
16
17export interface GenesisProbe {
18    /** `tools` ran: the GenesisTools CLI is installed and on PATH. */
19    tools: boolean;
20    linkHost: string | null;
21    opens: Record<LinkKind, boolean>;
22}
23
24const NO_LINKS: Record<LinkKind, boolean> = { md: false, finder: false, hub: false, "claude-find": false };
25const NOTHING: GenesisProbe = { tools: false, linkHost: null, opens: NO_LINKS };
26const MAX_AGE_MS = 30 * 60_000;
27
28let held: { at: number; value: GenesisProbe } | undefined;
29let pending: Promise<GenesisProbe> | undefined;
30
31function isRecord(value: unknown): value is Record<string, unknown> {
32    return typeof value === "object" && value !== null && !Array.isArray(value);
33}
34
35/** The probe out of `tools genesis-tools probe --json`; undefined for anything else. */
36export function probeOf(stdout: string): GenesisProbe | undefined {
37    let parsed: unknown;
38
39    try {
40        parsed = JSON.parse(stdout);
41    } catch {
42        return undefined;
43    }
44
45    if (!isRecord(parsed) || !isRecord(parsed.links) || !isRecord(parsed.router)) {
46        return undefined;
47    }
48
49    const links = parsed.links;
50    const linkHost = typeof parsed.router.linkHost === "string" ? parsed.router.linkHost : null;
51    const opens = (kind: LinkKind): boolean => {
52        const state = links[kind];
53        return linkHost !== null && isRecord(state) && state.opens === true;
54    };
55
56    return {
57        tools: true,
58        linkHost,
59        opens: { md: opens("md"), finder: opens("finder"), hub: opens("hub"), "claude-find": opens("claude-find") },
60    };
61}
62
63/** Undefined when the run was cut off (the hook that started it was replaced), which says nothing about this Mac. */
64async function runProbe(host: Host): Promise<GenesisProbe | undefined> {
65    try {
66        const run = await host.run(["tools", "genesis-tools", "probe", "--json"], { timeoutMs: 20_000 });
67
68        if (run.exitCode === 0) {
69            // A GenesisTools from before the probe (2026-10-23) prints help: its CLI runs, its links stay unknown.
70            return probeOf(run.stdout) ?? { ...NOTHING, tools: true };
71        }
72
73        // 127: the shell found no `tools`. Any other code: it runs, and the probe failed.
74        return { ...NOTHING, tools: run.exitCode !== 127 };
75    } catch (error) {
76        // A render hook's `$` calls are aborted when a newer render replaces it (the live clock redraws each
77        // second): that run never reached `tools`, so nothing is remembered and the next caller asks again.
78        if (String(error).includes("aborted")) {
79            return undefined;
80        }
81
82        // No `tools` on PATH: the spawn itself fails.
83        return NOTHING;
84    }
85}
86
87function probeWith(host: Host): Promise<GenesisProbe> {
88    pending ??= runProbe(host).then((value) => {
89        pending = undefined;
90
91        if (value === undefined) {
92            return held?.value ?? NOTHING;
93        }
94
95        held = { at: Date.now(), value };
96        return value;
97    });
98
99    return pending;
100}
101
102/**
103 * Runs the probe from the session's start, outside any render, and again every 30 minutes on a timer, so a render
104 * only ever reads the answer. `register.tsx` calls it from `session.start`.
105 */
106export function primeGenesisProbe(host: Host): void {
107    void probeWith(host);
108    host.every(MAX_AGE_MS, () => {
109        void probeWith(host);
110    });
111}
112
113/** What this Mac has: the last answer, or the run in flight; concurrent callers share one run. */
114export async function genesisProbe(host: Host): Promise<GenesisProbe> {
115    if (held && (Date.now() - held.at < MAX_AGE_MS || pending)) {
116        return held.value;
117    }
118
119    return probeWith(host);
120}
121
122/** Drops the remembered probe: a test sets up another Mac. */
123export function forgetGenesisProbe(): void {
124    held = undefined;
125    pending = undefined;
126}
127
128/** A path for a link: `encodeURI`, plus the parentheses a markdown link target cannot hold. */
129export function encodePath(path: string): string {
130    return encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29");
131}
132
133function queryPath(value: string): string {
134    return encodeURIComponent(value).replace(/%2F/g, "/").replace(/\(/g, "%28").replace(/\)/g, "%29");
135}
136
137function linkOn(probe: GenesisProbe, kind: LinkKind, path: string): string | undefined {
138    return probe.linkHost && probe.opens[kind] ? `https://${probe.linkHost}/${path}` : undefined;
139}
140
141/** A note or data file in Genesis Markdown; `range` highlights `12` or `12-30`. */
142export function mdLink(probe: GenesisProbe, path: string, range?: string): string | undefined {
143    return linkOn(probe, "md", `md/open?path=${queryPath(path)}${range ? `&range=${encodeURIComponent(range)}` : ""}`);
144}
145
146/** A folder or file shown in Finder. */
147export function finderLink(probe: GenesisProbe, target: string): string | undefined {
148    return linkOn(probe, "finder", `finder/reveal?target=${queryPath(target)}`);
149}
150
151/** The GenesisTools hub on a PR (`owner/repo#42`), or a repository's commit. */
152export function hubLink(
153    probe: GenesisProbe,
154    query: { pr: string; commit?: string } | { repo: string; commit: string }
155): string | undefined {
156    const pairs = Object.entries(query).filter((pair): pair is [string, string] => typeof pair[1] === "string");
157
158    return linkOn(probe, "hub", `hub?${pairs.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&")}`);
159}
160
161/** Scrolls a live Claude session to where DECISION `n` was first asked. */
162export function decisionLink(probe: GenesisProbe, session: string, n: number): string | undefined {
163    return linkOn(probe, "claude-find", `claude/find/${encodeURIComponent(session)}/decision/${n}`);
164}
165
hooks/git-band.tsx 1539 lines
1// The git band: one line above the prompt per checkout the session works in (`checkouts.ts`: the ones it wrote
2// files in or changed git state in, the last first, up to `mods.git.checkouts`), each with the branch, unpushed and
3// unpulled commits, the dirty count, and the pull requests that matter there (the branch's, plus the ones
4// `/mod-git pr add` tracks) with their CI counts. A session that has worked in no checkout yet shows its own
5// folder; `mods.git.cwd: always` shows that folder first in every session. It refreshes when the session starts,
6// after a Bash call that ran git or gh, after a call that works in another checkout, every 3 minutes, and on
7// `/mod-git refresh`; `gh` answers are reused for 2 minutes. `/mod-git on|off|toggle` switches it for this
8// session, `mods.git.enabled` for every session. Each refresh and command goes to `mods-log/git.jsonl`.
9//
10// `/mod-git add <folder>` (the prompt's typeahead proposes folders, zoxide's too) and the sidebar's field put any
11// checkout in this session's band; `remove` takes it out, the sidebar's `hide` drops a worked-in one until the
12// session works there again. A PR's `track` keeps it in its checkout's row after a branch switch; the branch's own
13// PR shows anyway, so its `hide` is what takes it out of the band.
14//
15// With `mods.git.earlier: show` (the default) the work stays visible after it left the checkout: a branch the session
16// made, pushed or worked on there and then switched away from keeps its PR in the row, and a worktree the session
17// removed keeps its row (`worktree removed`), with its branch and PRs read in the main checkout. A row is named by
18// `mods.git.name`: `Kalendora/enhancements` for a linked worktree (`repo-folder`), `Kalendora`, or `enhancements`.
19import type { ElementTable, RenderElement } from "claude-code";
20import type { BandCheck, BandPr, GitBand, GitBandRows, GitBandSwitch } from "../types";
21import {
22    addCheckout,
23    addedCheckouts,
24    type FolderSuggestion,
25    folderSuggestions,
26    headOf,
27    hideCheckout,
28    noteToolCall,
29    pickRoots,
30    removeAdded,
31    sessionCheckouts,
32    type Touch,
33} from "./checkouts";
34import { type GenesisProbe, genesisProbe, hubLink } from "./genesis";
35import { liveAgo } from "./live";
36import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared";
37import {
38    actionButton,
39    buttonsWidth,
40    divider,
41    type Kit,
42    line,
43    listRow,
44    type Pressable,
45    plural,
46    pressed,
47    sidebarFrame,
48    wrapWords,
49} from "./sidebar";
50
51export const GIT_PANE = "mods-git";
52
53/** The last refresh, one row per checkout; empty until one finished. */
54let rows: GitBandRows = [];
55/** The git root of the session's own folder at the last refresh. */
56let ownRoot: string | undefined;
57/** `/mod-git on|off` for this session; null follows `mods.git.enabled`. */
58let turnedOn: GitBandSwitch = null;
59
60const GH_TTL_MS = 2 * 60_000;
61const EVERY_MS = 3 * 60_000;
62/** The earlier branches of a checkout whose PRs a row looks up, the last first. */
63const EARLIER_PRS = 3;
64const ghCache = new Map<string, { at: number; pr: BandPr | null }>();
65let refreshing: Promise<void> | undefined;
66let requestedRefresh = 0;
67let completedRefresh = 0;
68
69async function run(host: Host, argv: string[], cwd: string): Promise<string | undefined> {
70    const done = await host.run(argv, { cwd, timeoutMs: 15_000 }).catch(() => undefined);
71
72    return done?.exitCode === 0 ? done.stdout : undefined;
73}
74
75async function cwdRoot(host: Host): Promise<string | undefined> {
76    const cwd = await host.cwd();
77
78    return (await run(host, ["git", "rev-parse", "--show-toplevel"], cwd))?.trim() || undefined;
79}
80
81/** The checkouts the band shows now, with why: the added ones, then the ones the session worked in, the last first. */
82async function bandRoots(host: Host): Promise<{ root: string; why: GitBand["why"] }[]> {
83    ownRoot = await cwdRoot(host);
84    const settings = (await settingsOf(host)).git;
85    const all = await sessionCheckouts(host);
86    // With `earlier: hide` a removed worktree takes no row, so it leaves its place to one that is there.
87    const there = await Promise.all(all.map((touch) => host.exists(touch.root).catch(() => true)));
88    const touches = settings.earlier === "show" ? all : all.filter((_, index) => there[index]);
89    const added = await addedCheckouts(host);
90
91    return pickRoots(touches, ownRoot, settings, added).map((root) => ({
92        root,
93        why: added.includes(root) ? "added" : touches.some((touch) => touch.root === root) ? "worked" : "folder",
94    }));
95}
96
97/** The checkout `/mod-git pr` acts on: the band's first row. */
98async function firstRoot(host: Host): Promise<string | undefined> {
99    return rows[0]?.root ?? (await bandRoots(host))[0]?.root;
100}
101
102/** A path with the home as `~`. */
103function tilde(path: string, home: string | undefined): string {
104    return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
105}
106
107/** A checkout's folder name: `shop-web-marketplace-2.0-poc`. */
108export function checkoutName(root: string): string {
109    return root.slice(root.lastIndexOf("/") + 1) || root;
110}
111
112/**
113 * A row's name by `mods.git.name`: `repo-folder` gives a linked worktree as `Kalendora/enhancements` and a main
114 * checkout as `Kalendora`; `repo` gives the main checkout's name alone; `folder` the checkout's own folder.
115 */
116export function labelOf(band: Pick<GitBand, "root" | "repo">, name: "repo-folder" | "repo" | "folder"): string {
117    const folder = checkoutName(band.root);
118    const repo = band.repo ? checkoutName(band.repo) : folder;
119
120    if (name === "folder") {
121        return folder;
122    }
123
124    return name === "repo" || repo === folder ? repo : `${repo}/${folder}`;
125}
126
127const storeKey = (root: string) => `git-band-prs:${root}`;
128
129const hiddenKey = (root: string) => `git-band-hidden-prs:${root}`;
130
131async function numbersAt(host: Host, key: string): Promise<number[]> {
132    const held = await host.storeGet(key);
133
134    return Array.isArray(held) ? held.filter((n): n is number => typeof n === "number") : [];
135}
136
137export function trackedPrs(host: Host, root: string): Promise<number[]> {
138    return numbersAt(host, storeKey(root));
139}
140
141/** The branch PRs the sidebar's `hide` took out of a checkout's band row. */
142function hiddenPrs(host: Host, root: string): Promise<number[]> {
143    return numbersAt(host, hiddenKey(root));
144}
145
146const PASSED = ["SUCCESS", "NEUTRAL", "SKIPPED"];
147const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
148
149/** Each check of `statusCheckRollup`: a check run has `name` and `conclusion`, a commit status `context` and `state`. */
150export function checksOf(rollup: unknown): BandCheck[] {
151    return (Array.isArray(rollup) ? rollup : []).filter(isRecord).map((check) => {
152        const verdict = String(check.conclusion || check.state || "").toUpperCase();
153
154        return {
155            name: String(check.name || check.context || "check"),
156            verdict: PASSED.includes(verdict) ? "pass" : FAILED.includes(verdict) ? "fail" : "pending",
157            description: typeof check.description === "string" ? check.description : "",
158            link: String(check.detailsUrl || check.targetUrl || ""),
159        };
160    });
161}
162
163/**
164 * The checks out of `gh pr checks --json name,description,link,bucket`: one per check (a re-run counts once), with
165 * the description a commit status carries, which `statusCheckRollup` leaves out. Undefined when it is not that list.
166 */
167export function prChecksOf(stdout: string): BandCheck[] | undefined {
168    let parsed: unknown;
169
170    try {
171        parsed = JSON.parse(stdout);
172    } catch {
173        return undefined;
174    }
175
176    if (!Array.isArray(parsed) || parsed.length === 0) {
177        return undefined;
178    }
179
180    return parsed.filter(isRecord).map((check) => {
181        const bucket = String(check.bucket ?? "");
182
183        return {
184            name: String(check.name || "check"),
185            verdict: bucket === "fail" || bucket === "cancel" ? "fail" : bucket === "pending" ? "pending" : "pass",
186            description: typeof check.description === "string" ? check.description : "",
187            link: typeof check.link === "string" ? check.link : "",
188        };
189    });
190}
191
192/** CI counts out of a list of checks. */
193export function countChecks(checks: readonly BandCheck[]): { passed: number; failed: number; pending: number } {
194    return {
195        passed: checks.filter((check) => check.verdict === "pass").length,
196        failed: checks.filter((check) => check.verdict === "fail").length,
197        pending: checks.filter((check) => check.verdict === "pending").length,
198    };
199}
200
201/** CI counts out of `statusCheckRollup`. */
202export function checkCounts(rollup: unknown): { passed: number; failed: number; pending: number } {
203    return countChecks(checksOf(rollup));
204}
205
206/**
207 * A PR from `gh pr view` in `root`: `which` is a number or a branch, or "" for the checked-out branch, whose answer is
208 * kept under `branch` so a switch does not show the last branch's PR for the next 2 minutes.
209 */
210async function prOf(
211    host: Host,
212    ask: { root: string; which: string; tracked: boolean; branch?: string }
213): Promise<BandPr | null> {
214    const { root, which, tracked } = ask;
215    const key = `${root}|${which || `@${ask.branch ?? ""}`}`;
216    const held = ghCache.get(key);
217
218    if (held && Date.now() - held.at < GH_TTL_MS) {
219        return held.pr ? { ...held.pr, tracked } : null;
220    }
221
222    const argv = [
223        "gh",
224        "pr",
225        "view",
226        ...(which ? [which] : []),
227        "--json",
228        "number,title,url,state,isDraft,statusCheckRollup",
229    ];
230    const text = await run(host, argv, root);
231    let pr: BandPr | null = null;
232
233    try {
234        const parsed: unknown = text ? JSON.parse(text) : undefined;
235
236        if (isRecord(parsed) && typeof parsed.number === "number") {
237            pr = {
238                number: parsed.number,
239                title: String(parsed.title ?? ""),
240                url: String(parsed.url ?? ""),
241                state: String(parsed.state ?? ""),
242                draft: parsed.isDraft === true,
243                tracked,
244                own: false,
245                hidden: false,
246                ...checkCounts(parsed.statusCheckRollup),
247                checks: checksOf(parsed.statusCheckRollup),
248            };
249        }
250    } catch {
251        pr = null;
252    }
253
254    // A PR with a check to look at gets `gh pr checks`, which says why a status failed (`Deployment was blocked`)
255    // and counts a re-run once. It exits 1 with a failed check and 8 with a pending one, so its exit code says nothing.
256    if (pr && (pr.failed > 0 || pr.pending > 0)) {
257        const done = await host
258            .run(["gh", "pr", "checks", String(pr.number), "--json", "name,description,link,bucket"], {
259                cwd: root,
260                timeoutMs: 15_000,
261            })
262            .catch(() => undefined);
263        const checks = done ? prChecksOf(done.stdout) : undefined;
264
265        if (checks) {
266            pr = { ...pr, ...countChecks(checks), checks };
267        }
268    }
269
270    ghCache.set(key, { at: Date.now(), pr });
271
272    return pr;
273}
274
275/** `## branch...origin/branch [ahead 2, behind 1]` and one line per changed path, from `git status --porcelain --branch`. */
276export function parseStatus(text: string): Pick<GitBand, "branch" | "ahead" | "behind" | "dirty"> {
277    const [head = "", ...rest] = text.split("\n");
278    const branch = /^## (?:No commits yet on )?([^.\s]+(?:\.(?!\.)[^.\s]*)*)/.exec(head)?.[1] ?? "?";
279
280    return {
281        branch: branch === "HEAD" ? "detached" : branch,
282        ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
283        behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
284        dirty: rest.filter((row) => row.trim()).length,
285    };
286}
287
288/** The line totals of `git diff --numstat HEAD`; a binary file (`-`) counts no lines. */
289export function parseNumstat(text: string): Pick<GitBand, "added" | "removed"> {
290    let added = 0;
291    let removed = 0;
292
293    for (const row of text.split("\n")) {
294        const [plus = "", minus = ""] = row.split("\t");
295        added += /^\d+$/.test(plus) ? Number(plus) : 0;
296        removed += /^\d+$/.test(minus) ? Number(minus) : 0;
297    }
298
299    return { added, removed };
300}
301
302async function isOn(host: Host): Promise<boolean> {
303    const held = turnedOn;
304
305    return held ?? (await settingsOf(host)).git.enabled;
306}
307
308/**
309 * The PRs a row shows: the branch's own (`which` names it, or "" for the checked-out branch), the tracked ones, then
310 * the ones of the branches the session worked on in the checkout before (`touch.branches`), each marked `from`.
311 */
312async function rowPrs(
313    host: Host,
314    at: { cwd: string; root: string; which: string; branch: string; touch?: Touch }
315): Promise<BandPr[]> {
316    const tracked = await trackedPrs(host, at.root);
317    const hidden = await hiddenPrs(host, at.root);
318    const own = await prOf(host, { root: at.cwd, which: at.which, tracked: false, branch: at.branch });
319    const others = await Promise.all(
320        tracked
321            .filter((n) => n !== own?.number)
322            .map((n) => prOf(host, { root: at.cwd, which: String(n), tracked: true }))
323    );
324    const mine = own
325        ? { ...own, own: true, tracked: tracked.includes(own.number), hidden: hidden.includes(own.number) }
326        : null;
327    const prs = [mine, ...others].filter((pr): pr is BandPr => pr !== null);
328    const names = (at.touch?.branches ?? []).filter((name) => name !== at.branch).slice(0, EARLIER_PRS);
329    const earlier = await Promise.all(names.map((name) => prOf(host, { root: at.cwd, which: name, tracked: false })));
330
331    earlier.forEach((pr, index) => {
332        if (pr && !prs.some((held) => held.number === pr.number)) {
333            prs.push({ ...pr, from: names[index], hidden: hidden.includes(pr.number) });
334        }
335    });
336
337    return prs;
338}
339
340/**
341 * The row of a worktree the session removed: the branch it had last, that branch's PRs and the earlier ones, read in
342 * the main checkout; null when the branch has no local ref and no PR left to show.
343 */
344async function goneRow(host: Host, root: string, touch: Touch): Promise<GitBand | null> {
345    const branch = touch.branches?.[0];
346    const repo = touch.repo;
347
348    if (!branch || !repo || repo === root) {
349        return null;
350    }
351
352    const ref = await run(
353        host,
354        ["git", "for-each-ref", "--format=%(refname:short)%09%(upstream:track)", `refs/heads/${branch}`],
355        repo
356    );
357    const sync = ref?.split("\t")[1] ?? "";
358    const prs = await rowPrs(host, { cwd: repo, root, which: branch, branch, touch });
359
360    if (!ref?.trim() && prs.length === 0) {
361        return null;
362    }
363
364    return {
365        root,
366        why: "worked",
367        at: Date.now(),
368        branch,
369        ahead: Number(/ahead (\d+)/.exec(sync)?.[1] ?? 0),
370        behind: Number(/behind (\d+)/.exec(sync)?.[1] ?? 0),
371        dirty: 0,
372        added: 0,
373        removed: 0,
374        prs,
375        repo,
376        gone: true,
377    };
378}
379
380/**
381 * One checkout's row: undefined when git cannot read it this time (the last row stays), null when its folder is gone
382 * and there is nothing left to show.
383 */
384async function readRow(host: Host, root: string, why: GitBand["why"]): Promise<GitBand | null | undefined> {
385    const status = await run(host, ["git", "status", "--porcelain", "--branch"], root);
386    const earlier = (await settingsOf(host)).git.earlier === "show";
387    const touch = earlier ? (await sessionCheckouts(host)).find((held) => held.root === root) : undefined;
388
389    if (status === undefined) {
390        if (await host.exists(root).catch(() => true)) {
391            return undefined;
392        }
393
394        return touch ? goneRow(host, root, touch) : null;
395    }
396
397    const numstat = parseNumstat((await run(host, ["git", "diff", "--numstat", "HEAD"], root)) ?? "");
398    const state = parseStatus(status);
399    const prs = await rowPrs(host, { cwd: root, root, which: "", branch: state.branch, touch });
400    const { repo } = await headOf(host, root);
401
402    return { root, why, at: Date.now(), ...state, ...numstat, prs, repo };
403}
404
405async function refreshNow(host: Host, why: string): Promise<void> {
406    const started = Date.now();
407    const roots = await bandRoots(host);
408    const read = await Promise.all(roots.map(({ root, why }) => readRow(host, root, why)));
409    // A checkout git could not read this time keeps its last row; one whose folder is gone with nothing to show goes.
410    const next = roots
411        .map(({ root }, index) => {
412            const row = read[index];
413
414            return row === null ? undefined : (row ?? rows.find((held) => held.root === root));
415        })
416        .filter((row): row is GitBand => row !== undefined);
417
418    rows = next;
419    host.redraw();
420    await logMod(host, "git", {
421        event: "refresh",
422        why,
423        rows: next.map((band) => ({
424            root: band.root,
425            branch: band.branch,
426            ahead: band.ahead,
427            behind: band.behind,
428            dirty: band.dirty,
429            added: band.added,
430            removed: band.removed,
431            gone: band.gone,
432            prs: band.prs.map((pr) => ({
433                n: pr.number,
434                from: pr.from,
435                passed: pr.passed,
436                failed: pr.failed,
437                pending: pr.pending,
438            })),
439        })),
440        ms: Date.now() - started,
441    });
442}
443
444/** One reader at a time; a request made during a read also waits for a pass that includes that request. */
445export async function refresh(host: Host, why: string): Promise<void> {
446    const requested = ++requestedRefresh;
447
448    while (completedRefresh < requested) {
449        refreshing ??= (async () => {
450            const through = requestedRefresh;
451            await refreshNow(host, why);
452            completedRefresh = through;
453        })().finally(() => {
454            refreshing = undefined;
455        });
456        await refreshing;
457    }
458}
459
460function backgroundRefresh(host: Host, why: string): void {
461    void refresh(host, why).catch(async (error: unknown) => {
462        await logMod(host, "git", { event: "refresh-failed", why, error: String(error) });
463        host.toast(`Could not refresh this session's checkouts: ${String(error)}`);
464    });
465}
466
467const USAGE =
468    "/mod-git [show] | on | off | toggle | refresh | status | add <folder> | remove <folder> | pr add <n…> | pr rm <n…> | pr list";
469
470/** The answer to `/mod-git <args>`. */
471async function command(host: Host, args: string): Promise<string> {
472    const [verb = "show", sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
473
474    if (verb === "show") {
475        await host.togglePane(GIT_PANE, "Git", "command");
476
477        return "";
478    }
479
480    if (verb === "on" || verb === "off" || verb === "toggle") {
481        const now = verb === "toggle" ? !(await isOn(host)) : verb === "on";
482        turnedOn = now;
483        host.redraw();
484
485        if (now) {
486            await refresh(host, "command");
487        }
488
489        return `Git band ${now ? "on" : "off"} for this session.`;
490    }
491
492    if (verb === "refresh") {
493        ghCache.clear();
494        await refresh(host, "command");
495
496        return "Git band refreshed.";
497    }
498
499    if (verb === "add" || verb === "remove") {
500        const folder = args.trim().slice(verb.length).trim();
501
502        if (!folder) {
503            return USAGE;
504        }
505
506        if (verb === "add") {
507            const done = await addCheckout(host, folder);
508
509            if ("error" in done) {
510                return `Not added: ${done.error}.`;
511            }
512
513            await refresh(host, "add");
514
515            return `${checkoutName(done.root)} (${done.root}) is in this session's git band.`;
516        }
517
518        const gone = await removeAdded(host, folder);
519        const added = await addedCheckouts(host);
520
521        if (!gone) {
522            return `No added folder ${folder}. Added here: ${added.map(checkoutName).join(", ") || "none"}.`;
523        }
524
525        await refresh(host, "remove");
526
527        return `${checkoutName(gone)} is out of this session's git band.`;
528    }
529
530    const root = await firstRoot(host);
531
532    if (!root) {
533        return "Not in a git repository.";
534    }
535
536    const where = checkoutName(root);
537
538    if (verb === "pr") {
539        const numbers = rest.map((n) => Number(n.replace(/^#/, ""))).filter((n) => Number.isInteger(n) && n > 0);
540        const held = await trackedPrs(host, root);
541
542        if (sub === "list" || sub === undefined) {
543            return held.length
544                ? `Tracked in ${where}: ${held.map((n) => `#${n}`).join(", ")}`
545                : `No tracked PRs in ${where}.`;
546        }
547
548        if ((sub !== "add" && sub !== "rm") || numbers.length === 0) {
549            return USAGE;
550        }
551
552        const next = sub === "add" ? [...new Set([...held, ...numbers])] : held.filter((n) => !numbers.includes(n));
553        await host.storeSet(storeKey(root), next);
554        await refresh(host, `pr ${sub}`);
555
556        return `Tracked in ${where}: ${next.length ? next.map((n) => `#${n}`).join(", ") : "none"}`;
557    }
558
559    if (verb === "status") {
560        const band = rows[0];
561        const on = await isOn(host);
562        const name = (await settingsOf(host)).git.name;
563        const shown = rows.length ? `; checkouts ${rows.map((row) => labelOf(row, name)).join(", ")}` : "";
564
565        return `Git band ${on ? "on" : "off"}; last refresh ${band ? new Date(band.at).toLocaleTimeString() : "never"}${shown}.`;
566    }
567
568    return USAGE;
569}
570
571export const gitBand: Feature = {
572    async start(host) {
573        host.every(EVERY_MS, () => {
574            void isOn(host).then((yes) => {
575                if (yes) {
576                    backgroundRefresh(host, "timer");
577                }
578            });
579        });
580
581        if (await isOn(host)) {
582            backgroundRefresh(host, "start");
583        }
584    },
585    commands: [
586        {
587            name: "mod-git",
588            description:
589                "The git band: no argument opens its sidebar; add <folder> and remove <folder> put a checkout in this session's band; on, off, toggle, refresh, status, pr add <n>, pr rm <n>, pr list",
590            argumentHint: "add <folder>|remove <folder>|on|off|refresh|pr add <n>",
591            async run(host, args) {
592                try {
593                    const text = await command(host, args);
594                    await logMod(host, "git", { event: "command", args, answer: text });
595
596                    return text ? { text } : {};
597                } catch (error) {
598                    await logMod(host, "git", { event: "command-failed", args, error: String(error) });
599                    return { text: `Git command failed: ${String(error)}` };
600                }
601            },
602        },
603    ],
604    sidebars: [{ id: GIT_PANE, title: "Git", draw: gitPane }],
605    async autocomplete(host, e) {
606        const head = /^\/mod-git\s+(add|remove)\s+/.exec(e.text);
607
608        if (!head || e.start < head[0].length || !(await isOn(host))) {
609            return [];
610        }
611
612        const home = await host.env("HOME");
613
614        if (head[1] === "remove") {
615            const needle = e.token.toLowerCase();
616
617            return (await addedCheckouts(host))
618                .filter((root) => root.toLowerCase().includes(needle))
619                .map((root) => ({ text: root, label: tilde(root, home), description: "added to this session's band" }));
620        }
621
622        return (await folderSuggestions(host, e.token)).map((found) => ({
623            text: found.path,
624            label: tilde(found.path, home),
625            description: `${found.isGit ? "git checkout" : "not in git"} · ${found.from}`,
626        }));
627    },
628    async afterTool(host, e) {
629        if (!(await isOn(host))) {
630            return;
631        }
632
633        try {
634            const moved = await noteToolCall(host, e);
635
636            if (moved || (e.tool === "Bash" && /(^|[\s;&|(])(git|gh)\s/.test(e.command))) {
637                backgroundRefresh(host, moved ? "checkout" : "tool");
638            }
639        } catch (error) {
640            await logMod(host, "git", { event: "checkout-failed", error: String(error) });
641            host.toast(`Could not save or refresh this session's checkouts: ${String(error)}`);
642        }
643    },
644};
645
646function ciText(pr: BandPr): string {
647    const parts = [
648        pr.passed ? `✓${pr.passed}` : "",
649        pr.failed ? `✗${pr.failed}` : "",
650        pr.pending ? `…${pr.pending}` : "",
651    ];
652
653    return parts.filter(Boolean).join(" ") || "no CI";
654}
655
656function prColor(pr: BandPr): string {
657    return pr.failed ? "red" : pr.pending ? "yellow" : pr.state === "MERGED" ? "magenta" : "green";
658}
659
660function prNote(pr: BandPr): string {
661    return `${pr.draft ? " draft" : ""}${pr.state === "OPEN" ? "" : ` ${pr.state.toLowerCase()}`} ${ciText(pr)}`;
662}
663
664/** The hub link for a GitHub PR (`hub?pr=owner%2Frepo%23507`); undefined for another forge or where it opens nothing. */
665export function hubUrlOf(probe: GenesisProbe, pr: Pick<BandPr, "url" | "number">): string | undefined {
666    const slug = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(pr.url)?.[1];
667
668    return slug ? hubLink(probe, { pr: `${slug}#${pr.number}` }) : undefined;
669}
670
671/** The share of the room left for the name and the branch that the checkout name may take when both cannot fit. */
672const NAME_SHARE = 0.4;
673/** The shortest a cut name or branch gets. */
674const LABEL_MIN = 8;
675
676/** `feat/2026-10-13-enhancements-unified` as `feat/2026-10-13…ements-unified` at 30. */
677export function middleCut(text: string, max: number): string {
678    if (text.length <= max) {
679        return text;
680    }
681
682    const head = Math.ceil((max - 1) / 2);
683
684    return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`;
685}
686
687/** Whether the rows name their checkout: always, unless the one row is the session's own folder. */
688export function namesCheckouts(shown: readonly Pick<GitBand, "root">[], own: string | undefined): boolean {
689    return shown.length > 1 || (shown.length === 1 && shown[0]?.root !== own);
690}
691
692/** Columns a text takes in the terminal: one per character, as every symbol the band draws is narrow. */
693function widthOf(text: string): number {
694    return [...text].length;
695}
696
697/**
698 * The checkout name and the branch, cut to `room` columns together. Both fit whole when they can; else the name
699 * keeps up to 40% of the room and the branch the rest, each keeping its start and end.
700 */
701export function fitLabels(name: string | undefined, branch: string, room: number): { name?: string; branch: string } {
702    const nameWidth = name === undefined ? 0 : widthOf(name);
703
704    if (nameWidth + widthOf(branch) <= room) {
705        return { name, branch };
706    }
707
708    const nameRoom = name === undefined ? 0 : Math.min(nameWidth, Math.max(LABEL_MIN, Math.floor(room * NAME_SHARE)));
709
710    return {
711        name: name === undefined ? undefined : middleCut(name, nameRoom),
712        branch: middleCut(branch, Math.max(LABEL_MIN, room - nameRoom)),
713    };
714}
715
716/** The PRs a band row shows: all but the ones the sidebar's `hide` took out. */
717function shownPrs(band: GitBand): BandPr[] {
718    return band.prs.filter((pr) => !pr.hidden);
719}
720
721/** How much a band row says after the branch: words, signs only, or signs with bare PR numbers. */
722type Wording = "full" | "signs" | "bare";
723
724/** The texts after the branch, in order: unpushed and unpulled, the changed files, their lines, then each PR. */
725function countsOf(band: GitBand, wording: Wording): { sync: string; dirty: string; lines: string; prs: string[] } {
726    const words = wording === "full";
727
728    return {
729        sync: [
730            band.ahead ? `↑${band.ahead}${words ? " to push" : ""}` : "",
731            band.behind ? `↓${band.behind}${words ? " to pull" : ""}` : "",
732        ]
733            .filter(Boolean)
734            .join(words ? " · " : " "),
735        dirty: band.gone
736            ? words
737                ? "worktree removed"
738                : "removed"
739            : band.dirty
740              ? words
741                  ? `${plural(band.dirty, "file")} changed`
742                  : `●${band.dirty}`
743              : "clean",
744        lines: band.dirty && (band.added || band.removed) ? `+${band.added} −${band.removed}` : "",
745        prs: shownPrs(band).map((pr) => `#${pr.number}${wording === "bare" ? "" : prNote(pr)}`),
746    };
747}
748
749/** The columns a row's counts take: one of gap before each part, then `⎇ ` and the space after a name. */
750function countsWidth(counts: ReturnType<typeof countsOf>, named: boolean): number {
751    const parts = [counts.sync, counts.dirty, counts.lines, ...counts.prs].filter(Boolean);
752
753    return parts.reduce((sum, part) => sum + widthOf(part) + 1, 0) + 2 + (named ? 1 : 0);
754}
755
756/**
757 * The fullest wording whose counts leave the name and the branch their shortest cut: words when they fit
758 * (`mods.git.labels: full`), else signs (`↑27 ↓2 ●13`), else signs with the PRs as bare numbers.
759 */
760export function wordingFor(band: GitBand, room: number, named: boolean, labelled: boolean): Wording {
761    const need = LABEL_MIN * (named ? 2 : 1);
762    const tries: Wording[] = labelled ? ["full", "signs", "bare"] : ["signs", "bare"];
763
764    return tries.find((wording) => room - countsWidth(countsOf(band, wording), named) >= need) ?? "bare";
765}
766
767/**
768 * The band's git rows, one per checkout; none while the band is off or has nothing yet. Each row fits `columns`,
769 * the first one less `firstReserve` (the hooks chip beside it): the counts keep their width, in words while they
770 * fit, else in signs, and the checkout name and the branch are cut to what is left. A row still too wide is cut at
771 * the right edge; nothing wraps. Each PR is a Button (it inverts under the pointer, so it reads as one) that opens
772 * the Git sidebar; the sidebar opens the PR in the browser or the hub.
773 */
774export async function gitRows(
775    host: Host,
776    ui: ElementTable,
777    room: { columns: number; firstReserve: number }
778): Promise<RenderElement[]> {
779    const shown = rows;
780
781    if (shown.length === 0 || !(await isOn(host))) {
782        return [];
783    }
784
785    const { Box, Button, Text } = ui;
786    const git = (await settingsOf(host)).git;
787    const labelled = git.labels === "full";
788    const named = namesCheckouts(shown, ownRoot);
789    const open = () => void host.togglePane(GIT_PANE, "Git");
790
791    const widthAt = (index: number) => room.columns - (index === 0 ? room.firstReserve : 0);
792    // Every row says its counts the same way: the shortest wording any row needs.
793    const order: Wording[] = ["full", "signs", "bare"];
794    const wording =
795        order[
796            Math.max(...shown.map((band, index) => order.indexOf(wordingFor(band, widthAt(index), named, labelled))))
797        ] ?? "bare";
798
799    return shown.map((band, index) => {
800        const width = widthAt(index);
801        const counts = countsOf(band, wording);
802        const labels = fitLabels(
803            named ? labelOf(band, git.name) : undefined,
804            band.branch,
805            width - countsWidth(counts, named)
806        );
807
808        return (
809            <Box key={`git-${band.root}`} flexDirection="row" gap={1} overflow="hidden">
810                <Box flexShrink={0}>
811                    <Button key="git-branch" plain onPress={open}>
812                        {labels.name === undefined ? null : <Text color="cyan">{`${labels.name} `}</Text>}
813                        <Text dimColor>⎇ </Text>
814                        <Text bold>{labels.branch}</Text>
815                    </Button>
816                </Box>
817                {counts.sync ? (
818                    <Text color="yellow" wrap="truncate-end">
819                        {counts.sync}
820                    </Text>
821                ) : null}
822                <Text color={band.dirty ? "magenta" : undefined} dimColor={!band.dirty} wrap="truncate-end">
823                    {counts.dirty}
824                </Text>
825                {counts.lines ? (
826                    <Text wrap="truncate-end">
827                        <Text color="green">{`+${band.added}`}</Text>
828                        <Text> </Text>
829                        <Text color="red">{`−${band.removed}`}</Text>
830                    </Text>
831                ) : null}
832                {shownPrs(band).map((pr) => (
833                    <Box key={`pr-${pr.number}`} flexShrink={0}>
834                        <Button key={`pr-${pr.number}`} plain onPress={open}>
835                            <Text color={prColor(pr)} underline>{`#${pr.number}`}</Text>
836                            {wording === "bare" ? null : <Text color={prColor(pr)}>{prNote(pr)}</Text>}
837                        </Button>
838                    </Box>
839                ))}
840            </Box>
841        );
842    });
843}
844
845/** What the add field holds, and the folders it proposes for it. */
846let addQuery = "";
847let addFound: FolderSuggestion[] = [];
848let addAsked = 0;
849
850async function proposeFolders(host: Host, typed: string): Promise<void> {
851    const asked = ++addAsked;
852    const found = await folderSuggestions(host, typed, 5);
853
854    if (asked === addAsked) {
855        addFound = found;
856        host.redraw();
857    }
858}
859
860/** Adds what the field holds: the folder typed, or else the first one proposed. */
861async function addFolder(host: Host, typed: string): Promise<void> {
862    try {
863        await addFolderNow(host, typed);
864    } catch (error) {
865        await logMod(host, "git", { event: "add-failed", path: typed, error: String(error) });
866        await pressed(host, "git-add", `Not added: ${String(error)}`, "error");
867    }
868}
869
870async function addFolderNow(host: Host, typed: string): Promise<void> {
871    const text = typed.trim();
872
873    if (!text) {
874        return;
875    }
876
877    await pressed(host, "git-add", `Adding ${text}…`, "busy");
878    let done = await addCheckout(host, text);
879
880    if ("error" in done && addFound[0]) {
881        done = await addCheckout(host, addFound[0].path);
882    }
883
884    if ("error" in done) {
885        await pressed(host, "git-add", `Not added: ${done.error}.`, "error");
886        return;
887    }
888
889    addQuery = "";
890    addFound = [];
891
892    try {
893        await refresh(host, "sidebar add");
894    } catch (error) {
895        await logMod(host, "git", { event: "refresh-failed", root: done.root, error: String(error) });
896        await pressed(
897            host,
898            "git-add",
899            `Added ${checkoutName(done.root)}, but refresh failed: ${String(error)}`,
900            "error"
901        );
902        return;
903    }
904
905    await pressed(host, "git-add", `Added ${checkoutName(done.root)} (${done.root}) to this session's band.`);
906}
907
908/** Runs `open` on a URL or a folder and says how it went. */
909async function openAndSay(host: Host, key: string, target: string, done: string): Promise<void> {
910    const run = await host.openUrl(target).catch((error: unknown) => error);
911    const code = isRecord(run) && typeof run.exitCode === "number" ? run.exitCode : -1;
912
913    await pressed(host, key, code === 0 ? done : `open ${target} failed (exit ${code}).`, code === 0 ? "ok" : "error");
914}
915
916/** Why a checkout is in the band, in two or three words. */
917function whyText(band: GitBand): string {
918    return band.why === "added" ? "added by hand" : band.why === "worked" ? "worked in" : "own folder";
919}
920
921/** A checkout's state in short: `↑24 to push · ↓2 to pull · 16 changed +643 −1359`, or `clean`; zeros left out. */
922function stateParts(band: GitBand): { text: string; color?: string }[] {
923    const parts: { text: string; color?: string }[] = [];
924
925    if (band.ahead) {
926        parts.push({ text: `↑${band.ahead} to push`, color: "yellow" });
927    }
928
929    if (band.behind) {
930        parts.push({ text: `↓${band.behind} to pull`, color: "yellow" });
931    }
932
933    if (band.dirty) {
934        parts.push({ text: `${band.dirty} changed`, color: "magenta" });
935
936        if (band.added || band.removed) {
937            parts.push({ text: `+${band.added}`, color: "green" }, { text: `−${band.removed}`, color: "red" });
938        }
939    }
940
941    if (band.gone) {
942        parts.push({ text: "worktree removed" });
943    }
944
945    return parts.length ? parts : [{ text: "clean" }];
946}
947
948/** A sidebar toggle changes its row only after persistence succeeds, then reads git again. */
949export async function toggle(
950    host: Host,
951    key: string,
952    work: { busy: string; flip: () => void; store: () => Promise<unknown>; done: string }
953): Promise<void> {
954    await pressed(host, key, work.busy, "busy");
955
956    try {
957        await work.store();
958    } catch (error) {
959        await logMod(host, "git", { event: "toggle-failed", key, error: String(error) });
960        await pressed(host, key, `Not saved: ${String(error)}`, "error");
961        return;
962    }
963
964    work.flip();
965    host.redraw();
966
967    try {
968        await refresh(host, "sidebar");
969        await pressed(host, key, work.done);
970    } catch (error) {
971        await logMod(host, "git", { event: "refresh-failed", key, error: String(error) });
972        await pressed(host, key, `Saved, but refresh failed: ${String(error)}`, "error");
973    }
974}
975
976/** The Git sidebar's actions for one checkout: Finder while its folder is there, and remove or hide. */
977function checkoutActions(host: Host, band: GitBand, at: string, name: string): Pressable[] {
978    const finder: Pressable = {
979        key: `${at}-finder`,
980        label: "Finder",
981        isDim: true,
982        hint: `Show ${band.root} in Finder.`,
983        onPress: () => void openAndSay(host, `${at}-finder`, band.root, `Opened ${band.root} in Finder.`),
984    };
985
986    if (band.why === "added") {
987        return [
988            finder,
989            {
990                key: `${at}-remove`,
991                label: "remove",
992                isDim: true,
993                hint: `Take ${name} out of this session's band. You added it by hand.`,
994                onPress: () =>
995                    void toggle(host, `${at}-remove`, {
996                        busy: `Removing ${name}…`,
997                        flip: () => {
998                            rows = rows.filter((row) => row.root !== band.root);
999                        },
1000                        store: () => removeAdded(host, band.root),
1001                        done: `Removed ${name} from this session's band.`,
1002                    }),
1003            },
1004        ];
1005    }
1006
1007    if (band.why === "worked") {
1008        return [
1009            ...(band.gone ? [] : [finder]),
1010            {
1011                key: `${at}-hide`,
1012                label: "hide",
1013                isDim: true,
1014                hint: `Hide ${name} until this session writes a file or changes git there again.`,
1015                onPress: () =>
1016                    void toggle(host, `${at}-hide`, {
1017                        busy: `Hiding ${name}…`,
1018                        flip: () => {
1019                            rows = rows.filter((row) => row.root !== band.root);
1020                        },
1021                        store: () => hideCheckout(host, band.root),
1022                        done: `Hid ${name}. It comes back when this session works there.`,
1023                    }),
1024            },
1025        ];
1026    }
1027
1028    return [finder];
1029}
1030
1031/** Flips one flag of one PR in the rows the band and the sidebar draw, before git is read again. */
1032function flipPr(root: string, n: number, flag: "tracked" | "hidden"): void {
1033    rows = rows.map((row) =>
1034        row.root === root
1035            ? { ...row, prs: row.prs.map((pr) => (pr.number === n ? { ...pr, [flag]: !pr[flag] } : pr)) }
1036            : row
1037    );
1038}
1039
1040/** The Git sidebar's actions for one PR: open, hub, copy link, track or untrack, and hide or show for the branch's own. */
1041function prActions(host: Host, band: GitBand, pr: BandPr, at: string, lists: Lists): Pressable[] {
1042    const n = pr.number;
1043    const name = lists.label;
1044    const link = `[#${n} (${pr.title})](${pr.url})`;
1045    const hub = hubUrlOf(lists.probe, pr);
1046    const store = (key: string, list: number[], on: boolean) => async () => {
1047        await host.storeSet(key, on ? [...new Set([...list, n])] : list.filter((held) => held !== n));
1048        await logMod(host, "git", {
1049            event: key.startsWith("git-band-hidden") ? "hide" : "track",
1050            on,
1051            n,
1052            root: band.root,
1053        });
1054    };
1055    const trackHint = pr.own
1056        ? pr.tracked
1057            ? `Stop keeping #${n} after a branch switch. It still shows while ${band.branch} is checked out.`
1058            : `Keep #${n} in ${name}'s row even after ${name} switches to another branch.`
1059        : `Take #${n} out of ${name}'s row.`;
1060    const actions: Pressable[] = [
1061        {
1062            key: `${at}-open`,
1063            label: "open",
1064            isPrimary: true,
1065            hint: `Open #${n} in the browser: ${pr.url}`,
1066            onPress: () => void openAndSay(host, `${at}-open`, pr.url, `Opened #${n} in the browser.`),
1067        },
1068        ...(hub
1069            ? [
1070                  {
1071                      key: `${at}-hub`,
1072                      label: "hub",
1073                      hint: `Open #${n} in the GenesisTools hub.`,
1074                      onPress: () => void openAndSay(host, `${at}-hub`, hub, `Opened #${n} in the hub.`),
1075                  },
1076              ]
1077            : []),
1078        {
1079            key: `${at}-copy`,
1080            label: "copy link",
1081            isDim: true,
1082            hint: `Copy ${link}`,
1083            onPress: () =>
1084                void host
1085                    .copy(link)
1086                    .then((result) =>
1087                        pressed(
1088                            host,
1089                            `${at}-copy`,
1090                            result.isCopied ? `Copied ${link}` : `Not copied: ${result.reason}.`,
1091                            result.isCopied ? "ok" : "error"
1092                        )
1093                    ),
1094        },
1095    ];
1096
1097    // An earlier branch's PR shows as long as the session remembers the branch; `hide` is what takes it out.
1098    if (!pr.from) {
1099        actions.push({
1100            key: `${at}-track`,
1101            label: pr.tracked ? "untrack" : "track",
1102            isDim: true,
1103            hint: trackHint,
1104            onPress: () =>
1105                void toggle(host, `${at}-track`, {
1106                    busy: pr.tracked ? `Untracking #${n}…` : `Tracking #${n}…`,
1107                    flip: () => flipPr(band.root, n, "tracked"),
1108                    store: store(storeKey(band.root), lists.tracked, !pr.tracked),
1109                    done: pr.tracked
1110                        ? pr.own
1111                            ? `#${n} is no longer kept after a branch switch.`
1112                            : `#${n} is out of ${name}'s row.`
1113                        : `#${n} stays in ${name}'s row after a branch switch.`,
1114                }),
1115        });
1116    }
1117
1118    if (pr.own || pr.from) {
1119        actions.push({
1120            key: `${at}-hide`,
1121            label: pr.hidden ? "show" : "hide",
1122            isDim: true,
1123            hint: pr.hidden
1124                ? `Put #${n} back in the band.`
1125                : `Take #${n} out of the band. It stays listed here, with show.`,
1126            onPress: () =>
1127                void toggle(host, `${at}-hide`, {
1128                    busy: pr.hidden ? `Showing #${n}…` : `Hiding #${n}…`,
1129                    flip: () => flipPr(band.root, n, "hidden"),
1130                    store: store(hiddenKey(band.root), lists.hidden, !pr.hidden),
1131                    done: pr.hidden ? `#${n} is back in the band.` : `#${n} is out of the band.`,
1132                }),
1133        });
1134    }
1135
1136    return actions;
1137}
1138
1139/** Which checkouts the person opened or folded in the sidebar; the rest follow `mods.git.expand`. */
1140const opened = new Set<string>();
1141const folded = new Set<string>();
1142
1143function isOpen(root: string, index: number, expand: "first" | "all" | "none"): boolean {
1144    if (opened.has(root)) {
1145        return true;
1146    }
1147
1148    if (folded.has(root)) {
1149        return false;
1150    }
1151
1152    return expand === "all" || (expand === "first" && index === 0);
1153}
1154
1155/** What one checkout's sidebar lines read: the link probe, its PR lists, its name and the home for `~`. */
1156type Lists = { probe: GenesisProbe; tracked: number[]; hidden: number[]; label: string; home?: string };
1157
1158/** A checkout's state in signs for its folded line: `↑24 ●16`, `clean`. */
1159function stateSigns(band: GitBand): { text: string; color?: string }[] {
1160    const parts: { text: string; color?: string }[] = [];
1161
1162    if (band.ahead) {
1163        parts.push({ text: `↑${band.ahead}`, color: "yellow" });
1164    }
1165
1166    if (band.behind) {
1167        parts.push({ text: `↓${band.behind}`, color: "yellow" });
1168    }
1169
1170    if (band.dirty) {
1171        parts.push({ text: `●${band.dirty}`, color: "magenta" });
1172    }
1173
1174    if (band.gone) {
1175        parts.push({ text: "removed" });
1176    } else if (parts.length === 0) {
1177        parts.push({ text: "clean" });
1178    }
1179
1180    for (const pr of band.prs.filter((held) => !held.hidden)) {
1181        parts.push({ text: `#${pr.number} ${ciText(pr)}`, color: prColor(pr) });
1182    }
1183
1184    return parts;
1185}
1186
1187/**
1188 * A checkout's first line, one Button the width of the pane: the fold mark, its name and branch at the left, and at
1189 * the right why it is here (open) or its state in signs (folded). The branch gives way first when the line is full.
1190 */
1191function checkoutLine(
1192    host: Host,
1193    kit: Kit,
1194    band: GitBand,
1195    at: string,
1196    isOpenNow: boolean,
1197    name: string
1198): RenderElement {
1199    const { Button, Text } = kit.ui;
1200    const right = isOpenNow ? [{ text: whyText(band) }] : stateSigns(band);
hooks/hook-ledger.tsx 409 lines
1// The hook ledger. Every classic (settings and plugin) hook's answer passes through `classic.*`, so this counts what
2// the hooks hand the model: `additionalContext` entries and `block` reasons (a Stop nudge is one), per event, for the
3// main loop and for subagents. `mods.hooks.tokens` shows the total as a band chip (`hooks ~38.2k · 41`; tokens are
4// characters / 4, an estimate), and a press on it opens the Hooks sidebar: the count per event, the latest injections
5// with their text, and the dedupe switch for this session. The status line cannot take a click, so the count left it
6// on 2026-10-23. The count is per session and survives a reload of the plugin. `mods.hooks.dedupe` drops a repeat: an
7// entry, or an area-rule block inside one, already handed to the same loop since its last compaction becomes a
8// one-line receipt. Each injection and each drop goes to `mods-log/hooks.jsonl`.
9import type { ElementTable, RenderElement } from "claude-code";
10import type { HookCount, HookLedger } from "../types";
11import { liveSince } from "./live";
12import { agentsDir, type Feature, type Host, isRecord, logMod, settingsOf } from "./shared";
13import { barText, dimNote, divider, headerRow, type Kit, listRow, plural, sectionTitle, sidebarFrame } from "./sidebar";
14
15export const HOOKS_PANE = "mods-hooks";
16
17const EMPTY: HookLedger = {
18    main: { fires: 0, chars: 0 },
19    agents: { fires: 0, chars: 0 },
20    byEvent: {},
21    dropped: { fires: 0, chars: 0 },
22};
23
24/** Counted for this session. Kept in the plugin store under `hook-ledger:<session id>`, so a reload keeps the count. */
25let ledger: HookLedger = EMPTY;
26let storeKey: string | undefined;
27
28function countOf(value: unknown): HookCount | undefined {
29    return isRecord(value) && typeof value.fires === "number" && typeof value.chars === "number"
30        ? { fires: value.fires, chars: value.chars }
31        : undefined;
32}
33
34/** A ledger read back from the store, or undefined when it is not one. */
35export function ledgerOf(value: unknown): HookLedger | undefined {
36    if (!isRecord(value) || !isRecord(value.byEvent)) {
37        return undefined;
38    }
39
40    const main = countOf(value.main);
41    const agents = countOf(value.agents);
42    const dropped = countOf(value.dropped);
43    const byEvent = Object.fromEntries(
44        Object.entries(value.byEvent).flatMap(([event, count]) => {
45            const held = countOf(count);
46
47            return held ? [[event, held]] : [];
48        })
49    );
50
51    return main && agents && dropped ? { main, agents, dropped, byEvent } : undefined;
52}
53
54function save(host: Host): void {
55    if (storeKey) {
56        void host.storeSet(storeKey, ledger).catch(() => undefined);
57    }
58}
59
60/** The latest injections, newest last, for the sidebar: the text cut at `ENTRY_TEXT` characters. */
61interface Injection {
62    at: number;
63    event: string;
64    agent: string;
65    tool?: string;
66    chars: number;
67    text: string;
68    dropped: string[];
69}
70
71let recent: Injection[] = [];
72const RECENT = 40;
73const ENTRY_TEXT = 4_000;
74/** The sidebar's dedupe switch for this session; null follows `mods.hooks.dedupe`. */
75let dedupeHere: boolean | null = null;
76/** The injection the sidebar shows in full, by its `at`. */
77let opened: number | undefined;
78
79/** Shorter entries are never dropped: a receipt would save nothing. */
80const MIN_DEDUPE_CHARS = 200;
81const AREA_BLOCK = /(?=Contents of \S+ \(area rules\):)/;
82
83/** What each loop has been handed since its last compaction: a key per whole entry and per area-rule block. */
84const seen = new Map<string, Map<string, number>>();
85
86function keyOf(text: string): string {
87    let hash = 5381;
88
89    for (let i = 0; i < text.length; i++) {
90        hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
91    }
92
93    return `${text.length}:${hash >>> 0}`;
94}
95
96export function tokensText(chars: number): string {
97    const tokens = chars / 4;
98
99    return tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${Math.round(tokens)}`;
100}
101
102function receipt(what: string, chars: number, at: number): string {
103    return `[genesis-tools-mods: ${what} repeated, ${chars} characters already in this context since ${new Date(at).toLocaleTimeString()}; dropped]`;
104}
105
106/**
107 * One entry with its repeats taken out: a whole repeat becomes a receipt; an entry of area-rule blocks keeps its
108 * new blocks and names the dropped ones. `held` remembers what the loop has now.
109 */
110export function dedupeEntry(
111    text: string,
112    held: Map<string, number>,
113    now: number
114): { text: string; droppedChars: number; dropped: string[] } {
115    const whole = keyOf(text);
116    const before = held.get(whole);
117
118    if (before !== undefined && text.length >= MIN_DEDUPE_CHARS) {
119        const head = text.split("\n")[0]?.slice(0, 80) ?? "hook context";
120        const note = receipt(`"${head}"`, text.length, before);
121
122        return { text: note, droppedChars: text.length - note.length, dropped: [head] };
123    }
124
125    held.set(whole, now);
126    const [preface = "", ...blocks] = text.split(AREA_BLOCK);
127
128    if (blocks.length === 0) {
129        return { text, droppedChars: 0, dropped: [] };
130    }
131
132    const kept: string[] = [];
133    const dropped: string[] = [];
134    let droppedChars = 0;
135
136    for (const block of blocks) {
137        const key = keyOf(block.trim());
138        const at = held.get(key);
139        const path = /^Contents of (\S+)/.exec(block)?.[1] ?? "area rules";
140
141        if (at === undefined) {
142            held.set(key, now);
143            kept.push(block);
144            continue;
145        }
146
147        const note = receipt(`area rules ${path}`, block.length, at);
148        dropped.push(path);
149        droppedChars += block.length - note.length;
150        kept.push(`${note}\n\n`);
151    }
152
153    return { text: dropped.length ? `${preface}${kept.join("")}`.trimEnd() : text, droppedChars, dropped };
154}
155
156function add(count: HookCount, chars: number): HookCount {
157    return { fires: count.fires + 1, chars: count.chars + chars };
158}
159
160/**
161 * Counts what one classic hook event handed the model and takes out the repeats. Answers the `additionalContext` to
162 * pass on in place of the hooks' own, or undefined to pass their answer on unchanged.
163 */
164export async function ledgerClassic(host: Host, input: unknown, answer: unknown): Promise<string[] | undefined> {
165    {
166        if (!isRecord(input) || !isRecord(answer)) {
167            return undefined;
168        }
169
170        const event = typeof input.hook_event_name === "string" ? input.hook_event_name : "?";
171        const agent = typeof input.agent_id === "string" ? input.agent_id : "main";
172
173        if (event === "SessionStart" && (input.source === "compact" || input.source === "clear")) {
174            seen.delete(agent);
175        }
176
177        const entries = Array.isArray(answer.additionalContext)
178            ? answer.additionalContext.filter((entry): entry is string => typeof entry === "string")
179            : [];
180        const block = typeof answer.block === "string" ? answer.block : "";
181
182        if (entries.length === 0 && !block) {
183            return undefined;
184        }
185
186        const settings = (await settingsOf(host)).hooks;
187        const dedupe = dedupeHere ?? settings.dedupe;
188        const held = seen.get(agent) ?? new Map<string, number>();
189        seen.set(agent, held);
190        const now = Date.now();
191        let droppedChars = 0;
192        const dropped: string[] = [];
193        const kept = entries.map((entry) => {
194            if (!dedupe) {
195                return entry;
196            }
197
198            const out = dedupeEntry(entry, held, now);
199            droppedChars += out.droppedChars;
200            dropped.push(...out.dropped);
201
202            return out.text;
203        });
204        const chars = kept.reduce((sum, entry) => sum + entry.length, 0) + block.length;
205
206        const before = ledger;
207        ledger = {
208            main: agent === "main" ? add(before.main, chars) : before.main,
209            agents: agent === "main" ? before.agents : add(before.agents, chars),
210            byEvent: { ...before.byEvent, [event]: add(before.byEvent[event] ?? { fires: 0, chars: 0 }, chars) },
211            dropped: dropped.length ? add(before.dropped, droppedChars) : before.dropped,
212        };
213        await logMod(host, "hooks", {
214            event,
215            agent,
216            tool: typeof input.tool_name === "string" ? input.tool_name : undefined,
217            chars,
218            heads: kept.map((entry) => entry.split("\n")[0]?.slice(0, 100)),
219            block: block ? block.slice(0, 100) : undefined,
220            dropped: dropped.length ? dropped : undefined,
221            droppedChars: droppedChars || undefined,
222        });
223        save(host);
224        recent = [
225            ...recent,
226            {
227                at: now,
228                event,
229                agent,
230                tool: typeof input.tool_name === "string" ? input.tool_name : undefined,
231                chars,
232                text: [...kept, block].filter(Boolean).join("\n\n").slice(0, ENTRY_TEXT),
233                dropped,
234            },
235        ].slice(-RECENT);
236        host.redraw();
237
238        return dropped.length ? kept : undefined;
239    }
240}
241
242export const hookLedger: Feature = {
243    async start(host) {
244        // Sessions that ran 1.7.0 pinned the count as a status line; the chip replaces it.
245        host.status(undefined);
246        const key = `hook-ledger:${await host.sessionId()}`;
247
248        if (key !== storeKey) {
249            storeKey = key;
250            ledger = ledgerOf(await host.storeGet(key).catch(() => undefined)) ?? EMPTY;
251            host.redraw();
252        }
253    },
254    compacted(_host, agentId) {
255        seen.delete(agentId ?? "main");
256    },
257    sidebars: [{ id: HOOKS_PANE, title: "Hooks", draw: hooksPane }],
258    commands: [
259        {
260            name: "mod-hooks",
261            description: "The Hooks sidebar: what the hooks handed the model, per event, with the latest injections",
262            async run(host) {
263                await host.togglePane(HOOKS_PANE, "Hooks", "command");
264
265                return {};
266            },
267        },
268    ],
269};
270
271/** The band chip's two texts: `hooks ~38.2k · 41`, and what the dedupe dropped. */
272function chipTexts(): { total: string; dropped: string } {
273    const held = ledger;
274
275    return {
276        total: `hooks ${tokensText(held.main.chars + held.agents.chars)} · ${held.main.fires + held.agents.fires}`,
277        dropped: held.dropped.fires ? ` −${tokensText(held.dropped.chars)}` : "",
278    };
279}
280
281/** The columns the band chip's text takes. */
282export function hooksChipWidth(): number {
283    const { total, dropped } = chipTexts();
284
285    return [...total, ...dropped].length;
286}
287
288/** The band chip, `hooks ~38.2k · 41`, or null while `mods.hooks.tokens` is off. */
289export async function hooksChip(host: Host, ui: ElementTable): Promise<RenderElement | null> {
290    if (!(await settingsOf(host)).hooks.tokens) {
291        return null;
292    }
293
294    const { Button, Text } = ui;
295    const { total, dropped } = chipTexts();
296
297    return (
298        <Button key="hooks-chip" plain onPress={() => void host.togglePane(HOOKS_PANE, "Hooks")}>
299            <Text dimColor>{total}</Text>
300            {dropped ? <Text color="green">{dropped}</Text> : null}
301        </Button>
302    );
303}
304
305function ago(at: number, now: number): string {
306    return liveSince(at, now);
307}
308
309/** The Hooks sidebar: the totals, the count per event as bars, and the latest injections, each opening its text. */
310async function hooksPane(host: Host, kit: Kit): Promise<RenderElement> {
311    const { Box, Text } = kit.ui;
312    const held = ledger;
313    const now = Date.now();
314    const settings = (await settingsOf(host)).hooks;
315    const dedupe = dedupeHere ?? settings.dedupe;
316    const total = held.main.chars + held.agents.chars;
317    const events = Object.entries(held.byEvent).sort((a, b) => b[1].chars - a[1].chars);
318    const most = events[0]?.[1].chars ?? 1;
319    const log = `${(await agentsDir(host)) ?? "~/.genesis-tools/agents"}/mods-log/hooks.jsonl`;
320    const shown = recent.find((entry) => entry.at === opened);
321    const reset = () => {
322        ledger = EMPTY;
323        save(host);
324        recent = [];
325        host.redraw();
326    };
327
328    return sidebarFrame(
329        kit,
330        <Box flexDirection="column">
331            {headerRow(
332                kit,
333                <Text wrap="truncate-end">
334                    <Text bold>{`${tokensText(total)} tokens`}</Text>
335                    <Text dimColor>{` from hooks · ${plural(held.main.fires + held.agents.fires, "fire")}`}</Text>
336                </Text>,
337                [
338                    {
339                        key: "hooks-dedupe",
340                        label: `dedupe: ${dedupe ? "on" : "off"}`,
341                        onPress: () => {
342                            dedupeHere = !dedupe;
343                            void logMod(host, "hooks", { event: "dedupe-switch", on: dedupeHere });
344                            host.redraw();
345                        },
346                    },
347                    {
348                        key: "hooks-log",
349                        label: "show log",
350                        isDim: true,
351                        // Finder straight away: a reveal needs no link router.
352                        onPress: () => void host.run(["/usr/bin/open", "-R", log], { timeoutMs: 10_000 }),
353                    },
354                    { key: "hooks-reset", label: "reset", isDim: true, onPress: reset },
355                ]
356            )}
357            {dimNote(
358                kit,
359                `main ${tokensText(held.main.chars)} · ${held.main.fires}   agents ${tokensText(held.agents.chars)} · ${held.agents.fires}   deduped ${tokensText(held.dropped.chars)} · ${held.dropped.fires}`
360            )}
361            {dimNote(kit, "Counted for this session; tokens are characters / 4.")}
362            <Box flexDirection="column" marginTop={1}>
363                {sectionTitle(kit, "By event")}
364                {events.length === 0 ? dimNote(kit, "No hook added context yet.") : null}
365                {events.map(([event, count]) => (
366                    <Box key={`event-${event}`} flexDirection="row" gap={1}>
367                        <Text wrap="truncate-end">{event}</Text>
368                        <Box flexGrow={1} />
369                        <Text dimColor>{`${count.fires}×`}</Text>
370                        <Text color="cyan">{barText(count.chars / most, 10)}</Text>
371                        <Text>{tokensText(count.chars).padStart(6)}</Text>
372                    </Box>
373                ))}
374            </Box>
375            <Box flexDirection="column" marginTop={1}>
376                {sectionTitle(kit, "Latest", "(press one to read it)")}
377                {[...recent]
378                    .reverse()
379                    .slice(0, 15)
380                    .map((entry) =>
381                        listRow(
382                            kit,
383                            {
384                                key: `entry-${entry.at}`,
385                                label: `${entry.at === opened ? "▾" : "▸"} ${entry.event}${entry.tool ? ` ${entry.tool}` : ""}${entry.agent === "main" ? "" : " (agent)"}`,
386                                isDim: entry.at !== opened,
387                                onPress: () => {
388                                    opened = entry.at === opened ? undefined : entry.at;
389                                    host.redraw();
390                                },
391                            },
392                            <Text
393                                dimColor
394                            >{`${entry.dropped.length ? "deduped · " : ""}${tokensText(entry.chars)} · ${ago(entry.at, now)}`}</Text>
395                        )
396                    )}
397            </Box>
398            {shown ? (
399                <Box flexDirection="column" marginTop={1}>
400                    {divider(kit)}
401                    <Text dimColor wrap="wrap">
402                        {shown.text.split("\n").slice(0, 30).join("\n")}
403                    </Text>
404                </Box>
405            ) : null}
406        </Box>
407    );
408}
409
hooks/idle.ts 119 lines
1import { modsSettings } from "./settings";
2
3/** The idle-compaction mod's settings, from the `mods` section of the hooks file. */
4export interface IdleCompactSettings {
5    enabled: boolean;
6    /** Minutes after the last main-loop turn. Under 60, so the compaction still reads the 1-hour cache. */
7    minutes: number;
8    /** Below this many context tokens a compaction saves too little to pay for itself. */
9    minTokens: number;
10}
11
12/**
13 * 56 minutes: Claude Code's own idle compaction fires at 54 (0.9 of the hour) and goes first; the cache
14 * written by the last request expires at 60. 200,000 tokens is the built-in's floor
15 * (`CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS` on this machine).
16 */
17export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minutes: 56, minTokens: 200_000 };
18
19let dueAt: number | undefined;
20
21/** When the armed idle compaction fires (ms since the epoch), or `undefined` while none will: the cache band shows it. */
22export function idleDueAt(): number | undefined {
23    return dueAt;
24}
25
26export function setIdleDueAt(at: number | undefined): void {
27    dueAt = at;
28}
29
30/** An older usage lookup must not publish a countdown after its timer was cancelled or replaced. */
31export async function publishIdleDue({
32    current,
33    tokens,
34    minTokens,
35    at,
36}: {
37    current: () => boolean;
38    tokens: Promise<number | undefined>;
39    minTokens: number;
40    at: number;
41}): Promise<void> {
42    const count = await tokens;
43    if (current()) {
44        setIdleDueAt(count === undefined || count >= minTokens ? at : undefined);
45    }
46}
47
48/** The start of the notice the mod appends. GenesisClaude's session-analyzer `idle-compact.ts scan` counts it. */
49export const IDLE_NOTICE = "Compacted while idle";
50
51/**
52 * `mods.idleCompact` of the hooks file: `{ enabled, minutes, minTokens }`, or the flat `mods.idleCompact`,
53 * `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` of before 2026-10-23
54 * (`tools agents hooks config set mods.idleCompact.enabled false --write`). A missing or broken file, and a value
55 * of the wrong kind, leave that value's default.
56 */
57export function idleCompactSettings(hooksFile: string | undefined): IdleCompactSettings {
58    return modsSettings(hooksFile).idleCompact;
59}
60
61/**
62 * Whether a compaction leaves nothing for the timer to save: a main-loop one that ran. A `precompute`
63 * installs nothing (the engine keeps its summary for a later threshold compaction), and it resolves after
64 * the turn that started it, so cancelling on it would drop the timer of exactly the largest sessions.
65 */
66export function compactionEndsIdleWait({
67    agentId,
68    trigger,
69    skipped,
70}: {
71    agentId: string | undefined;
72    trigger: string;
73    skipped: boolean;
74}): boolean {
75    return agentId === undefined && trigger !== "precompute" && !skipped;
76}
77
78/** Why the timer did not compact, or `undefined` to compact. */
79export function idleCompactSkip({
80    settings,
81    surfaces,
82    tokens,
83}: {
84    settings: IdleCompactSettings;
85    surfaces: number;
86    tokens: number | undefined;
87}): string | undefined {
88    if (!settings.enabled) {
89        return "mods.idleCompact.enabled is off";
90    }
91
92    if (surfaces === 0) {
93        return "no surface draws the session (claude -p)";
94    }
95
96    if (tokens === undefined || tokens < settings.minTokens) {
97        return `context ${tokens ?? "unknown"} tokens, under the floor of ${settings.minTokens}`;
98    }
99
100    return undefined;
101}
102
103export function idleNotice({
104    minutes,
105    tokensBefore,
106    tokensAfter,
107}: {
108    minutes: number;
109    tokensBefore: number | undefined;
110    tokensAfter: number | undefined;
111}): string {
112    const sizes =
113        tokensBefore !== undefined && tokensAfter !== undefined
114            ? ` (${tokensBefore.toLocaleString("en-US")} to ${tokensAfter.toLocaleString("en-US")} tokens)`
115            : "";
116
117    return `${IDLE_NOTICE}, before the prompt cache expired: genesis-tools-mods after ${minutes} min${sizes}`;
118}
119
hooks/inbox.tsx 271 lines
1// The Inbox sidebar (`/mod-inbox`, or the `inbox` chip in the band): what this session put in the GenesisTools
2// inbox through the answer MCP, as a summary. The forms it waits on, the messages it sent (`inbox_send`), the
3// questions and answers it recorded (`question_answer`) and its todos; an entry opens to its text, [jump to] (the
4// tool call that sent it) and its images. `new` is an entry nobody read in the widget yet.
5import type { ElementTable, RenderElement } from "claude-code";
6import {
7    afterQuestionTool,
8    answersOf,
9    jumpRowOf,
10    jumpTo,
11    type LoggedEntry,
12    refreshAnswers,
13    startAnswers,
14} from "./answers";
15import { liveAgo } from "./live";
16import { type Feature, type Host, logMod, settingsOf } from "./shared";
17import {
18    actionRow,
19    dimNote,
20    headerRow,
21    type Kit,
22    listRow,
23    type Pressable,
24    plural,
25    sectionTitle,
26    sidebarFrame,
27    wrapWords,
28} from "./sidebar";
29
30export const INBOX_PANE = "mods-inbox";
31/** Entries opened or closed against `mods.inbox.expand`. */
32const flipped = new Set<string>();
33const DONE_TODOS = new Set(["implemented", "dismissed", "superseded"]);
34
35export const inbox: Feature = {
36    async start(host) {
37        await startAnswers(host);
38    },
39    async afterTool(host, e) {
40        if ((await settingsOf(host)).inbox.enabled) {
41            afterQuestionTool(host, e);
42        }
43    },
44    sidebars: [{ id: INBOX_PANE, title: "Inbox", draw: inboxPane }],
45    commands: [
46        {
47            name: "mod-inbox",
48            description:
49                "The Inbox sidebar: the forms, messages, answers and todos this session put in the GenesisTools inbox",
50            async run(host) {
51                if (!(await settingsOf(host)).inbox.enabled) {
52                    return {
53                        text: "The inbox mod is off: tools agents hooks config set mods.inbox.enabled true --write",
54                    };
55                }
56
57                void startAnswers(host);
58                await host.togglePane(INBOX_PANE, "Inbox", "command");
59                await refreshAnswers(host, "command", true);
60
61                return {};
62            },
63        },
64    ],
65};
66
67/** How many items the chip and the header count: waiting forms, entries and open todos. */
68function counts(): { items: number; unread: number } {
69    const read = answersOf();
70
71    if (!read) {
72        return { items: 0, unread: 0 };
73    }
74
75    const todos = read.todos.filter((todo) => !DONE_TODOS.has(todo.state)).length;
76
77    return {
78        items: read.forms.length + read.entries.length + todos,
79        unread: read.forms.length + read.entries.filter((entry) => entry.isUnread).length,
80    };
81}
82
83/** The band chip, `inbox 2 new` or `inbox 5`, with the columns it takes; null when it has nothing to say. */
84export async function inboxChip(
85    host: Host,
86    ui: ElementTable
87): Promise<{ element: RenderElement; width: number } | null> {
88    const settings = (await settingsOf(host)).inbox;
89    const { items, unread } = counts();
90
91    if (!settings.enabled || settings.chip === "off" || items === 0 || (settings.chip === "unread" && unread === 0)) {
92        return null;
93    }
94
95    const label = unread > 0 ? `inbox ${unread} new` : `inbox ${items}`;
96    const { Button, Text } = ui;
97
98    return {
99        element: (
100            <Button key="inbox-chip" plain onPress={() => void host.togglePane(INBOX_PANE, "Inbox")}>
101                <Text color={unread > 0 ? "cyan" : undefined} dimColor={unread === 0}>
102                    {label}
103                </Text>
104            </Button>
105        ),
106        width: [...label].length,
107    };
108}
109
110/** The first line of a text, for a row's label. */
111function firstLine(text: string): string {
112    return (
113        text
114            .split("\n")
115            .find((line) => line.trim())
116            ?.trim() ?? ""
117    );
118}
119
120/** The Inbox sidebar: a summary line, then forms, messages, answers and todos; an entry opens to its text. */
121async function inboxPane(host: Host, kit: Kit): Promise<RenderElement> {
122    const { Box, Text } = kit.ui;
123    const settings = (await settingsOf(host)).inbox;
124    const read = answersOf();
125    const { items, unread } = counts();
126    const width = Math.max(10, kit.columns - 4);
127    const messages = read?.entries.filter((entry) => entry.tag === "message") ?? [];
128    const answers = read?.entries.filter((entry) => entry.tag !== "message") ?? [];
129    const todos = [...(read?.todos ?? [])].sort(
130        (a, b) => Number(DONE_TODOS.has(a.state)) - Number(DONE_TODOS.has(b.state)) || a.n - b.n
131    );
132    const isOpen = (entry: LoggedEntry) => {
133        const byDefault = settings.expand === "all" || (settings.expand === "unread" && entry.isUnread);
134
135        return flipped.has(entry.id) ? !byDefault : byDefault;
136    };
137
138    const entryRow = (entry: LoggedEntry): RenderElement => {
139        const open = isOpen(entry);
140        const actions: Pressable[] = [];
141
142        if (jumpRowOf({ entry: entry.id })) {
143            actions.push({
144                key: `${entry.id}-jump`,
145                label: "jump to",
146                hint: "Scrolls the transcript to the tool call that sent this.",
147                onPress: () => void jumpTo(host, `${entry.id}-jump`, { entry: entry.id }),
148            });
149        }
150
151        if (entry.images.length > 0) {
152            actions.push({
153                key: `${entry.id}-images`,
154                label: plural(entry.images.length, "image"),
155                isDim: true,
156                hint: "Opens the attached images in Preview.",
157                onPress: () => void host.run(["/usr/bin/open", ...entry.images], { timeoutMs: 10_000 }),
158            });
159        }
160
161        return (
162            <Box key={`entry-${entry.id}`} flexDirection="column">
163                {listRow(
164                    kit,
165                    {
166                        key: `${entry.id}-row`,
167                        label: `${open ? "▾" : "▸"} ${firstLine(entry.title) || "(no title)"}`,
168                        isDim: !entry.isUnread,
169                        hint: open ? "Folds this entry." : "Opens this entry: its text and [jump to].",
170                        onPress: () => {
171                            if (flipped.has(entry.id)) {
172                                flipped.delete(entry.id);
173                            } else {
174                                flipped.add(entry.id);
175                            }
176
177                            host.redraw();
178                            void logMod(host, "inbox", { event: open ? "fold" : "open", id: entry.id });
179                        },
180                    },
181                    <Text color={entry.isUnread ? "cyan" : undefined} dimColor={!entry.isUnread}>
182                        {`${entry.isUnread ? "new · " : ""}${entry.tag === "message" ? "" : `${entry.tag} · `}${liveAgo(entry.at, { unit: "minute" })}`}
183                    </Text>
184                )}
185                {open ? (
186                    <Box flexDirection="column" marginLeft={2} marginBottom={1}>
187                        {wrapWords(
188                            entry.text.trim() === entry.title.trim() ? "" : entry.text.replace(/\n+/g, " "),
189                            width,
190                            12
191                        ).map((line, index) => (
192                            <Text key={`${entry.id}-t${index}`} wrap="truncate-end">
193                                {line}
194                            </Text>
195                        ))}
196                        {actions.length > 0 ? actionRow(kit, actions) : null}
197                    </Box>
198                ) : null}
199            </Box>
200        );
201    };
202
203    return sidebarFrame(
204        kit,
205        <Box flexDirection="column">
206            {headerRow(
207                kit,
208                <Text wrap="truncate-end">
209                    <Text bold>{plural(items, "item")}</Text>
210                    <Text dimColor>{unread > 0 ? ` · ${unread} new` : ""}</Text>
211                </Text>,
212                [
213                    {
214                        key: "inbox-refresh",
215                        label: "refresh",
216                        isDim: true,
217                        hint: "Reads the inbox again.",
218                        onPress: () => void refreshAnswers(host, "button", true),
219                    },
220                ]
221            )}
222            {dimNote(
223                kit,
224                read
225                    ? `What this session sent to the GenesisTools inbox, read ${liveAgo(read.at, { unit: "minute" })}.`
226                    : "Not read yet: the inbox needs the tools CLI."
227            )}
228            {read && items === 0 ? dimNote(kit, "Nothing from this session in the inbox yet.") : null}
229            {read && read.forms.length > 0 ? (
230                <Box flexDirection="column" marginTop={1}>
231                    {sectionTitle(kit, "Forms waiting for you", `(${read.forms.length})`)}
232                    {read.forms.map((form) => (
233                        <Box key={`form-${form.id}`} flexDirection="row">
234                            <Text wrap="truncate-end">{firstLine(form.prompts[0] ?? "") || form.id}</Text>
235                            <Box flexGrow={1} />
236                            <Text color="yellow">{`waiting · ${liveAgo(form.at, { unit: "minute" })}`}</Text>
237                        </Box>
238                    ))}
239                    {dimNote(kit, "Answer a form in the GenesisTools widget.")}
240                </Box>
241            ) : null}
242            {messages.length > 0 ? (
243                <Box flexDirection="column" marginTop={1}>
244                    {sectionTitle(kit, "Messages", `(${messages.length})`)}
245                    {messages.map(entryRow)}
246                </Box>
247            ) : null}
248            {answers.length > 0 ? (
249                <Box flexDirection="column" marginTop={1}>
250                    {sectionTitle(kit, "Questions and answers", `(${answers.length})`)}
251                    {answers.map(entryRow)}
252                </Box>
253            ) : null}
254            {todos.length > 0 ? (
255                <Box flexDirection="column" marginTop={1}>
256                    {sectionTitle(kit, "Todos", `(${todos.length})`)}
257                    {todos.map((todo) => (
258                        <Box key={`todo-${todo.n}`} flexDirection="row">
259                            <Text dimColor={DONE_TODOS.has(todo.state)} wrap="truncate-end">
260                                {`TODO ${todo.n} ${todo.title}`}
261                            </Text>
262                            <Box flexGrow={1} />
263                            <Text dimColor>{todo.state}</Text>
264                        </Box>
265                    ))}
266                </Box>
267            ) : null}
268        </Box>
269    );
270}
271