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…

<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>
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.
📌 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
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
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).
tools update # git pull, bun install with a clean retry, optional plugin refresh
Discovery. tools scans src/ and treats as a tool:
index.ts or index.tsx (tool name is the directory name),.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.
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.
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
| Command | Argument hint | What 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.
| Skill | What it does |
|---|---|
agents-talk | Cross-agent messaging protocol via tools agents. Invoke before spawning subagents that must talk to each other. |
analyze-har | Token-efficient HAR analysis. The rule it enforces: never cat or jq a HAR file. |
artifact | Create, serve, and build local dashboards and single-file HTML artifacts via tools artifact, with a 40+ component kit (React, Tailwind, charts, diffs). |
azure-devops | Work items, queries, sprints, dashboards. Defers time logging to /gt:timelog. |
browser-router | Route non-web links so a click on this Mac runs a saved command instead of opening a page. |
chrome-devtools | Drive a real running browser over the Chrome DevTools Protocol to debug auth loops, cookie/session issues, and captured HAR files. |
claude-history | Find native Claude, Codex or Grok conversations by topic, file or date. |
debugging-master | Hypothesis-driven runtime debugging with temporary, auto-cleanable instrumentation (Node/TS, PHP, browser). |
fable-replace | Verified, transactional find/replace for code and docs, instead of sed/perl/repeated edits, for a change spanning more than one file or spot. |
git | Branch 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. |
github | Read or search GitHub, and analyze GitHub Actions runs, failures, and billing. |
handoff-to | Offload 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-md | Empirically evaluate and trim CLAUDE.md / AGENTS.md by testing which rules a clean model already follows. |
jev-grep | Find the source code for a behavior when you don't know the symbol or file, via tools jev grep. |
json2md | Generate markdown efficiently from JSON data. |
living-docs | Self-maintaining docs system: bootstrap, validate, refine minimal doc chunks. |
macos-control | Drive native macOS apps through the Accessibility API, and record short screen captures reviewed frame by frame. |
mcp-scripting | Call MCP tools from a plain TypeScript script via tools scripts, with types generated from each server's live tools/list. |
plan-it | Write implementation plans hardened with an executor-proof contract, so a weaker model can execute them. |
question | Answer a question, then preserve the question and answer for later review. |
react-compiler-debug | Inspect babel-plugin-react-compiler output and explain memoization decisions. |
research | Answer questions that need information from outside the local codebase. |
review-proposal | Review a GitHub PR or GitLab MR and push the result into the GenesisTools.app review window as a proposal. |
stash | Save, apply, and unapply named code overlays across projects with tools stash. |
summarize | Summarize a Claude Code session into learnings, a postmortem, a changelog, or onboarding docs. |
task | Run long-lived interactive commands (dev servers, Metro, Vite) with PTY capture and an agent-friendly log tail. |
tdd | Test-first workflow for implementing any feature or bugfix, with mechanical RED/GREEN evidence gates. |
timely | Turn a day of Timely auto-tracked memories into time-log entries via a plan/apply workflow. |
todo | Project-scoped task tracking through tools todo, including timed todos synced to Apple Calendar. |
typescript-error-fixer | Fix TypeScript compile errors and eliminate any, one agent per file. |
wrap-up | Write the state doc that lets a fresh agent resume cold: an Obsidian wrap-up or a repo handoff. |
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:
| Event | Script | Purpose | |||
|---|---|---|---|---|---|
SessionStart | track-session-files.ts | Start the per-session record of modified files. | |||
SessionStart | agents-talk-hint.ts | Surface the agents-talk protocol hint at session start. | |||
SessionStart | record-session-account.ts | Record which Claude account the session is billing. | |||
SessionStart | record-session-cmux.ts | Record the cmux pane/surface the session is running in. | |||
UserPromptSubmit | record-session-cmux.ts | Keep the cmux pane record current as the session continues. | |||
PostToolUse (`Edit\ | Write\ | MultiEdit\ | NotebookEdit`) | track-session-files.ts | Append 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.
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:
| Capability | Tools | Purpose |
|---|---|---|
question_answer | question_answer | Capture a question and your complete answer to the local question store, reviewable later with tools question log / tools question tail. |
question_ask | question_post, question_wait, question_poll, question_respond, question_cancel, question_update, question_tokens | Ask 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. |
handoff | handoff_post, handoff_get, handoff_list, handoff_action | Cross-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. |
annotate | annotate_image | Annotate an image (arrows, boxes, labels) for review. |
jev | jev_route, jev_compact, jev_verify, jev_verify_templates | Read-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.
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.
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.
| Tool | What it does | Key subcommands |
|---|---|---|
ai | Unified AI toolkit: translate, summarize, classify, generate images, manage accounts and models. | translate summarize image classify models config |
ai-proxy | OpenAI-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-spend | Token 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 |
ask | Multi-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) |
say | Text to speech with pluggable backends (macOS, xAI Grok, OpenAI) and per-app config profiles. | voices models config |
transcribe | Transcribe audio files with AI, locally or in the cloud. | flags only |
darwinkit | Apple on-device ML from the terminal: NLP, embeddings, OCR, clustering, biometry, iCloud. | 40+ verbs, see --help |
json | Convert 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-reader | Fetch 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) |
indexer | Semantic code indexer with AST-aware chunking and hybrid search. | add search sync watch graph context mcp-serve |
| Tool | What it does | Key subcommands |
|---|---|---|
claude | Everything 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 |
cc | Resume a Claude Code session by short ID, name, or content search. | (single command) |
codex | Run 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 |
grok | Run isolated Grok workers, resume native sessions, and search indexed conversation history. | login run resume history usage steer read tail sessions |
cursor | Ask Cursor Agent a question about the codebase and stream the answer, tool calls on stderr and answer on stdout. | flags only (--mode, --model, --raw) |
agents | Cross-agent communication: register, message, request, discover, listen across a swarm. | login message request discover listen |
question | Capture and review the questions fired at agents mid-session, with their answers. | record log tail config |
task | PTY-aware command wrapper with ordered log capture, built for long-lived dev servers. | run get logs tail wait sessions dashboard |
scripts | Script MCP tool calls directly, with no agent loop, using types generated from each server. | servers tools call create run regen remote doctor |
debugging-master | Instrumentation primitives plus a token-efficient log reader for runtime debugging. | start get expand diff sessions cleanup dashboard |
stash | Global 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-fable | Staged pipeline that distills Fable 5's working style from local session transcripts into the Fable Pack. | bootstrap mine eval consolidate spec skill |
| Tool | What it does | Key subcommands | ||
|---|---|---|---|---|
git | Commit 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 |
loc | Count files and code, blank, and comment lines by language, respecting .gitignore. | flags only |
| Tool | What it does | Key subcommands |
|---|---|---|
github | Token-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 |
gitlab | GitLab 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 |
jenkins | Jenkins 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
hooks/register.tsx 856 lines1import 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}
856hooks/agent-model.tsx 110 lines1// 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}
110hooks/agents-band.tsx 533 lines1// 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}
533hooks/ask-row.tsx 103 lines1// `[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}
103hooks/band.tsx 25 lines1// 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}
25hooks/cache-band.tsx 340 lines1// 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}
340hooks/decisions.tsx 822 lines1// 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}
822hooks/genesis.ts 165 lines1// 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}
165hooks/git-band.tsx 1539 lines1// 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 lines1// 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}
409hooks/idle.ts 119 lines1import { 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}
119hooks/inbox.tsx 271 lines1// 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