SLOPSHOPPER

rabe

Watch Claude Code's background work: subagents, Codex jobs, shells, monitors, cron jobs and workflows.

newpanebandspinnerguardcommand
★ 4v0.4.4MITupdated 2026-10-11ThisaruGuruge/dotfiles/claude-mods/rabe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rabe
│ ┃ Rabe ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Items 0 2: Cost 3: Effects 4: Timelin │ ┃ ─━━━━━━━━━━───────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ [ s ] [ x: stop ] [ g: stop group ] [ r: rem ⎿ Read 6 lines │ ┃ search: title, kind or command ⏎ filter ⏺ Update(src/auth.ts) │ ┃ Nothing runs in the background. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /rabe │ ┃ ⎿ rabe: Rabe opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rabe
1: Items 0 2: Cost 3: Effects 4: Timeline ─━━━━━━━━━━───────────────────────────────────────────── [ s ] [ x: stop ] [ g: stop group ] [ r: remove ] [ a: remov search: title, kind or command ⏎ filter Nothing runs in the background. tab to select · esc close
README

Thisaru's Dotfiles

A macOS-focused developer workstation built around Zsh, Powerlevel10k, modern CLI tools, and battle-tested automation. Everything is wired together with bestow (a Go-based GNU Stow successor), encrypted secrets, and a centralized package manifest so each new machine behaves exactly like the last one.

Highlights

  • Fast Zsh environment – zinit-managed plugins, fzf-tab completion, syntax highlighting, autosuggestions, zoxide (via z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindings
  • Powerlevel10k prompt – pure-Zsh rendering (no binary subprocess per draw), instant prompt on startup, contextual Git state, Go/Java/Python/Ballerina indicators, transient prompt for clean scrollback, full Catppuccin Mocha theme matching the tmux status bar
  • Modern CLI stack – eza, bat, dust, ripgrep, fd, yazi (with inline image previews in Ghostty/iTerm2, chafa fallback for other terminals), jless, lazygit, lazydocker, tmux, direnv, atuin, gh, git-delta, gng (gw), uv (Python packages and projects), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)
  • Debugging in Neovim – nvim-dap with a DAP UI, inline variable values while stepping, and one keymap set (<leader>d… plus F5/F10/F11) shared by every language. Go (delve) supports debugging the test under the cursor, launching the program with arguments, and attaching to a remote dlv --headless; Python, Java, and Ballerina are wired in too. Adding another debugger is a single drop-in file: a module at nvim/.config/nvim/lua/thisarug/dap/<lang>.lua exporting setup(dap) is picked up automatically by lua/plugins/dap.lua
  • Go tooling in Neovim – gopls with inlay hints and codelenses, <leader>G… for code generation (if err != nil guards, struct tags, interface stubs, test and doc-comment generation), and <leader>T… to run tests from the buffer with a summary tree and watch mode (Go, plus pytest and Vitest via neotest adapters)
  • Sessions and branch review in Neovim – persistence.nvim restores a project's buffers and layout on demand (<leader>qs, or s on the dashboard), and diffview.nvim reviews a branch against the default branch (<leader>gv) or walks a file's history (<leader>gh)
  • Smart aliases – Single-letter shortcuts for modern tools (v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts
  • Suffix aliases – Automatically open files with the right tool based on extension (.md → mdcat, .json/.yaml → jless, .py/.bash/.zsh/.bal → $EDITOR)
  • Language runtimes – pyenv, rbenv, nvm, SDKMAN, and Ballerina (plus uv for Python packages and projects) with lazy-loading shell glue so heavy managers don't slow startup
  • Touch ID for sudo – Use your fingerprint instead of typing passwords in the terminal (works inside tmux too via pam-reattach)
  • Caps Lock remap – Hammerspoon + ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extension
  • Claude Code, two accounts – ccp (personal) and cce (enterprise) run Claude Code against separate config dirs, with TypeScript mods for a status band, risk previews, secret redaction and a subagent pane (see Claude Code); tmux shows which windows are waiting on Claude
  • Project sessions in tmux – prefix + f opens a project with three windows ([Claude], [Nvim], [Shell])
  • Secrets handled correctly – SOPS + age encryption, edit_secrets workflow, and automatic .env handling inside init.sh
  • Brewfile-driven – Curated Brewfile with optional category files in packages/ for modular installation
  • Validation + profiling – bin/lint (shellcheck, shfmt, zsh -n, stylua, mod tests; also run by CI), test-zsh integration tests, dotdoctor health check, profile_startup quick timing, and bin/profile-zsh-startup for deep dives

Prerequisites

  1. macOS – Tested on Sonoma/Ventura (Apple Silicon friendly)
  2. Git – Already on macOS, confirm with git --version
  3. Homebrew – Install if missing:
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. Terminal – Ghostty (recommended and configured), iTerm2, or Terminal.app all work
  2. Nerd Font – Needed for icons in Powerlevel10k/lazygit:
   brew install --cask font-fira-code-nerd-font

Set the font once inside your terminal profile (Appearance/Text settings).

Installation

Option 1 – Automated (init.sh)

git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh

The checkout can live anywhere (the script finds the repo from its own path). The rest of the setup hard-codes ~/dotfiles, so when the checkout is elsewhere init.sh creates a ~/dotfiles symlink pointing at it (and stops with a clear message if ~/dotfiles is already something else). ./init.sh --dry-run is a preflight only: it checks the checkout and ~/dotfiles and changes nothing; ./init.sh --help lists the steps.

The script is interactive; it will:

  • Validate macOS + Xcode CLT, install Homebrew, jq, Go (needed to install bestow)
  • Install core packages from Brewfile with Brew
  • Offer opt-in categories (development, terminals, editors, etc.)
  • Install SDKMAN + Java 21 (optional) and brew-based Ballerina
  • Enable Touch ID for sudo (with tmux support via pam-reattach, loaded from a root-owned copy in /opt/pam)
  • Configure Atuin, direnv, tmux, git delta, lazygit, lazydocker, aliases, and helper functions
  • Generate/restore encrypted .env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt, per-machine ~/.sops.yaml)
  • Create Nerd Font + terminal integrations
  • Back up existing dotfiles and symlink everything via bestow
  • Install TPM (pinned release) and the tmux plugins
  • Check that the SSH commit-signing key exists (offers to create it)
  • Verify the install and show next steps (run dotdoctor for a full health check; the final output also lists the manual steps: the Hammerspoon spoon and permissions, the ~/.claude symlink, tmux plugins)

Option 2 – Manual setup

git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles

# Install everything that is currently enabled in Brewfile
brew bundle --file=Brewfile

# Install bestow (requires Go)
go install github.com/redpierrot/bestow@latest

# Bootstrap bestow's own config (source: this repo, destination: $HOME)
bestow init --source ~/dotfiles --destination ~/

# Symlink the packages you need (add/remove as desired)
bestow stow zsh git tmux direnv bash
bestow stow bin -d ~/bin   # bin links into ~/bin, not ~

# Secrets: an age key and this machine's own sops config (not tracked)
mkdir -p ~/.config/sops/age && age-keygen -o ~/.config/sops/age/keys.txt
sed "s/AGE_PUBLIC_KEY/$(grep '^# public key:' ~/.config/sops/age/keys.txt | cut -d' ' -f4)/g" \
  bash/.sops.yaml.example > ~/.sops.yaml

# Reload configuration, then create the encrypted ~/.env
source ~/.zshrc
edit_secrets

Git signs every commit with SSH (gpgSign = true), so set user.signingkey in ~/.gitconfig.local (see git/.gitconfig.local.example) and create the key it names (ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing) before your first commit.

Edit the Brewfile directly or use category Brewfiles in packages/ before running brew bundle if you want to customize packages.

Option 3 – Brewfile only

Already have a preferred dotfiles strategy but want the curated tools? Run brew bundle --file=Brewfile and manually pick pieces (aliases, functions, etc.). Edit the Brewfile directly or use the category files in packages/ to customize your installation.

Package Management

The main Brewfile contains core packages that are always installed. Optional packages are organized into category files in packages/:

packages/
├── cloud.brewfile       # aws-vault, gcloud CLI
├── containers.brewfile  # colima, Docker CLI + Compose + credential helper
├── development.brewfile # pyenv, rbenv, nvm, flutter
├── editors.brewfile     # VS Code
├── productivity.brewfile # Raycast, Rectangle, Alfred, Caffeine, etc.
└── terminals.brewfile   # iTerm2

Installing optional categories

brew bundle --file=Brewfile                      # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile     # Add editors

Categories available

  • core – powerlevel10k, zoxide, eza, bat, ripgrep, lazygit, lazydocker, tmux, direnv, atuin, gh, etc.
  • security – sops + age for encrypted secrets (always enabled)
  • development – pyenv, rbenv, nvm, flutter (optional)
  • cloud – aws-vault and the gcloud CLI
  • editors – VS Code
  • terminals – iTerm2 (casks)
  • containers – colima (container runtime), Docker CLI + Compose + credential helper
  • productivity – Raycast, Rectangle, TablePlus, Alfred, Postman, Caffeine

Comment out what you do not need in the Brewfile, then rerun brew bundle.

brew-sync (or ./bin/sync-brewfile) is a read-only drift report: it lists formulae and casks that are installed but in no Brewfile, and declared packages that are not installed. It never rewrites a Brewfile; add or remove lines by hand. brew-check verifies the core Brewfile is satisfied.

Repository Layout & Symlink Packages

PathNotes
zsh/.zshrc, .zshrc.d/ (modular shell config), .functions.d/ (modular functions), aliases, paths
zsh/.zshrc.d/6 modules: plugins, completion, keybindings, history, integrations, environment
zsh/.functions.d/9 modules: colors, core, navigation, archives, git, system, dotfiles, docs, packages
<tool>/.config/<tool>/XDG configs, one stow package per tool (ghostty, lazygit, nvim, yazi, ripgrep, typos)
zsh/.p10k.zshPowerlevel10k prompt config (Catppuccin Mocha, stowed to ~/.p10k.zsh)
git/.gitconfig, ignore rules, delta settings
tmux/Modern tmux config + keybinds, project session layout, Claude state in status bar
claude/Per-account Claude Code settings.json and status line, stowed to ~/.claude-personal and ~/.claude-enterprise
claude-mods/Claude Code mods (not a stow package; loaded through CLAUDE_CODE_PLUGIN_DIRS)
tests/Golden-output and behaviour tests for the Claude Code helpers (tests/claude/check.sh, run by test-zsh)
direnv/Project-specific environment automation
packages/Optional category Brewfiles (cloud, containers, development, etc.)
bin/Helper scripts (lint, dotdoctor, test-zsh-config, profile-zsh-startup, audit-configs, adopt-config; see bin/README.md)
docs/Additional documentation (prompt guide, tmux keybindings, config management)

Ghostty Configuration

Ghostty is configured with:

  • Cmd+Left/Right sent as Home/End (ESC [ H / ESC [ F), which 03-keybindings.zsh binds to start/end of line (a raw Ctrl+a would be eaten as the tmux prefix)
  • Option+Left/Right for word navigation (sent as Alt+b / Alt+f, so it works inside tmux too) and Cmd+Delete for delete-to-end-of-line (sent as Alt+k)
  • Catppuccin Mocha theme
  • FiraCode Nerd Font with ligatures
  • Tmux-aware keybindings for window/pane navigation (they send the tmux prefix Ctrl+a; test-zsh fails if the prefix changes)

Containers (colima)

The optional containers category (packages/containers.brewfile) installs colima as the local container runtime, paired with the plain docker, docker-compose, and docker-credential-helper CLI formulae — no Docker Desktop app/VM required.

brew bundle --file=packages/containers.brewfile  # colima + docker CLI + compose + credential helper
colima-start                                     # boots the VM, sets the `colima` docker context
docker ps                                        # talks to colima automatically
lzd                                               # lazydocker also works unchanged — it just follows the active docker context

Colima lifecycle aliases (zsh/.aliases.sh, only defined if colima is installed):

AliasCommand
colima-startcolima start
colima-stopcolima stop
colima-restartcolima restart
colima-statuscolima status
colima-listcolima list
colima-sshcolima ssh

Homebrew's docker-compose formula installs as a CLI plugin, and docker-credential-helper installs docker-credential-osxkeychain to store registry credentials in the macOS Keychain instead of plaintext — but Docker only picks either up if ~/.docker/config.json says so:

{
  "cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"],
  "credsStore": "osxkeychain"
}

init.sh does not currently write these keys automatically — add them by hand (or merge into an existing ~/.docker/config.json) after installing docker-compose / docker-credential-helper.

Symlink Management (bestow)

Day-to-day package management (adding/removing symlinks after the initial install) uses bestow — a Go-based successor to GNU Stow — instead of stow directly. It replicates stow --no-folding behavior (always on, no flag needed) but is idempotent and conflict-safe with pre-existing absolute symlinks.

bestow stow zsh                    # Shell config (includes .p10k.zsh)
bestow stow nvim ghostty lazygit ripgrep yazi typos  # Per-tool packages
bestow stow git tmux direnv        # Git/Tmux/Direnv packages

# Remove a package
bestow unstow zsh

init.sh (the fresh-machine installer) installs bestow with go install github.com/redpierrot/bestow@latest and runs the same bestow stow commands per package. @latest resolves to bestow's newest tagged release once it publishes tags (until then, the newest commit on its default branch).

bin is the exception: it links into ~/bin, so always stow it as bestow stow bin -d ~/bin. Don't run a bare bestow stow across every package — with the single $HOME destination in bestow's config it would link bin/* straight into ~.

Quick Start Commands

test-zsh                                   # Full validation (tools, PATH, runtimes)
./bin/lint                                 # Lint everything, as CI does (./bin/lint --file <path> for one file)
./bin/profile-zsh-startup                  # Deep component timing (zinit plugins, SDKMAN, p10k, etc.)
help                                       # Alias documentation entry point
docs                                       # Interactive alias browser (alias_docs)
show_tools                                 # Overview of installed CLI upgrades
alias_search git                           # Search for aliases by keyword
edit_secrets                               # Safely edit encrypted ~/.env via SOPS
take my-service && code .                  # Smart project bootstrap / clone helper
kill_by_port 3000                          # Stop the process LISTENING on port 3000 (TERM, then KILL); clients are left alone
awake                                      # Keep the Mac from idle-sleeping (awake stop | status | for 2h | screen | ac)
dotdoctor                                  # Health check (changes nothing): unlinked files, dangling links, secrets, mods (--full adds brew + plugin validation)
prs                                        # Browse PRs with fzf: enter=checkout, ctrl-o=browser, ctrl-d=diff via delta
prw                                        # Watch the current PR's checks, then notify with the result
envrc work|personal|status|off             # Pin the Claude Code + gws account for this directory via direnv
lg                                         # Launch lazygit with our config (arguments are forwarded, e.g. lg log)
lzd                                        # Launch lazydocker for Docker management
gw build                                   # Run Gradle build from any subdirectory
gwt                                        # Alias for: gw test
gwc                                        # Alias for: gw clean
gwcb                                       # Alias for: gw clean build
Ctrl+R                                     # Atuin search UI (fuzzy search all history; Ctrl+R again narrows the filter)
Up arrow                                   # Atuin prefix search (as you type)
Ctrl+T / Alt+C                             # fzf file / directory picker with a preview (Ctrl+/ toggles it)

Claude Code

Two accounts, each with its own config directory (claude/ is the stow package that links settings.json and statusline-command.sh into both; history, sessions and auth stay local, auth in the macOS Keychain):

CommandAccountConfig dir
ccp, ccpr, ccpcpersonal: start, --resume, --continue~/.claude-personal
cce, ccer, ccecenterprise: start, --resume, --continue~/.claude-enterprise
  • ~/.claude must be a symlink to ~/.claude-personal (ln -s ~/.claude-personal ~/.claude), so plain claude outside a pinned directory is the personal account. dotdoctor checks it.
  • ccp/ccpr/ccpc also load ~/.claude-personal/projects.d/<repo>.json with --settings when the current git repo has one. That is where a project's own autoMode rules go, since Claude Code ignores autoMode in a project's .claude/settings*.json and they must not live in this public repo. <repo> is the main checkout's directory name, so linked worktrees share it.
  • Mods in claude-mods/ (statusbar, blast-radius, redact, agents, rabe, session-wrapped, conventions) load straight from the repo via CLAUDE_CODE_PLUGIN_DIRS in both settings.json files, with nothing to link. The band above the prompt (context bar, rate limits) is the statusbar mod, the line below it is bin/claude-statusline, and conventions runs bin/lint --file after each edit Claude makes in this repo. See claude-mods/README.md.
  • Hooks call bin/claude-tmux-state, so tmux windows show when Claude is working, waiting or done (prefix + A jumps to the next waiting window).
  • Hooks also call bin/claude-notify, which shows a macOS notification when a session finishes (Stop), asks you something (AskUserQuestion) or needs you (permission prompt, idle: Notification). It stays quiet while the terminal and the tmux window are in view. CLAUDE_NOTIFY=0 turns it off, CLAUDE_NOTIFY_SUBAGENTS=1 adds subagent completions, CLAUDE_NOTIFY_ALWAYS=1 disables the in-view suppression, CLAUDE_NOTIFY_SOUND=<name> changes the sound. One-time setup: allow banners and sounds for Script Editor under System Settings > Notifications (osascript notifications are attributed to it); Focus modes silence them. Details in bin/README.md.
  • Pin an account to a directory with envrc (below).

tmux project sessions

prefix + f (sessionizer) and prefix + S (new session) create a session with three windows: [Claude] (runs claude with the directory's .envrc applied), [Nvim] (nvim over a small shell, the window the session opens on) and [Shell] (plain shell). SESSIONIZER_LAYOUT=bare gives a single shell instead. After pulling, run bestow stow tmux if a new helper script is missing. Key reference: docs/TMUX_KEYBINDINGS.md.

Per-project accounts (envrc)

envrc work (enterprise) or envrc personal writes a .envrc in the current directory that pins CLAUDE_CONFIG_DIR and GOOGLE_WORKSPACE_CLI_CONFIG_DIR (plus the gws file keyring backend), creates the account's gws directory with mode 700, runs direnv allow and applies the pin to the current shell straight away. Plain claude and gws then use the right account anywhere under that directory, including subdirectories and new shells or tmux panes opened inside the tree (direnv stays lazy-loaded: it is only loaded once a .envrc exists in the current directory or any parent). The file is added to the repo's local exclude (info/exclude, also from a linked worktree; envrc confirms with git check-ignore) and inherits parent pins through source_up_if_exists.

  • envrc status shows which pin applies (and whether it is inherited from a parent directory)
  • envrc off removes a pin written by envrc (it never touches a .envrc it did not write)
  • Replacing an existing .envrc (not written by envrc, or edited by hand since) asks first and keeps a copy as .envrc.bak; a symlinked .envrc is refused rather than written through

What overrides what

CommandCLAUDE_CONFIG_DIRGOOGLE_WORKSPACE_CLI_CONFIG_DIR
plain claude in a pinned treepinned accountpinned account (inherited)
ccp/ccpr/ccpc, cce/ccer/ccecthe account you typedblanked for that claude, so a pin never leaks the other account's gws into it
gwsp/gwseunchangedthe account you typed
bare gws in a pinned treeunchangedpinned account: the bin/gws guard lets it through because the pin supplies the account context
bare gws anywhere elseunchangedblocked (exit 64)

Inside a pinned tree a bare gws therefore acts on the pinned account instead of being refused; that is the point of the pin. Plain claude outside any pinned directory uses ~/.claude, which should be a symlink to ~/.claude-personal (dotdoctor checks this: ln -s ~/.claude-personal ~/.claude).

GitHub PR helpers (prs, prw)

  • prs [gh pr list flags] – fzf over the repo's PRs with a gh pr view preview. enter checks the PR out, ctrl-o opens it in the browser, ctrl-d shows the full diff through delta. Example: prs --author @me
  • prw [pr] – gh pr checks --watch for the current branch's PR (or the given number/URL), then a macOS notification with the result; the exit status mirrors gh pr checks

Both need gh and jq; prs also needs fzf. PR titles are untrusted text: control characters (including ESC) are replaced with spaces before they are shown, and prw prints titles verbatim (no backslash interpretation). prw says whether the checks failed, were cancelled or are still pending, or that gh reported no result (no checks, or gh could not be reached), instead of calling every non-zero exit "failed".

Shell behaviour worth knowing

  • cp / mv are small functions, not aliases: they prompt before overwriting (-i, verbose) only when a human is at the terminal, and run the plain command otherwise (scripts, agents, pipes). They do not force -r; copy directories with cp -R. sudo cp / sudo mv run the plain commands (sudo runs the binary, not the shell function), so pass -i yourself there.
  • awake keeps the Mac from idle-sleeping in the background (caffeinate -i: the display may still sleep or lock, and it works on AC and battery). awake screen also keeps the display on, awake ac
Source 36 files
hooks/register.tsx 15 lines
1import type { Register } from 'claude-code'
2
3import { sources } from './sources'
4import { band } from './ui/band'
5import { builtin } from './ui/builtin'
6import { pane } from './ui/pane'
7import { hoursOf } from './ui/views/timeline'
8
9export const register: Register = (on, options) => {
10  sources(on, String(options.pricesFile ?? ''))
11  band(on)
12  pane(on, hoursOf(options.timelineHours))
13  if (options.hideBuiltinTasks !== false) builtin(on)
14}
15
hooks/sources/index.ts 23 lines
1import type { On } from 'claude-code'
2
3import { agents } from './agents'
4import { codex } from './codex'
5import { crons } from './crons'
6import { monitors } from './monitors'
7import { shells } from './shells'
8import { workflows } from './workflows'
9import { worktrees } from './worktrees'
10
11export type Source = (on: On) => void
12
13// `file`: the user's price table (option `pricesFile`), '' for none.
14export function sources(on: On, file: string): void {
15  agents(on, file)
16  workflows(on)
17  codex(on, file)
18  shells(on)
19  monitors(on)
20  crons(on)
21  worktrees(on)
22}
23
hooks/ui/band.tsx 32 lines
1import type { On } from 'claude-code'
2
3import { render } from './render'
4import { bounded, NO_SELECTION } from './view'
5import { bandView } from './views/band'
6
7export function band(on: On): void {
8  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
9    if (e.props.hasSurvey) return next(e)
10    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
11    const { value: removed = [] } = await $.state.get({ plugin: 'rabe', key: 'removed' })
12    if (!items.some(item => item.status === 'running')) return next(e)
13    const usage = await $.session.usage().catch(() => undefined)
14    const model = {
15      items,
16      removed,
17      turns: {},
18      lines: {},
19      now: await $.clock.now(),
20      usd: usage?.cost?.usd,
21    }
22    const size = bounded({
23      columns: e.props.bodyColumns,
24      rows: e.props.maxRows,
25      surface: e.surface,
26      hasInput: false,
27    })
28
29    return render($.ui.resolve(e), e.surface, bandView(model, size, NO_SELECTION), () => {}, 'band')
30  })
31}
32
hooks/ui/builtin.tsx 57 lines
1import type { On } from 'claude-code'
2
3const TASK =
4  /^\d+ (background |dynamic )?(shell|monitor|agent|subagent|workflow|task|teammate|MCP task|cloud session)s?$/
5
6// Removes the part of Claude Code's prompt hint that counts background work
7// ("2 shells, 1 monitor") and its "↓ to manage"; keeps every other part.
8export function stripTasks(hint: string): string {
9  return hint
10    .split(' · ')
11    .filter(part => part !== '↓ to manage' && !part.split(', ').every(one => TASK.test(one)))
12    .join(' · ')
13}
14
15// The engine's duration format on the turn line: 5s, 1m 4s, 1h 2m 5s.
16export function turnDuration(ms: number): string {
17  const s = Math.floor(ms / 1000)
18  const h = Math.floor(s / 3600)
19  const m = Math.floor(s / 60) % 60
20  if (h > 0) return `${h}h ${m}m ${s % 60}s`
21
22  return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
23}
24
25// Hides Claude Code's own count of background work, since the band shows it.
26export function builtin(on: On): void {
27  on('ui.render', { component: 'PromptHint' }, async (_$, e, next) => {
28    const hint = stripTasks(e.props.hint)
29
30    return hint === e.props.hint ? next(e) : next({ ...e, props: { ...e.props, hint } })
31  })
32
33  // The line's "N shells, M monitors still running" part is not a prop, so the
34  // hook draws the line itself, only while Rabe knows of a running shell or
35  // monitor and no running agent or workflow (then the engine waits for those
36  // and draws no such part). That line leaves out "done 1:16": no prop has it.
37  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
38    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
39    const running = items.filter(item => item.status === 'running')
40    const isWaiting = running.some(item => item.kind === 'agent' || item.kind === 'workflow')
41    const hasTasks = running.some(item => item.kind === 'shell' || item.kind === 'monitor')
42    if (isWaiting || !hasTasks) return next(e)
43    const { Box, Text } = $.ui.resolve(e)
44
45    return (
46      <Box flexDirection="row" marginTop={1} width="100%">
47        <Box minWidth={2}>
48          <Text dimColor>✻</Text>
49        </Box>
50        <Text dimColor>
51          {e.props.word} for {turnDuration(e.props.durationMs)}
52        </Text>
53      </Box>
54    )
55  })
56}
57
hooks/ui/pane.tsx 704 lines
1import type { EngineInterface, On, RenderSurface } from 'claude-code'
2
3import type { RabeItem, RabePrevious } from '../../types'
4import { KIND_LABEL, kept, matches, orderOf, previousOf, rowOf } from './lists'
5import { type At, type Held, hold, isRowKey, render, shifts } from './render'
6import { reserve, sequence } from './sequence'
7import {
8  type Action,
9  type ArmEvent,
10  type Arming,
11  arm,
12  bounded,
13  DISARMED,
14  landing,
15  landingOf,
16  layout,
17  type Model,
18  messageLanding,
19  rowKeys,
20  type Selection,
21  stepRow,
22  targetsOf,
23  taskIdOf,
24} from './view'
25import { fallbackOf, isLiveRow, paneView, seatsRows, selectsOnPress } from './views/pane'
26import { widen, windowOf } from './views/timeline'
27
28const PANE = 'rabe'
29const MAX_ACTIONS = 20
30
31// The option `timelineHours`, set by `pane(on, hours)`: the Timeline's window
32// when the pane opens.
33let baseHours = 4
34
35function toastText(text: string): string {
36  return text.replace(/\s+/g, ' ').replace(/[\p{Cc}\p{Cf}]/gu, '?')
37}
38
39// Titles and reasons come from files and tools.
40function toast($: EngineInterface, text: string): void {
41  $.ui.toast(toastText(text))
42}
43
44// Tabs and line breaks stay; an escape could end a bracketed paste.
45function clipText(text: string): string {
46  return text.replace(/(?![\t\n\r])[\p{Cc}\p{Cf}]/gu, '?')
47}
48
49// A cron job's title is its prompt.
50function nameOf(item: RabeItem): string {
51  return item.kind === 'cron' ? item.detail.jobId : item.title
52}
53
54// Esc may have closed the pane before the delayed focus call.
55async function refocus($: EngineInterface): Promise<void> {
56  const pane = (await $.ui.panes()).find(one => one.id === PANE)
57  if (!pane || pane.isFocused) return
58  await $.ui.open({ id: PANE, title: 'Rabe', focus: true, closeOnEscape: true })
59}
60
61async function tick($: EngineInterface): Promise<void> {
62  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
63  if (items.some(item => item.status === 'running')) $.ui.invalidate('ui.render')
64}
65
66// The store is the plugin's own JSON; an older Rabe may have written another
67// shape, and a summary before 0.4 has no session id.
68function asPrevious(value: unknown): RabePrevious | undefined {
69  const prev = value as RabePrevious | undefined
70  if (typeof prev?.endedAt !== 'number' || !Array.isArray(prev.failed) || !prev.counts) {
71    return undefined
72  }
73  const { sessionId, ...rest } = prev
74
75  return typeof sessionId === 'string' && sessionId ? { ...rest, sessionId } : rest
76}
77
78async function previous($: EngineInterface): Promise<RabePrevious | undefined> {
79  return asPrevious(await $.store.get(`previous:${await $.session.cwd()}`))
80}
81
82// Keeps this session's summary for the next one in this project; a session
83// with no background work leaves the last summary in place.
84async function remember($: EngineInterface, sessionId?: string): Promise<void> {
85  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
86  if (items.length === 0) return
87  const usage = await $.session.usage()
88  const summary = previousOf(
89    items,
90    await $.clock.now(),
91    { startedAt: usage.startedAt, usd: usage.cost?.usd },
92    sessionId,
93  )
94  await $.store.set(`previous:${await $.session.cwd()}`, summary)
95}
96
97async function stop($: EngineInterface, ids: string[]): Promise<void> {
98  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
99  const targets = items.filter(item => ids.includes(item.id))
100  const one = targets.length === 1 ? targets[0] : undefined
101  if (one) toast($, `Stopping ${KIND_LABEL[one.kind]} ${nameOf(one)}…`)
102  let stopped = 0
103  const stoppedItems: RabeItem[] = []
104  let ended = 0
105  let refused: string | undefined
106  for (const item of targets) {
107    if (item.kind === 'codex' && item.status === 'running') {
108      const { text = 'n/a' } = await $.command
109        .run({ command: 'rabe-stop', args: item.id })
110        .catch((error: unknown) => ({ text: `Stop refused: ${String(error)}` }))
111      if (text.startsWith('Stopped')) {
112        stopped += 1
113        stoppedItems.push(item)
114      } else refused = text.replace(/^Stop refused: /, '')
115      continue
116    }
117    const task = taskIdOf(item)
118    if (item.status !== 'running' || !task) {
119      ended += 1
120      continue
121    }
122    try {
123      const result = await $.tool.call({ tool: 'TaskStop', task_id: task })
124      if (result.deny) refused = result.deny
125      else if (result.isError) refused = result.text ?? 'n/a'
126      else {
127        stopped += 1
128        stoppedItems.push(item)
129      }
130    } catch (error) {
131      refused = error instanceof Error ? error.message : String(error)
132    }
133  }
134  for (const item of stoppedItems) {
135    await queueAction($, `Rabe: user stopped ${item.kind} ${item.id}`)
136  }
137  if (one && refused) toast($, `Stop refused: ${refused}`)
138  else if (one) toast($, ended ? `${nameOf(one)} had already finished` : `Stopped ${nameOf(one)}`)
139  else {
140    const done = ended ? `; ${ended} had already finished` : ''
141    const no = refused ? `; refused: ${refused}` : ''
142    toast($, `Stopped ${stopped} of ${targets.length}${done}${no}`)
143  }
144}
145
146async function queueAction($: EngineInterface, note: string): Promise<void> {
147  for (;;) {
148    const { value = [], version } = await $.state.get({ plugin: 'rabe', key: 'actions' })
149    const { isSet } = await $.state.set(
150      { plugin: 'rabe', key: 'actions' },
151      [...value, note].slice(-MAX_ACTIONS),
152      { ifVersion: version },
153    )
154    if (isSet) return
155  }
156}
157
158async function clearActions($: EngineInterface, delivered: string[]): Promise<void> {
159  for (;;) {
160    const { value = [], version } = await $.state.get({ plugin: 'rabe', key: 'actions' })
161    if (!delivered.every((note, index) => value[index] === note)) return
162    const { isSet } = await $.state.set(
163      { plugin: 'rabe', key: 'actions' },
164      value.slice(delivered.length),
165      { ifVersion: version },
166    )
167    if (isSet) return
168  }
169}
170
171// Whether stop and delete act: the ring is known to sit on a safe element.
172// A module value, so a reload, which also drops the hold, starts disarmed.
173let arming: Arming = DISARMED
174
175// Where the last `ui.focus` on the pane landed, as the pane's focus hook read it.
176let landedOn: string | undefined
177
178// The element that holds the focus ring, as far as the pane's focus hook saw
179// it land; unknown after a reset, whose view puts another element at its index.
180let ringOn: string | undefined
181
182// The element the ring belongs on after rows came above it in the Items list
183// (the ring kept its index), until the pane's focus hook sees the ring land.
184// `isAsked`: a move back is under way; `tries`: moves asked since the pane
185// last took the keys.
186type Shift = { key: string; isAsked: boolean; tries: number }
187let shifted: Shift | undefined
188
189function feed($: EngineInterface, event: ArmEvent): void {
190  const was = arming
191  arming = arm(arming, event)
192  if (event.type === 'reset') {
193    ringOn = undefined
194    shifted = undefined
195  }
196  if (arming.isArmed !== was.isArmed || arming.isListArmed !== was.isListArmed) {
197    $.ui.invalidate('ui.render')
198  }
199}
200
201// Moves the ring onto one of the pane's safe elements. Refused while the pane
202// does not hold the keys (Enter then goes to the prompt) or when another hook
203// says no: either way the ring may sit anywhere, so the pane disarms.
204async function focusOn($: EngineInterface, key: string): Promise<boolean> {
205  landedOn = undefined
206  const result = await $.ui.focus({ requestId: PANE, key }).catch(() => ({ deny: 'threw' }))
207  const isMoved = !('deny' in result && result.deny) && landedOn === key
208  feed($, { type: 'landed', isMoved })
209
210  return isMoved
211}
212
213// Puts the ring back on the element rows came above, a moment after the
214// drawing: a move asked while the hook draws resolves against the tree
215// before the one it returns, where the element still stands at its old
216// index. From a timer the move reaches no hook (see docs/feasibility/ui.md), so the
217// engine's answer is the evidence: a move it refuses or abandons counts as a
218// refused landing, and the next drawing asks again, at most three times. A
219// landing the focus hook saw since (the person's arrow) makes the move moot.
220const REGAIN_MS = 100
221
222async function regain($: EngineInterface, want: Shift): Promise<void> {
223  if (shifted !== want) return
224  const result = await $.ui
225    .focus({ requestId: PANE, key: want.key })
226    .catch(() => ({ deny: 'threw' }))
227  if (!('deny' in result && result.deny)) {
228    if (shifted === want) shifted = undefined
229    return
230  }
231  want.isAsked = false
232  feed($, { type: 'landed', isMoved: false })
233}
234
235// A row press while the ring is off its element: Enter would press the row
236// that took its index. A click while the pane does not hold the keys is the
237// person's own.
238const isHeldBack = (at: At): boolean =>
239  shifted !== undefined && seats.get(at.surface)?.isFocused === true && isRowKey(at.element)
240
241// A new view starts its hold anew, but the ring keeps its index, where the new
242// view may draw a stop; so the pane disarms and the ring moves (`landing`).
243async function land($: EngineInterface, keys: string[]): Promise<void> {
244  for (const key of keys) if (!(await focusOn($, key))) return
245}
246
247async function landView($: EngineInterface, action: Action, surface: RenderSurface): Promise<void> {
248  const { model, selection } = await look($, true)
249  const drawn = paneView(model, { columns: 80, rows: 24, surface, hasInput: true }, selection)
250  return land($, landing(action, selection.open, drawn))
251}
252
253// A press on a live row of the Items list away from the ring selects it: a
254// click moves no ring, so the press is the person's choice of that row. The
255// ring's row and the selected row open.
256async function choose($: EngineInterface, id: string): Promise<boolean> {
257  const { model, selection } = await look($, true)
258  if (!selectsOnPress(model, selection, id, ringOn)) return false
259  await $.state.set({ plugin: 'rabe', key: 'selected' }, id)
260  feed($, { type: 'press' })
261
262  return true
263}
264
265// Hides ended items for the session: they stay in `rabe.items`, which polls
266// fill again, and only the list leaves them out (`kept`). An id stays even when
267// the cap drops its item, which a poll may find again.
268async function remove($: EngineInterface, action: Action & { type: 'remove' | 'clear' }) {
269  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
270  const { value: query = '' } = await $.state.get({ plugin: 'rabe', key: 'query' })
271  let gone: RabeItem[] = []
272  for (;;) {
273    const { value: removed = [], version } = await $.state.get({ plugin: 'rabe', key: 'removed' })
274    gone = kept(items, removed).filter(
275      item =>
276        item.status !== 'running' &&
277        (action.type === 'remove' ? action.ids.includes(item.id) : matches(item, query)),
278    )
279    if (gone.length === 0) return
280    const next = [...removed, ...gone.map(item => item.id)]
281    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'removed' }, next, {
282      ifVersion: version,
283    })
284    if (isSet) break
285  }
286  const [one] = gone
287  toast(
288    $,
289    gone.length === 1 && one
290      ? `Removed ${KIND_LABEL[one.kind]} ${nameOf(one)}`
291      : `Removed ${gone.length} ended items`,
292  )
293}
294
295async function act($: EngineInterface, action: Action, surface: RenderSurface): Promise<void> {
296  if (action.type === 'open' && (await choose($, action.id))) return
297  if (action.type !== 'message' && landing(action, '').length > 0) feed($, { type: 'reset' })
298  switch (action.type) {
299    case 'tab':
300      await $.state.set({ plugin: 'rabe', key: 'tab' }, action.tab)
301      return landView($, action, surface)
302    case 'fold': {
303      const { value: folded = [] } = await $.state.get({ plugin: 'rabe', key: 'folded' })
304      const next = folded.includes(action.group)
305        ? folded.filter(group => group !== action.group)
306        : [...folded, action.group]
307      await $.state.set({ plugin: 'rabe', key: 'folded' }, next)
308      return land($, landing(action, ''))
309    }
310    case 'open': {
311      // Back selects the item that was open: the focus may have moved onto a
312      // row inside its detail (a workflow's agents). A folded forwarder has no
313      // row, so back selects its job.
314      const { value: open = '' } = await $.state.get({ plugin: 'rabe', key: 'open' })
315      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
316      const was = rowOf(open, items)
317      const id = action.id || was
318      if (id) {
319        await $.state.set({ plugin: 'rabe', key: 'selected' }, id)
320        await $.state.set({ plugin: 'rabe', key: 'tab' }, 'items')
321      }
322      await $.state.set({ plugin: 'rabe', key: 'open' }, action.id)
323      return land($, landing(action, was))
324    }
325    case 'query':
326      await $.state.set({ plugin: 'rabe', key: 'query' }, action.text)
327      return action.isSubmit ? landView($, action, surface) : land($, landing(action, ''))
328    case 'window': {
329      const now = await $.clock.now()
330      const { value = windowOf(baseHours, now) } = await $.state.get({
331        plugin: 'rabe',
332        key: 'window',
333      })
334      await $.state.set({ plugin: 'rabe', key: 'window' }, widen(value, now))
335      return land($, landing(action, ''))
336    }
337    case 'focus':
338      await focusOn($, action.key)
339      return
340    case 'stop':
341      await stop($, action.ids)
342      return
343    case 'remove':
344    case 'clear':
345      await remove($, action)
346      return
347    case 'delete': {
348      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
349      const item = items.find(one => one.id === action.id)
350      if (
351        item?.kind !== 'cron' ||
352        item.status !== 'running' ||
353        item.detail.scheduledFor !== undefined
354      )
355        return
356      const result = await $.tool.call({ tool: 'CronDelete', id: item.detail.jobId })
357      const reason = result.deny ?? (result.isError ? (result.text ?? 'n/a') : undefined)
358      toast(
359        $,
360        reason
361          ? `Delete refused: ${reason.replace(/<\/?tool_use_error>/g, '').trim()}`
362          : `Deleted cron ${nameOf(item)}`,
363      )
364      if (!reason) await queueAction($, `Rabe: user deleted cron ${item.id}`)
365      return
366    }
367    case 'copy': {
368      // Over SSH the clipboard needs the terminal's OSC 52.
369      const text = clipText(action.text)
370      const result = await $.ui
371        .copy({ text, surface })
372        .catch((error: unknown) => ({ isCopied: false as const, reason: String(error) }))
373      const preview = toastText(text)
374      // only text the filters left alone is offered for selection
375      const selectable = !action.what && preview === action.text
376      const copied = action.what ? `Copied ${action.what}` : `Copied: ${preview}`
377      toast(
378        $,
379        result.isCopied
380          ? copied
381          : `Copy failed: ${result.reason}.${selectable ? ` Select it: ${text}` : ''}`,
382      )
383      return
384    }
385    case 'none':
386      return
387    case 'message': {
388      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
389      const item = items.find(one => one.id === action.id)
390      if (item?.kind !== 'agent' || !action.text.trim()) return
391      const sent = await $.session.send({ to: { agentId: item.detail.agentId }, text: action.text })
392      toast(
393        $,
394        sent.isDelivered
395          ? `Message sent to claude ${item.detail.name || item.title}`
396          : `Message not sent: ${sent.reason}`,
397      )
398      if (sent.isDelivered) await queueAction($, `Rabe: user sent a message to agent ${item.id}`)
399      return sequence(async () => {
400        const { selection } = await look($, true)
401        const target = messageLanding(action, selection, ringOn)
402        if (target) await land($, target)
403      })
404    }
405  }
406}
407
408// What the pane draws from: the sources' values and the person's place.
409async function look(
410  $: EngineInterface,
411  isFocused: boolean,
412): Promise<{ model: Model; selection: Selection }> {
413  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
414  const { value: removed = [] } = await $.state.get({ plugin: 'rabe', key: 'removed' })
415  const { value: turns = {} } = await $.state.get({ plugin: 'rabe', key: 'turns' })
416  const { value: lines = {} } = await $.state.get({ plugin: 'rabe', key: 'lines' })
417  const { value: edits = [] } = await $.state.get({ plugin: 'rabe', key: 'edits' })
418  const { value: tab = 'items' } = await $.state.get({ plugin: 'rabe', key: 'tab' })
419  const { value: query = '' } = await $.state.get({ plugin: 'rabe', key: 'query' })
420  const { value: folded = [] } = await $.state.get({ plugin: 'rabe', key: 'folded' })
421  const { value: selected = '' } = await $.state.get({ plugin: 'rabe', key: 'selected' })
422  const { value: open = '' } = await $.state.get({ plugin: 'rabe', key: 'open' })
423  const { value: order } = await $.state.get({ plugin: 'rabe', key: 'order' })
424  const { value: worktrees } = await $.state.get({ plugin: 'rabe', key: 'worktrees' })
425  const { value: window } = await $.state.get({ plugin: 'rabe', key: 'window' })
426  const now = await $.clock.now()
427  const usage = await $.session.usage().catch(() => undefined)
428  const model = {
429    items,
430    removed,
431    turns,
432    lines,
433    now,
434    usd: usage?.cost?.usd,
435    previous: await previous($).catch(() => undefined),
436    edits,
437    cwd: await $.session.cwd().catch(() => undefined),
438    sessionId: await $.session.id().catch(() => undefined),
439    ...(worktrees && { worktrees }),
440  }
441
442  const selection = {
443    tab,
444    query,
445    folded,
446    selected,
447    open,
448    isFocused,
449    isArmed: arming.isArmed,
450    isListArmed: arming.isListArmed,
451    window: window ?? windowOf(baseHours, now),
452    ...(order && { order }),
453  }
454
455  return { model, selection }
456}
457
458// The view the person chose. A change of it is theirs, so its hold starts anew.
459const scopeOf = (sel: Selection) =>
460  JSON.stringify([sel.tab, sel.open, sel.query, sel.folded, sel.window?.hours])
461
462// Per surface, the focusable keys the pane drew since it opened, for one view
463// (`scope`). A render hook may not write `$.state` (drawing is pure), so this
464// is a module value: a reload starts the hold anew, as a new drawing would.
465const holds = new Map<string, { scope: string; keys: Held[] }>()
466
467// Per surface, where the pane sat and whether it held the keys at the last
468// drawing. A move between dock and inline takes the keys from the pane.
469const seats = new Map<string, { placement: string; isFocused: boolean }>()
470
471// The presses and Input events under way, per surface and element, oldest
472// first. Each event's hook adds its own slot before `next(e)`; the closure
473// beneath, which the engine runs with the event, fills the oldest empty slot of
474// its element, so an event carries out only an action of its own element.
475// ponytail: two events on one element in flight at once may swap their
476// actions; both are that element's, and each runs exactly once.
477type Slot = { press?: { action: Action; surface: RenderSurface } }
478const waiting = new Map<string, Slot[]>()
479
480const slotOf = (at: At) => `${at.surface} ${at.element}`
481
482function leave(action: Action, at: At): void {
483  const slot = waiting.get(slotOf(at))?.find(one => !one.press)
484  if (slot) slot.press = { action, surface: at.surface }
485}
486
487// Runs a press or an Input event beneath the hook, then carries out the action
488// its closure left in this event's own slot with the hook's `$`.
489async function carry<R>($: EngineInterface, at: At, run: () => Promise<R>): Promise<R> {
490  const key = slotOf(at)
491  const mine: Slot = {}
492  const focusTurn = reserve()
493  waiting.set(key, [...(waiting.get(key) ?? []), mine])
494  let result: R
495  try {
496    result = await run()
497  } catch (error) {
498    await focusTurn(async () => undefined)
499    throw error
500  } finally {
501    const rest = (waiting.get(key) ?? []).filter(one => one !== mine)
502    if (rest.length > 0) waiting.set(key, rest)
503    else waiting.delete(key)
504  }
505  const press = mine.press
506  if (press && !isHeldBack(at)) {
507    const action = press.action
508    if (
509      action.type === 'tab' ||
510      action.type === 'fold' ||
511      action.type === 'open' ||
512      action.type === 'query' ||
513      action.type === 'window' ||
514      action.type === 'focus'
515    ) {
516      await focusTurn(() => act($, action, press.surface))
517    } else {
518      await focusTurn(async () => undefined)
519      await act($, action, press.surface)
520    }
521  } else {
522    await focusTurn(async () => undefined)
523  }
524
525  return result
526}
527
528const heldOf = (surface: RenderSurface, sel: Selection): Held[] | undefined => {
529  const mine = holds.get(surface)
530  return mine?.scope === scopeOf(sel) ? mine.keys : undefined
531}
532
533// An arrow key in a pane taller than its body scrolls it a row; Rabe moves
534// the focus to the next or previous row instead, and the pane follows the
535// focus. Past either end the scroll goes on, to show what is above or below.
536async function arrow($: EngineInterface, by: number, bodyRows: number): Promise<number | false> {
537  const { model, selection } = await look($, true)
538  const size = { columns: 80, rows: bodyRows, surface: 'terminal', hasInput: true } as const
539  const drawn = layout(paneView(model, size, selection))
540  const keys = rowKeys(hold(drawn, heldOf('terminal', selection), seatsRows(selection)).list)
541  const key = stepRow(keys, selection.selected, by)
542  if (!key) return false
543  const from = keys.indexOf(`row:${selection.selected}`)
544  const to = keys.indexOf(key)
545  const moved = from < 0 ? to + 1 : to - from
546  feed($, { type: 'step', key })
547  await focusOn($, key)
548
549  return by - moved
550}
551
552export function pane(on: On, hours = baseHours): void {
553  baseHours = hours
554  on('session.start', async ($, e, next) => {
555    await $.command.register({
556      name: 'rabe',
557      description:
558        'Show background work: agents, Codex jobs, shells, monitors, cron jobs and workflows',
559    })
560    $.clock.every(1000, () => void tick($))
561
562    return next(e)
563  })
564
565  // Each open sorts the lists once; then they hold their order (see `stable`).
566  on('command.run', { command: 'rabe' }, async $ => {
567    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
568    const { value: edits = [] } = await $.state.get({ plugin: 'rabe', key: 'edits' })
569    await $.state.set({ plugin: 'rabe', key: 'order' }, orderOf(items, edits))
570    await $.state.set({ plugin: 'rabe', key: 'window' }, windowOf(baseHours, await $.clock.now()))
571    holds.clear()
572    feed($, { type: 'reset' })
573    await $.ui.open({ id: PANE, title: 'Rabe', closeOnEscape: true })
574    $.clock.after(1500, () => void refocus($).catch(() => undefined))
575
576    return { text: 'Rabe opened.' }
577  })
578
579  on('session.end', { reason: /^/ }, async ($, e, next) => {
580    await remember($, e.resume?.id ?? e.sessionId)
581
582    return next(e)
583  }).catch((_$, e, next) => next(e))
584
585  on('prompt.submit', { origin: { kind: /^/ } }, async ($, e, next) => {
586    const { value: actions = [] } = await $.state.get({ plugin: 'rabe', key: 'actions' })
587    if (actions.length === 0) return next(e)
588    const result = await next({ ...e, context: [...(e.context ?? []), ...actions] })
589    if (!result.drop) await clearActions($, actions)
590    return result
591  }).catch((_$, e, next) => next(e))
592
593  // The ring lands where the engine's link received the move (`landingOf`),
594  // maybe not `e.element`; the selection follows it. A move that never
595  // reached the engine left the ring where it was, so the pane disarms.
596  on('ui.focus', { requestId: PANE }, ($, e, next) => {
597    const focus = async () => {
598      const { value: before = '' } = await $.state.get({ plugin: 'rabe', key: 'selected' })
599      const asked = e.element?.startsWith('row:') ? e.element.slice(4) : before
600      if (asked !== before) await $.state.set({ plugin: 'rabe', key: 'selected' }, asked)
601      const result = await next(e)
602      const landing = landingOf(next.trace, 'deny' in result && !!result.deny)
603      const landed = landing?.element
604      const selected = landed?.startsWith('row:') ? landed.slice(4) : before
605      if (selected !== asked) await $.state.set({ plugin: 'rabe', key: 'selected' }, selected)
606      landedOn = landed
607      if (landing) {
608        ringOn = landed
609        shifted = undefined
610      }
611      if (!landing) feed($, { type: 'landed', isMoved: false })
612      else if (landed) {
613        const { model, selection } = await look($, true)
614        feed($, {
615          type: 'focus',
616          byPerson: e.origin.kind === 'person',
617          key: landed,
618          requested: e.element,
619          isLiveRow: isLiveRow(model, selection, landed),
620          selected: selection.selected,
621        })
622      }
623
624      return result
625    }
626    return e.origin.kind === 'person' && ringOn && e.element !== ringOn ? sequence(focus) : focus()
627  }).catch((_$, e, next) => next(e))
628
629  // Rabe's own `$.ui.focus` from a Button's or Input's closure reaches no
630  // hook of Rabe's, so its landings would never count; calls from these do.
631  on('ui.press', { requestId: PANE }, ($, e, next) => carry($, e, () => next(e))).catch(
632    (_$, e, next) =>
633      next.called && next.error.message?.includes('no handler is held under handle')
634        ? { element: e.element }
635        : next(e),
636  )
637  on('ui.input', { requestId: PANE }, ($, e, next) => carry($, e, () => next(e))).catch(
638    (_$, e, next) =>
639      next.called && next.error.message?.includes('no handler is held under handle')
640        ? { element: e.element, value: e.value }
641        : next(e),
642  )
643
644  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
645    if (e.pointer || e.by === 0 || Math.abs(e.by) >= e.bodyRows) return next(e)
646    const remaining = await sequence(() => arrow($, e.by, e.bodyRows)).catch(() => false)
647    if (remaining === 0) return {}
648    if (typeof remaining === 'number')
649      return next(
650        e.offset > 0 && e.offset < e.contentRows - e.bodyRows
651          ? { ...e, offset: e.offset - e.by + remaining }
652          : e,
653      )
654
655    return next(e)
656  })
657
658  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
659    const ui = $.ui.resolve(e)
660    const seat = seats.get(e.surface)
661    seats.set(e.surface, { placement: e.props.placement, isFocused: e.props.isFocused })
662    if (seat?.isFocused && seat.placement !== e.props.placement) {
663      $.clock.after(500, () => void refocus($).catch(() => undefined))
664    }
665    const { model, selection: seen } = await look($, e.props.isFocused)
666    const size = bounded({
667      columns: e.props.bodyColumns,
668      rows: e.props.scroll?.bodyRows || 24,
669      surface: e.surface,
670      hasInput: 'Input' in ui,
671      ...(e.props.scroll && {
672        window: { top: e.props.scroll.offset, rows: e.props.scroll.bodyRows },
673      }),
674    })
675
676    // What x and g would act on decides whether they may.
677    const armed = paneView(model, size, { ...seen, isArmed: true, isListArmed: true })
678    const { selected } = seen
679    feed($, {
680      type: 'drawn',
681      fallback: fallbackOf(model, seen),
682      targets: targetsOf(armed),
683      selected,
684    })
685    const selection = { ...seen, isArmed: arming.isArmed, isListArmed: arming.isListArmed }
686    const drawn = arming.isArmed && arming.isListArmed ? armed : paneView(model, size, selection)
687    const before = heldOf(e.surface, selection)
688    const out = hold(layout(drawn), before, seatsRows(selection))
689    holds.set(e.surface, { scope: scopeOf(selection), keys: out.held })
690    if (before && ringOn && shifts(before, out.held, ringOn)) {
691      shifted = { key: ringOn, isAsked: false, tries: 0 }
692    }
693    if (shifted && seat && !seat.isFocused && e.props.isFocused) shifted.tries = 0
694    if (shifted && !shifted.isAsked && shifted.tries < 3 && e.props.isFocused) {
695      shifted.isAsked = true
696      shifted.tries += 1
697      const want = shifted
698      $.clock.after(REGAIN_MS, () => void regain($, want).catch(() => undefined))
699    }
700
701    return render(ui, e.surface, out.list, leave)
702  })
703}
704
hooks/ui/views/timeline.ts 319 lines
1import type { RabeWindow } from '../../../types'
2import type { RabeItem } from '../../model'
3import { nextRun } from '../../schedule'
4import { fit } from '../cells/grid'
5import { C, CHIP, type Style, tone } from '../cells/palette'
6import { clockTime, day, duration, tokens, usd } from '../format'
7import {
8  bar,
9  byStart,
10  forwarderOf,
11  glyph,
12  KIND_LABEL,
13  kept,
14  nameSpans,
15  shown,
16  stable,
17  tree,
18} from '../lists'
19import type { Drawn, Line, Model, View, ViewButton } from '../view'
20import { resumeButton } from './detail'
21import { beside, fitLine, focusOn, text } from './lines'
22
23const dim = { fg: C.dim }
24const BOX = 40
25const SIDE_COLUMNS = 90
26
27const LEGEND: [string, Style][] = [
28  ['shell', CHIP.shell],
29  ['claude', CHIP.agent],
30  ['codex', CHIP.codex],
31  ['monitor', CHIP.monitor],
32  ['scheduled run', CHIP.cron],
33  ['done', CHIP.done],
34  ['failed', CHIP.failed],
35]
36
37const COUNTS: [RabeItem['kind'], string, string][] = [
38  ['agent', 'agent', 'agents'],
39  ['codex', 'codex', 'codex'],
40  ['workflow', 'workflow', 'workflows'],
41  ['shell', 'shell', 'shells'],
42  ['monitor', 'monitor', 'monitors'],
43]
44
45function color(item: RabeItem): number {
46  if (item.status === 'failed') return C.red
47  if (item.status === 'done') return C.green
48  if (item.status === 'stopped') return C.dim
49
50  return CHIP[item.kind].fg
51}
52
53// One cell per slice of the window: a bar over the run, or for a cron job a
54// tick at each scheduled run since Rabe saw it (jitter not counted).
55// What began before the window starts with `◂` at the left edge.
56function track(item: RabeItem, start: number, now: number, width: number): string {
57  const out = cells(item, start, now, width)
58
59  return (item.startedAt ?? item.seenAt) < start ? `◂${out.slice(1)}` : out
60}
61
62function cells(item: RabeItem, start: number, now: number, width: number): string {
63  if (item.kind !== 'cron') {
64    return bar(item.startedAt ?? item.seenAt, item.endedAt ?? now, start, now, width)
65  }
66  const cells = Array<string>(width).fill(' ')
67  const schedule = item.detail.schedule
68  const span = Math.max(1, now - start)
69  const end = Math.min(now, item.endedAt ?? now)
70  let at = schedule ? nextRun(schedule, Math.max(start, item.startedAt ?? item.seenAt)) : undefined
71  for (let n = 0; at !== undefined && at <= end && n < width; n++) {
72    cells[Math.min(width - 1, Math.floor(((at - start) / span) * width))] = '█'
73    at = nextRun(schedule as string, at)
74  }
75
76  return cells.join('')
77}
78
79function treeLines(items: RabeItem[]): Line[] {
80  return [
81    {
82      spans: [
83        ['WHO STARTED WHAT?', { fg: C.bright }],
84        ['  agents and their children', dim],
85      ],
86    },
87    { spans: [['main session']] },
88    ...tree(items).map(
89      (line): Line => ({
90        spans: [
91          [line.prefix, dim],
92          [`${glyph(line.item)} `, { fg: tone(line.item) }],
93          [line.item.title],
94          [line.item.kind === 'agent' ? '' : ` (${KIND_LABEL[line.item.kind]})`, dim],
95        ],
96      }),
97    ),
98  ]
99}
100
101function previousLines(model: Model, width: number): Line[] {
102  const prev = model.previous
103  const head: Line = { spans: [[' PREVIOUS SESSION', { fg: C.bright }]] }
104  if (!prev) {
105    return [
106      head,
107      ...text('No earlier session with background work in this project.', width, dim, ' '),
108    ]
109  }
110  const counts = COUNTS.flatMap(([kind, one, many]) => {
111    const n = prev.counts[kind] ?? 0
112    return n ? [`${n} ${n === 1 ? one : many}`] : []
113  })
114  const crons = prev.counts.cron ?? 0
115  const took = prev.startedAt === undefined ? '' : ` · ${duration(prev.endedAt - prev.startedAt)}`
116  const failed = prev.failed
117    .slice(0, 2)
118    .map((title): Line => ({ spans: [[' ✗ failed', { fg: C.red }], [` ${title}`]] }))
119  const more = prev.failed.length - failed.length
120
121  const out: Line[] = [
122    head,
123    { spans: [[` this project · ended ${day(prev.endedAt, model.now)}`, dim]] },
124    { spans: [[' ✓', { fg: C.green }], [` ${counts.join(' · ') || 'no items'}`]] },
125    {
126      spans: [
127        [' session ', dim],
128        [prev.usd === undefined ? 'cost n/a' : `≈ ${usd(prev.usd)}`, { fg: C.bright }],
129        [took, dim],
130      ],
131    },
132    { spans: [[` background ${tokens(prev.tokens)} tok`, dim]] },
133    ...failed,
134    ...(more ? [{ spans: [[` +${more} more failed`, dim]] } as Line] : []),
135    ...(crons
136      ? [
137          {
138            spans: [
139              [' ⟳', { fg: C.purple }],
140              [` ${crons} cron job${crons === 1 ? '' : 's'} ended with the session`, dim],
141            ],
142          } as Line,
143        ]
144      : []),
145    { spans: [[` id ${prev.sessionId ?? 'n/a'}`, dim]] },
146  ]
147
148  return out.map(line => ({ ...line, bg: C.raised }))
149}
150
151const MIN = 60_000
152const STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440].map(one => one * MIN)
153
154// The time axis: round clock times, at most four, spaced for the window, then
155// `now`. A label that would repeat the one before it, run into it or into
156// `now` is left out.
157function axis(start: number, span: number, labelWidth: number, columns: number): string {
158  const barWidth = columns - labelWidth
159  const cells = Array<string>(columns).fill(' ')
160  const put = (x: number, value: string) => {
161    ;[...value].forEach((ch, i) => {
162      if (x + i >= 0 && x + i < columns) cells[x + i] = ch
163    })
164  }
165  const step = STEPS.find(one => span / one <= 4) ?? (STEPS.at(-1) as number)
166  // Round in local time: the zone's offset from UTC at the start.
167  const offset = new Date(start).getTimezoneOffset() * MIN
168  let last = ''
169  let end = labelWidth
170  for (let at = Math.ceil((start - offset) / step) * step + offset; at < start + span; at += step) {
171    const label = clockTime(at).slice(0, 5)
172    const x = labelWidth + Math.round((barWidth * (at - start)) / span) - 2
173    if (label === last || x < end || x + label.length > columns - 4) continue
174    put(x, label)
175    last = label
176    end = x + label.length + 1
177  }
178  if (!last && labelWidth + 5 <= columns - 4) put(labelWidth, clockTime(start).slice(0, 5))
179  put(columns - 3, 'now')
180
181  return cells.join('')
182}
183
184const HOUR = 3_600_000
185const DEFAULT_HOURS = 4
186const WIDE_HOURS = 12
187
188// The option `timelineHours`: hours, 0 for no limit; anything else the default.
189export function hoursOf(value: unknown): number {
190  const hours = typeof value === 'string' && value.trim() ? Number(value) : value
191  return typeof hours === 'number' && Number.isFinite(hours) && hours >= 0 ? hours : DEFAULT_HOURS
192}
193
194// The window `/rabe` opens with: the last `base` hours, or the whole session.
195export const windowOf = (base: number, now: number): RabeWindow => ({
196  base,
197  hours: base,
198  since: base > 0 ? now - base * HOUR : 0,
199})
200
201const steps = (base: number) => [base, ...(base < WIDE_HOURS ? [WIDE_HOURS] : []), 0]
202
203const next = (w: RabeWindow) => {
204  const list = steps(w.base)
205  return list[(list.indexOf(w.hours) + 1) % list.length] ?? 0
206}
207
208// The next window `w` steps to: base hours, 12 hours, the whole session, then
209// base again. A wider one never starts later than the one before.
210export function widen(w: RabeWindow, now: number): RabeWindow {
211  const hours = next(w)
212  if (hours === 0) return { ...w, hours, since: 0 }
213  const since = now - hours * HOUR
214
215  return { ...w, hours, since: w.hours === 0 || hours < w.hours ? since : Math.min(w.since, since) }
216}
217
218const spanText = (ms: number) => (ms < 2 * HOUR ? `${Math.round(ms / 60_000)} min` : duration(ms))
219
220// The Timeline tab: a bar per item over the window with a legend and a time
221// axis, then the tree of who started what beside (or above) the previous
222// session in this project. The rows keep the order the pane opened with.
223// Items that ended before the window (`sel.window`) fold into one line of
224// plain text, so the rows' focus order stays; `w` widens the window.
225export const timelineView: View = (model, size, sel): Drawn => {
226  const since = sel.window?.since ?? 0
227  const all = stable(kept(shown(model.items), model.removed), sel.order?.timeline, byStart)
228  const list = all.filter(item => item.status === 'running' || (item.endedAt ?? since) >= since)
229  const folded = all.length - list.length
230  const selected = list.find(item => item.id === sel.selected) ?? list[0]
231  const start = Math.max(
232    since,
233    Math.min(model.now - 60_000, ...list.map(item => item.startedAt ?? item.seenAt)),
234  )
235  const span = model.now - start
236  const labelWidth = Math.min(24, Math.floor(size.columns / 3))
237  const barWidth = size.columns - labelWidth
238  const isSide = size.columns >= SIDE_COLUMNS
239  const bars: Line[] = list.length
240    ? list.map(item => {
241        const name = fit(
242          nameSpans(item)
243            .map(([one]) => one)
244            .join(''),
245          labelWidth - 2,
246        ).trimEnd()
247        return {
248          spans: [
249            [item === selected ? '▌' : ' ', { fg: C.orange }],
250            {
251              key: `row:${item.id}`,
252              label: name,
253              action: { type: 'open', id: item.id },
254              ...(item !== selected && { dim: true }),
255            },
256            [' '.repeat(labelWidth - 2 - [...name].length + 1)],
257            [track(item, start, model.now, barWidth), { fg: color(item) }],
258          ],
259          ...(item === selected && { bg: C.selected }),
260        }
261      })
262    : folded
263      ? []
264      : [{ spans: [[' Nothing ran yet.', dim]] }]
265  const fold: Line[] = folded
266    ? [
267        {
268          spans: [
269            [
270              ` +${folded} older item${folded === 1 ? '' : 's'}, ended before ${day(since, model.now).replace(/^today /, '')}`,
271              dim,
272            ],
273          ],
274        },
275      ]
276    : []
277  // a forwarder has no row, but stays in the tree above its job
278  const who = treeLines(
279    model.items.filter(
280      item => list.includes(item) || list.some(one => forwarderOf(one, model.items) === item),
281    ),
282  )
283  const prev = previousLines(model, isSide ? BOX : size.columns)
284  const below = isSide
285    ? beside(who, size.columns - BOX - 2, [['  ']], prev, BOX)
286    : [...who, { spans: [] }, ...prev].map(line => fitLine(line, size.columns))
287  const lines: Line[] = [
288    {
289      spans: [
290        ['WHEN DID THINGS RUN?', { fg: C.bright }],
291        [`  this session, last ${spanText(span)}`, dim],
292      ],
293    },
294    { spans: LEGEND.flatMap(([label, chip]) => [[` ${label} `, chip], [' ']]) },
295    { spans: [] },
296    { spans: [[axis(start, span, labelWidth, size.columns), dim]] },
297    ...fold,
298    ...focusOn(bars, selected?.id ?? ''),
299    { spans: [] },
300  ]
301  const w = sel.window
302  const buttons: ViewButton[] =
303    w && w.base > 0
304      ? [
305          {
306            key: 'window',
307            label: `w: show ${next(w) ? `${next(w)} h` : 'all'}`,
308            hotkey: 'w',
309            action: { type: 'window' },
310          },
311        ]
312      : []
313
314  const id = model.previous?.sessionId
315  if (id) buttons.push(resumeButton(`claude --resume ${id}`, id))
316
317  return { nodes: [...lines.map(line => fitLine(line, size.columns)), ...below], buttons }
318}
319
hooks/sources/agents.ts 631 lines
1import type {
2  AgentInfo,
3  AgentStatus,
4  EngineInterface,
5  On,
6  TurnCompleteReason,
7  TurnStepResult,
8  TurnUsage,
9} from 'claude-code'
10
11import type { RabeCodexCall, RabeEdit, RabeToolUse, RabeTurn } from '../../types'
12import { companionCall, companionOutput } from '../forwarders'
13import {
14  clip,
15  type EndStatus,
16  itemId,
17  type RabeItem,
18  type RabeItemOf,
19  type RabeItemStatus,
20  type RabeTokens,
21} from '../model'
22import { claudeUsd, type Prices, parsePrices, withOverride } from '../prices'
23import { addItem, type Change, commit, endItem, pastEnd, prune, updateItem } from '../registry'
24import { changed, type Seen, shellWrites } from '../writes'
25
26type Turns = Record<string, RabeTurn[]>
27type AgentItem = RabeItemOf<'agent'>
28
29const POLL_MS = 3000
30const MAX_TURNS = 30
31const MAX_ANSWER = 16000
32const MAX_EDITS = 100
33const MAX_CALLS = 10
34const MAX_CHECKS = 20
35const LOOK_MS = 1000
36const MAX_PROMPT = 600
37const MAX_SUMMARY = 80
38const SUMMARY_FIELDS = ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description']
39const META_FIELDS = ['cwd', 'worktreePath', 'worktreeBranch', 'workflowPhase'] as const
40
41const LISTED: Record<AgentStatus, RabeItemStatus> = {
42  pending: 'running',
43  running: 'running',
44  waiting: 'running',
45  idle: 'running',
46  completed: 'done',
47  failed: 'failed',
48  killed: 'stopped',
49}
50
51const ENDED: Record<TurnCompleteReason, EndStatus> = {
52  answer: 'done',
53  aborted: 'stopped',
54  refusal: 'failed',
55  error: 'failed',
56}
57
58export function toolSummary(input: unknown): string | undefined {
59  if (!input || typeof input !== 'object') return undefined
60  const fields = input as Record<string, unknown>
61  const name = SUMMARY_FIELDS.find(one => typeof fields[one] === 'string')
62  if (!name) return undefined
63  const line = String(fields[name]).split('\n')[0] ?? ''
64
65  // Effects tells files apart by the whole path.
66  return name === 'file_path' ? line : clip(line, MAX_SUMMARY)
67}
68
69export function metaPatch(text: string): Partial<AgentItem['detail']> | undefined {
70  try {
71    const meta = JSON.parse(text) as Record<string, unknown>
72
73    return Object.fromEntries(
74      META_FIELDS.filter(name => typeof meta[name] === 'string').map(name => [name, meta[name]]),
75    )
76  } catch {
77    return undefined
78  }
79}
80
81export function metaPath(item: AgentItem, items: RabeItem[]): string | undefined {
82  const parent = items.find(one => one.id === item.parentId)
83  if (parent?.kind === 'workflow') {
84    const folder =
85      parent.detail.transcriptDir ??
86      parent.detail.scriptPath?.replace(
87        /\/workflows\/scripts\/[^/]+$/,
88        `/subagents/workflows/${parent.detail.runId}`,
89      )
90    if (folder && folder !== parent.detail.scriptPath)
91      return `${folder}/agent-${item.detail.agentId}.meta.json`
92  }
93
94  return item.detail.transcriptPath?.replace(/\.jsonl$/, '.meta.json')
95}
96
97export function agentTranscript(path: string, agentId: string): string | undefined {
98  if (path.endsWith(`/agent-${agentId}.jsonl`)) return path
99  if (!path.endsWith('.jsonl')) return undefined
100
101  return `${path.slice(0, -'.jsonl'.length)}/subagents/agent-${agentId}.jsonl`
102}
103
104function workflowTranscript(
105  path: string | undefined,
106  agentId: string,
107  runId: string,
108): string | undefined {
109  const generic = `/subagents/agent-${agentId}.jsonl`
110  return path?.endsWith(generic)
111    ? `${path.slice(0, -generic.length)}/subagents/workflows/${runId}/agent-${agentId}.jsonl`
112    : path
113}
114
115// The file an Edit or Write changed: the engine ran it (a result, no error) and
116// did not only stage it for review. The path comes from the result when it has one.
117export function changedFile(
118  answer: { result?: unknown; isError?: boolean },
119  input: string,
120): string | undefined {
121  if (answer.isError || !answer.result || typeof answer.result !== 'object') return undefined
122  const result = answer.result as { filePath?: unknown; staged?: unknown }
123  if (result.staged === true) return undefined
124
125  return typeof result.filePath === 'string' ? result.filePath : input
126}
127
128// A Bash call that ran to its end: a result, no error, not interrupted and not
129// moved to the background.
130export function ranBash(answer: { result?: unknown; isError?: boolean }): boolean {
131  if (answer.isError || !answer.result || typeof answer.result !== 'object') return false
132  const result = answer.result as { interrupted?: unknown; backgroundTaskId?: unknown }
133
134  return result.interrupted !== true && result.backgroundTaskId === undefined
135}
136
137// Changes to files of an agent; its newest MAX_EDITS are kept.
138function edited(items: RabeItem[], id: string, changes: RabeEdit[]): RabeItem[] {
139  const agent = asAgent(items, id)
140  if (!agent) return items
141  const edits = [...(agent.detail.edits ?? []), ...changes].slice(-MAX_EDITS)
142
143  return updateItem(items, id, { detail: { edits } })
144}
145
146// The companion calls of an agent: a new one, or what its answer told.
147function called(
148  items: RabeItem[],
149  id: string,
150  at: number,
151  patch: Partial<RabeCodexCall>,
152): RabeItem[] {
153  const agent = asAgent(items, id)
154  if (!agent) return items
155  const calls = agent.detail.codexCalls ?? []
156  const codexCalls = calls.some(one => one.at === at)
157    ? calls.map(one => (one.at === at ? { ...one, ...patch } : one))
158    : [...calls, { at, ...patch } as RabeCodexCall].slice(-MAX_CALLS)
159
160  return updateItem(items, id, { detail: { codexCalls } })
161}
162
163// A call that went to the background still runs: it keeps no end. A failed
164// run still printed its thread.
165function answered(answer: { result?: unknown }, now: number): Partial<RabeCodexCall> {
166  const result = (answer.result ?? {}) as Record<string, unknown>
167  if (result.backgroundTaskId) return {}
168
169  return { endedAt: now, ...companionOutput(`${result.stdout ?? ''}\n${result.stderr ?? ''}`) }
170}
171
172export function addTurn(turns: Turns, id: string, turn: RabeTurn): Turns {
173  return { ...turns, [id]: [...(turns[id] ?? []), turn].slice(-MAX_TURNS) }
174}
175
176function addUsage(tokens: RabeTokens | undefined, usage: TurnUsage): RabeTokens {
177  return {
178    input:
179      (tokens?.input ?? 0) +
180      usage.input_tokens +
181      usage.cache_creation_input_tokens +
182      usage.cache_read_input_tokens,
183    output: (tokens?.output ?? 0) + usage.output_tokens,
184    cached: (tokens?.cached ?? 0) + usage.cache_read_input_tokens,
185  }
186}
187
188// Each step adds its own price, so a long-context step bills at its own tier. The
189// sum is known only while every step since the start was: an agent Rabe did not
190// see start, or one step without a price, stays n/a.
191function addCost(item: AgentItem, usd: number | undefined): number | undefined {
192  if (usd === undefined || item.startedAt === undefined) return undefined
193  if (!item.tokens) return usd
194
195  return item.costUsd === undefined ? undefined : item.costUsd + usd
196}
197
198function asAgent(items: RabeItem[], id: string): AgentItem | undefined {
199  const item = items.find(one => one.id === id)
200
201  return item?.kind === 'agent' ? item : undefined
202}
203
204// Statuses of a turn in flight: an ended agent in one was resumed. `idle` is
205// not one: a teammate is idle after each turn, which already ended its item.
206const RESUMED: AgentStatus[] = ['pending', 'running', 'waiting']
207
208// An ended agent Rabe holds no item for takes `end` (see `pastEnd`), and none
209// is added when `end` is undefined, so a poll never gives one a new recency.
210function listed(
211  items: RabeItem[],
212  info: AgentInfo,
213  now: number,
214  end: number | undefined,
215): RabeItem[] {
216  const id = itemId('agent', info.id)
217  const status = LISTED[info.status] ?? 'running'
218  const held = asAgent(items, id)
219  if (!held) {
220    if (status !== 'running' && end === undefined) return items
221    return addItem(
222      items,
223      {
224        id,
225        kind: 'agent',
226        title: info.name || info.description || info.type,
227        status,
228        ...(status !== 'running' && { endedAt: end }),
229        parentId: info.parentId ? itemId('agent', info.parentId) : undefined,
230        detail: {
231          agentId: info.id,
232          ...(info.name && { name: info.name }),
233          type: info.type,
234          description: info.description,
235        },
236      },
237      now,
238    )
239  }
240  const named = info.name
241    ? updateItem(items, id, { title: info.name, detail: { name: info.name } })
242    : items
243  if (held.status !== 'running' && RESUMED.includes(info.status)) {
244    return updateItem(named, id, { status: 'running', endedAt: undefined })
245  }
246
247  return status === 'running' ? named : endItem(named, id, status, now)
248}
249
250function stepped(
251  items: RabeItem[],
252  id: string,
253  result: TurnStepResult,
254  tools: RabeToolUse[],
255  now: number,
256  usd: number | undefined,
257): RabeItem[] {
258  const item = asAgent(items, id)
259  if (!item) return items
260  const last = tools.at(-1)
261
262  return updateItem(items, id, {
263    status: 'running',
264    endedAt: undefined,
265    tokens: result.usage ? addUsage(item.tokens, result.usage) : item.tokens,
266    costUsd: result.usage ? addCost(item, usd) : item.costUsd,
267    detail: {
268      model: result.usage?.model ?? item.detail.model,
269      toolCount: (item.detail.toolCount ?? 0) + tools.length,
270      lastTool: last?.name ?? item.detail.lastTool,
271      lastToolAt: last ? now : item.detail.lastToolAt,
272    },
273  })
274}
275
276async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
277  for (;;) {
278    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
279    const next = commit(value, change)
280    if (next === undefined) return
281    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
282      ifVersion: version,
283    })
284    if (isSet) return forget($, next.dropped)
285  }
286}
287
288// Drops the lines and turns of the items a write dropped.
289async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
290  if (dropped.length === 0) return
291  for (;;) {
292    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
293    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
294    const next = prune(value, dropped, items)
295    if (next === value) break
296    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
297    if (lines.isSet) break
298  }
299  for (;;) {
300    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
301    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
302    const next = prune(value, dropped, items)
303    if (next === value) return
304    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
305    if (turns.isSet) return
306  }
307}
308
309async function writeTurns(
310  $: Pick<EngineInterface, 'state'>,
311  change: (turns: Turns) => Turns,
312): Promise<void> {
313  for (;;) {
314    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
315    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'turns' }, change(value), {
316      ifVersion: version,
317    })
318    if (isSet) return
319  }
320}
321
322// The main session's changes go to `rabe.edits`, an agent's to its item.
323async function record(
324  $: EngineInterface,
325  agentId: string | undefined,
326  changes: RabeEdit[],
327): Promise<void> {
328  if (changes.length === 0) return
329  if (agentId) {
330    const id = itemId('agent', agentId)
331    return write($, items => edited(items, id, changes))
332  }
333  for (;;) {
334    const { value = [], version } = await $.state.get({ plugin: 'rabe', key: 'edits' })
335    const next = [...value, ...changes].slice(-MAX_EDITS)
336    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'edits' }, next, {
337      ifVersion: version,
338    })
339    if (isSet) return
340  }
341}
342
343type Listings = Map<string, Promise<Set<string> | undefined>>
344
345// A path whose stat failed is missing only when its folder lists without it,
346// or that folder is missing itself; error text is never read.
347function hasName(names: Set<string>, name: string): boolean {
348  if (names.has(name)) return true
349  const fold = (one: string) => one.normalize('NFC').toLowerCase()
350  const wanted = fold(name)
351  for (const one of names) if (fold(one) === wanted) return true
352
353  return false
354}
355
356async function isMissing($: EngineInterface, path: string, lists: Listings): Promise<boolean> {
357  const cut = path.lastIndexOf('/')
358  if (cut < 0 || path === '/') return false
359  const folder = path.slice(0, cut) || '/'
360  let names = lists.get(folder)
361  if (!names) {
362    names = $.fs.list(folder).then(
363      entries => new Set(entries.map(entry => entry.name)),
364      () => undefined,
365    )
366    lists.set(folder, names)
367  }
368  const listed = await names
369  // A name the folder lists in another case or Unicode form may be this file (macOS, Windows).
370  if (listed) return !hasName(listed, path.slice(cut + 1))
371
372  return folder !== '/' && isMissing($, folder, lists)
373}
374
375// What is on disk at each path, or undefined when the looks outlast LOOK_MS.
376async function look($: EngineInterface, paths: string[]): Promise<Seen[] | undefined> {
377  const stop = new AbortController()
378  const late = $.clock.sleep(LOOK_MS, { signal: stop.signal }).then(
379    () => undefined,
380    () => undefined,
381  )
382  const lists: Listings = new Map()
383  const seen = Promise.all(
384    paths.map(path =>
385      $.fs.stat(path).then(
386        (stat): Seen => stat,
387        async (): Promise<Seen> => ((await isMissing($, path, lists)) ? 'none' : undefined),
388      ),
389    ),
390  )
391  try {
392    return await Promise.race([seen, late])
393  } finally {
394    stop.abort()
395  }
396}
397
398type Looked = { paths: string[]; seen: Seen[] }
399
400async function beforeBash(
401  $: EngineInterface,
402  command: string,
403  agentId: string | undefined,
404): Promise<Looked | undefined> {
405  const home = await $.env.get('HOME')
406  let candidates = shellWrites(command, undefined, home)
407  if (candidates.some(one => !one.path.startsWith('/'))) {
408    let cwd: string | undefined
409    if (agentId) {
410      const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
411      cwd = asAgent(items, itemId('agent', agentId))?.detail.cwd
412    } else {
413      cwd = await $.session.cwd().catch(() => undefined)
414    }
415    candidates = shellWrites(command, cwd, home)
416  }
417  const paths = candidates.map(one => one.path).filter(path => path.startsWith('/'))
418  if (paths.length === 0 || paths.length > MAX_CHECKS) return undefined
419  const seen = await look($, paths)
420
421  return seen && { paths, seen }
422}
423
424async function afterBash($: EngineInterface, agentId: string | undefined, before: Looked) {
425  const after = await look($, before.paths)
426  if (!after) return
427  const at = await $.clock.now()
428  const changes = before.paths.flatMap((path, n): RabeEdit[] => {
429    const change = changed(before.seen[n], after[n])
430    if (!change) return []
431    return [{ path, at, via: 'shell', ...(change === 'delete' && { change }) }]
432  })
433  await record($, agentId, changes)
434}
435
436async function readMeta($: EngineInterface, id: string): Promise<void> {
437  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
438  const item = asAgent(items, id)
439  const path = item && metaPath(item, items)
440  if (!path) return
441  const text = await $.fs.read(path).catch(() => undefined)
442  const patch = typeof text === 'string' ? metaPatch(text) : undefined
443  if (patch) await write($, list => updateItem(list, id, { detail: patch }))
444}
445
446async function refresh($: EngineInterface): Promise<void> {
447  const agents = await $.agent.list().catch(() => [])
448  const now = await $.clock.now()
449  await write($, items => {
450    const end = pastEnd(items, now)
451
452    return agents.reduce((list, info) => listed(list, info, now, end), items)
453  })
454  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
455  for (const item of items) {
456    if (item.kind !== 'agent') continue
457    const workflow = items.find(parent => parent.id === item.parentId && parent.kind === 'workflow')
458    if (
459      item.status === 'running' ||
460      (workflow?.kind === 'workflow' &&
461        workflow.detail.phases?.length &&
462        item.detail.workflowPhase === undefined)
463    )
464      await readMeta($, item.id)
465  }
466}
467
468// The built-in table, or with the user's file (`pricesFile`) its rows first. A
469// file that is set but cannot be read gives no prices: costs show n/a.
470async function prices($: EngineInterface, file: string): Promise<Prices> {
471  const read = (path: string) => $.fs.read(path).then(text => parsePrices(String(text)))
472  const built = await read(`${$.plugin.root}/data/prices.csv`).catch(() => [])
473  if (!file) return built
474  const home = file.startsWith('~/') && (await $.env.get('HOME').catch(() => undefined))
475  const own = await read(home ? `${home}${file.slice(1)}` : file).catch(() => undefined)
476
477  return own ? withOverride(built, own) : []
478}
479
480async function recordStep(
481  $: EngineInterface,
482  agentId: string,
483  result: TurnStepResult,
484  file: string,
485) {
486  const id = itemId('agent', agentId)
487  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
488  if (!asAgent(items, id)) return
489  const now = await $.clock.now()
490  const tools = result.toolUses.map(use => ({ name: use.name, summary: toolSummary(use.input) }))
491  const usage = result.usage
492  const usd = usage ? claudeUsd(await prices($, file), usage, usage.model) : undefined
493  await write($, list => stepped(list, id, result, tools, now, usd))
494  if (!result.answer && tools.length === 0) return
495  await writeTurns($, turns => {
496    const index = (turns[id]?.at(-1)?.index ?? 0) + 1
497    const turn = { index, at: now, text: clip(result.answer, MAX_ANSWER), tools }
498
499    return addTurn(turns, id, turn)
500  })
501}
502
503export function agents(on: On, file: string): void {
504  on('session.start', { cwd: /^/ }, async ($, e, next) => {
505    $.clock.every(POLL_MS, () => refresh($))
506    await refresh($)
507
508    return next(e)
509  })
510
511  on('agent.spawn', { subagentType: /^/ }, async ($, e, next) => {
512    const result = await next(e)
513    const agentId = result.agentId
514    if (!agentId) return result
515    const now = await $.clock.now()
516    const runId = e.workflow?.runId
517    const parent = e.parentAgentId ? itemId('agent', e.parentAgentId) : undefined
518    const id = itemId('agent', agentId)
519    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
520    const transcriptPath = runId
521      ? workflowTranscript(asAgent(items, id)?.detail.transcriptPath, agentId, runId)
522      : undefined
523    await write($, items =>
524      addItem(
525        items,
526        {
527          id: itemId('agent', agentId),
528          kind: 'agent',
529          title: e.description || e.subagentType,
530          status: 'running',
531          startedAt: now,
532          parentId: runId ? itemId('workflow', runId) : parent,
533          detail: {
534            agentId,
535            ...(transcriptPath && { transcriptPath }),
536            type: e.subagentType,
537            model: result.model,
538            description: e.description,
539            prompt: clip(e.prompt, MAX_PROMPT),
540            workflowIndex: e.workflow?.agentIndex,
541          },
542        },
543        now,
544      ),
545    )
546
547    return result
548  }).catch((_$, e, next) => next(e))
549
550  on('classic.SubagentStart', { agent_id: /^/ }, async ($, e, next) => {
551    const transcriptPath = agentTranscript(e.transcript_path, e.agent_id)
552    const detail = {
553      agentId: e.agent_id,
554      ...(transcriptPath && { transcriptPath }),
555      ...(e.cwd && { cwd: e.cwd }),
556    }
557    const id = itemId('agent', e.agent_id)
558    const now = await $.clock.now()
559    await write($, items =>
560      items.some(item => item.id === id)
561        ? updateItem(items, id, { detail })
562        : addItem(
563            items,
564            { id, kind: 'agent', title: e.agent_type, status: 'running', startedAt: now, detail },
565            now,
566          ),
567    )
568
569    return next(e)
570  }).catch((_$, e, next) => next(e))
571
572  on('turn.step', { agentId: /^/ }, async function* ($, e, next) {
573    const result = yield* next(e)
574    if (e.agentId) await recordStep($, e.agentId, result, file)
575
576    return result
577  })
578
579  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
580    const answer = await next(e)
581    try {
582      const path = (e.tool === 'Edit' || e.tool === 'Write') && changedFile(answer, e.file_path)
583      if (path) {
584        const via = e.tool === 'Edit' ? 'edit' : 'write'
585        await record($, e.agentId, [{ path, at: await $.clock.now(), via }])
586      }
587    } catch {}
588
589    return answer
590  })
591
592  // The command line proposes files; a look on disk before and after decides.
593  // A call of the Codex companion is kept on its agent, so the Codex source can
594  // link the job it starts (see forwarders.ts).
595  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
596    const before =
597      e.tool === 'Bash'
598        ? await beforeBash($, e.command, e.agentId).catch(() => undefined)
599        : undefined
600    const call = e.tool === 'Bash' && e.agentId ? companionCall(e.command) : undefined
601    const id = itemId('agent', e.agentId ?? '')
602    const at = call && (await $.clock.now().catch(() => undefined))
603    if (call && at !== undefined) {
604      await write($, items => called(items, id, at, call)).catch(() => undefined)
605    }
606    const answer = await next(e)
607    try {
608      if (at !== undefined) {
609        const patch = answered(answer, await $.clock.now())
610        await write($, items => called(items, id, at, patch))
611      }
612    } catch {}
613    try {
614      if (before && ranBash(answer)) await afterBash($, e.agentId, before)
615    } catch {}
616
617    return answer
618  })
619
620  on('turn.complete', { agentId: /^/ }, async ($, e, next) => {
621    const result = await next(e)
622    const agentId = e.agentId
623    if (!agentId) return result
624    const now = await $.clock.now()
625    await write($, items => endItem(items, itemId('agent', agentId), ENDED[e.reason], now))
626    await readMeta($, itemId('agent', agentId))
627
628    return result
629  })
630}
631
hooks/sources/codex.ts 636 lines
1import type { EngineInterface, FsStat, On } from 'claude-code'
2
3import type { RabeCodexStep, RabeEdit, RabeTokens } from '../../types'
4import { linkForwarders } from '../forwarders'
5import {
6  clip,
7  type EndStatus,
8  itemId,
9  type NewItem,
10  type RabeItem,
11  type RabeItemOf,
12} from '../model'
13import { type CodexRequest, codexUsd, type Prices, parsePrices, withOverride } from '../prices'
14import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
15import { shellWrites } from '../writes'
16
17const POLL_MS = 2000
18const CODEX_DATA = 'plugins/data/codex-openai-codex'
19const CODEX_PLUGIN = 'codex@openai-codex'
20const MAX_READ = 4 * 1024 * 1024
21const TAIL_LINES = 200
22const RUN_MS = 5000
23const MAX_STEPS = 50
24const MAX_TEXT = 300
25const MAX_EDITS = 100
26const CHANGES = ['add', 'update', 'delete'] as const
27const DAY = 24 * 60 * 60 * 1000
28const MAX_CHECKS = 20
29const LATE_MS = 2000
30const LOOK_MS = 1000
31
32type Rec = Record<string, unknown>
33
34export type CodexJob = Rec & { id: string }
35
36export type Rollout = {
37  model?: string
38  effort?: string
39  sandbox?: string
40  prompt?: string
41  tokens?: RabeTokens
42  // One per model request, from `last_token_usage`, with the model then in use.
43  requests: CodexRequest[]
44  commandCount: number
45  steps: RabeCodexStep[]
46  edits?: RabeEdit[]
47  checks?: Check[]
48}
49
50// A file a shell command may have written while it ran, from `from` to `to`.
51export type Check = { path: string; from: number; to: number }
52
53export type CodexSession = {
54  path?: string
55  updatedAt?: number
56  isMissing?: boolean
57  isPartial?: boolean
58  rollout?: Rollout
59  // Set with `rollout`: the job's dollars, undefined when not known.
60  usd?: number
61}
62
63function rec(value: unknown): Rec {
64  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Rec) : {}
65}
66
67function list(value: unknown): unknown[] {
68  return Array.isArray(value) ? value : []
69}
70
71function str(value: unknown): string | undefined {
72  return typeof value === 'string' && value !== '' ? value : undefined
73}
74
75function num(value: unknown): number | undefined {
76  return typeof value === 'number' ? value : undefined
77}
78
79function parseJson(text: string): Rec | undefined {
80  try {
81    const value = JSON.parse(text)
82    return value && typeof value === 'object' ? (value as Rec) : undefined
83  } catch {
84    return undefined
85  }
86}
87
88function defined<T extends object>(value: T): T {
89  return Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined)) as T
90}
91
92function texts(content: unknown, type: string): string {
93  return list(content)
94    .map(rec)
95    .filter(part => part.type === type)
96    .map(part => str(part.text) ?? '')
97    .join('')
98}
99
100function shellLine(command: unknown): string {
101  const argv = list(command).map(String)
102  return argv.length === 3 && argv[1] === '-lc' ? (argv[2] as string) : argv.join(' ')
103}
104
105function pendingCommand(input: unknown): string {
106  const text = String(input ?? '')
107  const quoted = /cmd:\s*("(?:[^"\\]|\\.)*")/.exec(text)?.[1]
108  if (quoted) {
109    try {
110      return JSON.parse(quoted)
111    } catch {}
112  }
113
114  return text.split('\n')[0] ?? ''
115}
116
117// Codex writes the cwd as a `file://` URL.
118function folder(cwd: unknown): string | undefined {
119  const text = str(cwd)
120  if (!text?.startsWith('file://')) return text
121  try {
122    return text.slice('file://'.length).split('/').map(decodeURIComponent).join('/')
123  } catch {
124    return undefined
125  }
126}
127
128// The absolute files a command line may write; a command with more is skipped.
129function commandChecks(item: Rec, from: number, to: number, home?: string): Check[] {
130  const paths = shellWrites(shellLine(item.command), folder(item.cwd), home)
131    .filter(one => !one.isDeleted && one.path.startsWith('/'))
132    .map(one => one.path)
133
134  return paths.length > MAX_CHECKS ? [] : paths.map(path => ({ path, from, to }))
135}
136
137function lineCount(output: unknown): number | undefined {
138  const text = str(output)
139
140  return text === undefined ? undefined : text.replace(/\n$/, '').split('\n').length
141}
142
143// The files a completed FileChange changed; a moved file leaves its old path.
144function fileChanges(changes: unknown, at: number): RabeEdit[] {
145  return Object.entries(rec(changes)).flatMap(([path, value]) => {
146    const change = CHANGES.find(one => one === rec(value).type)
147    const moved = str(rec(value).move_path)
148    if (!change) return []
149    const edit = (one: string, kind: RabeEdit['change']) =>
150      ({ path: one, at, via: 'codex', change: kind }) as RabeEdit
151
152    return moved ? [edit(path, 'delete'), edit(moved, 'add')] : [edit(path, change)]
153  })
154}
155
156// A count that is missing makes the request one without a model: no price.
157function request(value: unknown, model: string | undefined): CodexRequest {
158  const usage = rec(value)
159  const counts = [
160    usage.input_tokens,
161    usage.cached_input_tokens,
162    usage.cache_write_input_tokens,
163    usage.output_tokens,
164  ].map(num)
165  const [input = 0, cached = 0, write = 0, output = 0] = counts
166
167  return { ...(!counts.includes(undefined) && { model }), input, cached, write, output }
168}
169
170// `since` is the job's start: a job that resumed a thread finds the turns of
171// the jobs before it in the same file, and counts none of them. `until` is a
172// finished job's end: the turns of jobs that resumed the thread later count
173// nothing either.
174export function parseRollout(text: string, home?: string, since?: number, until?: number): Rollout {
175  const out: Rollout = { requests: [], commandCount: 0, steps: [] }
176  let total = ''
177  // the thread's total when the job started; undefined while none was seen
178  let base: Rec | undefined
179  let isResumed = false
180  const edits: RabeEdit[] = []
181  const checks: Check[] = []
182  const pending = new Map<string, RabeCodexStep>()
183  for (const raw of text.split('\n')) {
184    const record = parseJson(raw)
185    if (!record) continue
186    const payload = rec(record.payload)
187    if (until !== undefined && Date.parse(str(record.timestamp) ?? '') > until) continue
188    if (record.type === 'turn_context') {
189      const settings = rec(rec(payload.collaboration_mode).settings)
190      out.model = str(payload.model) ?? str(settings.model) ?? out.model
191      out.effort = str(payload.effort) ?? str(settings.reasoning_effort) ?? out.effort
192      out.sandbox = str(rec(payload.sandbox_policy).type) ?? out.sandbox
193    } else if (since !== undefined && Date.parse(str(record.timestamp) ?? '') < since) {
194      isResumed = true
195      const info = rec(payload.info)
196      if (payload.type === 'token_count' && Object.keys(info).length) {
197        base = rec(info.total_token_usage)
198        total = JSON.stringify(base)
199      }
200    } else if (record.type === 'event_msg' && payload.type === 'token_count') {
201      const info = rec(payload.info)
202      const usage = rec(info.total_token_usage)
203      // the total counts the whole thread: a resumed job's share needs the total it started at
204      const own = (key: string) => {
205        const now = num(usage[key])
206        if (!isResumed || now === undefined) return now
207        const before = num(base?.[key])
208        return before === undefined ? undefined : now - before
209      }
210      const input = own('input_tokens')
211      const output = own('output_tokens')
212      if (input !== undefined && output !== undefined) {
213        out.tokens = defined({ input, output, cached: own('cached_input_tokens') })
214      }
215      // a record without counts (rate limits only) or that repeats the total adds no request
216      const key = JSON.stringify(usage)
217      if (Object.keys(info).length) {
218        if (key !== total) out.requests.push(request(info.last_token_usage, out.model))
219        total = key
220      }
221    } else if (record.type === 'event_msg' && payload.type === 'item_completed') {
222      const item = rec(payload.item)
223      const at = num(payload.completed_at_ms) ?? (Date.parse(str(record.timestamp) ?? '') || 0)
224      if (item.type === 'UserMessage') out.prompt ??= str(texts(item.content, 'text'))
225      if (item.type === 'FileChange' && item.status === 'completed') {
226        edits.push(...fileChanges(item.changes, at))
227      }
228      const from = num(payload.started_at_ms)
229      if (item.type === 'CommandExecution' && item.exit_code === 0 && from !== undefined) {
230        checks.push(...commandChecks(item, from, at, home))
231      }
232      if (item.type === 'CommandExecution') {
233        out.commandCount += 1
234        out.steps.push(
235          defined({
236            kind: 'command' as const,
237            text: clip(shellLine(item.command), MAX_TEXT),
238            exitCode: num(item.exit_code),
239            lines: lineCount(item.aggregated_output),
240          }),
241        )
242      }
243    } else if (record.type === 'response_item') {
244      if (payload.type === 'message' && payload.role === 'assistant') {
245        const message = texts(payload.content, 'output_text')
246        if (message) out.steps.push({ kind: 'message', text: clip(message, MAX_TEXT) })
247      } else if (payload.type === 'reasoning') {
248        const summary = list(payload.summary)
249          .map(part => str(rec(part).text))
250          .filter(Boolean)
251          .join('\n')
252        if (summary) out.steps.push({ kind: 'reasoning', text: clip(summary, MAX_TEXT) })
253      } else if (payload.type === 'custom_tool_call') {
254        const step: RabeCodexStep = {
255          kind: 'command',
256          text: clip(pendingCommand(payload.input), MAX_TEXT),
257          isRunning: true,
258        }
259        pending.set(String(payload.call_id), step)
260        out.steps.push(step)
261      } else if (payload.type === 'custom_tool_call_output') {
262        const step = pending.get(String(payload.call_id))
263        out.steps = out.steps.filter(one => one !== step)
264      }
265    }
266  }
267  out.steps = out.steps.slice(-MAX_STEPS)
268  if (edits.length) out.edits = edits.slice(-MAX_EDITS)
269  if (checks.length) out.checks = checks.slice(-MAX_EDITS)
270
271  return defined(out)
272}
273
274export function jobEnd(status: unknown): EndStatus | undefined {
275  if (status === 'completed') return 'done'
276  if (status === 'failed') return 'failed'
277  if (status === 'cancelled') return 'stopped'
278
279  return undefined
280}
281
282function jobStart(job: CodexJob): number | undefined {
283  return Date.parse(str(job.startedAt) ?? str(job.createdAt) ?? '') || undefined
284}
285
286// a running job, or a finished one without its time, has no end to cut at
287function jobStop(job: CodexJob): number | undefined {
288  return (jobEnd(job.status) && Date.parse(str(job.completedAt) ?? '')) || undefined
289}
290
291export function codexItem(job: CodexJob, session: CodexSession | undefined): NewItem {
292  const rollout = session?.rollout
293  // an unchanged session file is not parsed again; the held item keeps what it gave
294  const request = session?.path && !rollout ? {} : rec(job.request)
295
296  const item = defined({
297    id: itemId('codex', job.id),
298    kind: 'codex' as const,
299    title: str(String(job.summary ?? '').split('\n')[0]) ?? str(job.title) ?? 'Codex job',
300    status: 'running' as const,
301    startedAt: jobStart(job),
302    tokens: rollout?.tokens,
303    detail: defined({
304      jobId: job.id,
305      jobKind: str(job.kindLabel),
306      threadId: str(job.threadId),
307      model: rollout?.model ?? str(request.model),
308      effort: rollout?.effort ?? str(request.effort),
309      sandbox: rollout?.sandbox,
310      prompt: str(request.prompt) ?? rollout?.prompt,
311      workspaceRoot: str(job.workspaceRoot),
312      logPath: str(job.logFile),
313      sessionPath: session?.path,
314      sessionUpdatedAt: session?.updatedAt,
315      isSessionMissing: session?.isMissing,
316      isSessionPartial: session?.isPartial,
317      commandCount: rollout?.commandCount,
318      steps: rollout?.steps,
319      edits: rollout?.edits,
320    }),
321  })
322
323  // only an unchanged file keeps the cost; a parse, a gone or unreadable file sets it, also to unknown
324  const isUnchanged = session?.updatedAt !== undefined && !rollout
325
326  return session && !isUnchanged ? { ...item, costUsd: session.usd } : item
327}
328
329async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
330  for (;;) {
331    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
332    const next = commit(value, change)
333    if (next === undefined) return
334    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
335      ifVersion: version,
336    })
337    if (isSet) return forget($, next.dropped)
338  }
339}
340
341// Drops the lines and turns of the items a write dropped.
342async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
343  if (dropped.length === 0) return
344  for (;;) {
345    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
346    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
347    const next = prune(value, dropped, items)
348    if (next === value) break
349    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
350    if (lines.isSet) break
351  }
352  for (;;) {
353    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
354    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
355    const next = prune(value, dropped, items)
356    if (next === value) return
357    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
358    if (turns.isSet) return
359  }
360}
361
362async function homes($: EngineInterface): Promise<{ home: string; claude: string; codex: string }> {
363  const home = (await $.env.get('HOME')) ?? ''
364
365  return {
366    home,
367    claude: (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`,
368    codex: (await $.env.get('CODEX_HOME')) ?? `${home}/.codex`,
369  }
370}
371
372async function findRollout(
373  $: EngineInterface,
374  codexHome: string,
375  threadId: string,
376  startedAt: number,
377): Promise<string | undefined> {
378  for (const offset of [0, -DAY, DAY]) {
379    const day = new Date(startedAt + offset).toISOString().slice(0, 10).replaceAll('-', '/')
380    const dir = `${codexHome}/sessions/${day}`
381    const entries = await $.fs.list(dir).catch(() => [])
382    const found = entries.find(entry => entry.name.endsWith(`-${threadId}.jsonl`))
383    if (found) return `${dir}/${found.name}`
384  }
385
386  return undefined
387}
388
389// One poll's shell checks: each path is statted once, and all share one deadline.
390type Looks = {
391  asked: Map<string, Promise<void>>
392  known: Map<string, FsStat>
393  late?: Promise<void>
394  stop: AbortController
395}
396
397function statOnce($: EngineInterface, looks: Looks, path: string): Promise<void> {
398  let asked = looks.asked.get(path)
399  if (!asked) {
400    asked = $.fs.stat(path).then(
401      stat => {
402        looks.known.set(path, stat)
403      },
404      () => undefined,
405    )
406    looks.asked.set(path, asked)
407  }
408
409  return asked
410}
411
412// A shell write counts when its file was changed while the command ran; a
413// delete leaves nothing to look at, so only FileChange records give those.
414async function confirmed($: EngineInterface, rollout: Rollout, looks: Looks): Promise<Rollout> {
415  const { checks, ...rest } = rollout
416  if (!checks) return rollout
417  looks.late ??= $.clock.sleep(LOOK_MS, { signal: looks.stop.signal }).catch(() => undefined)
418  await Promise.race([Promise.all(checks.map(one => statOnce($, looks, one.path))), looks.late])
419  const written = checks.flatMap(({ path, from, to }): RabeEdit[] => {
420    const stat = looks.known.get(path)
421    const isWritten = stat?.kind === 'file' && stat.mtimeMs >= from && stat.mtimeMs <= to + LATE_MS
422
423    return isWritten ? [{ path, at: to, via: 'shell' }] : []
424  })
425  const edits = [...(rest.edits ?? []), ...written].sort((a, b) => a.at - b.at).slice(-MAX_EDITS)
426
427  return edits.length ? { ...rest, edits } : rest
428}
429
430// The built-in table, or with the user's file (`pricesFile`) its rows first. A
431// file that is set but cannot be read gives no prices: costs show n/a.
432async function prices($: EngineInterface, file: string): Promise<Prices> {
433  const read = (path: string) => $.fs.read(path).then(text => parsePrices(String(text)))
434  const built = await read(`${$.plugin.root}/data/prices.csv`).catch(() => [])
435  if (!file) return built
436  const home = file.startsWith('~/') && (await $.env.get('HOME').catch(() => undefined))
437  const own = await read(home ? `${home}${file.slice(1)}` : file).catch(() => undefined)
438
439  return own ? withOverride(built, own) : []
440}
441
442async function readSession(
443  $: EngineInterface,
444  codexHome: string,
445  job: CodexJob,
446  held: RabeItemOf<'codex'> | undefined,
447  looks: Looks,
448  file: string,
449): Promise<CodexSession | undefined> {
450  const threadId = str(job.threadId)
451  if (!threadId) return undefined
452  const path =
453    held?.detail.sessionPath ??
454    (await findRollout($, codexHome, threadId, jobStart(job) ?? (await $.clock.now())))
455  const stat = path ? await $.fs.stat(path).catch(() => undefined) : undefined
456  if (!path || !stat) {
457    if (jobEnd(job.status)) return { isMissing: true }
458
459    // a file seen before and gone now: no new fields, but the cost is no longer known
460    return held?.detail.sessionPath ? { path } : undefined
461  }
462  const updatedAt = stat.mtimeMs
463  if (updatedAt === held?.detail.sessionUpdatedAt) return { path, updatedAt }
464  // no updatedAt on failure, so the next poll tries again
465  const home = await $.env.get('HOME')
466  if (stat.size <= MAX_READ) {
467    const text = await $.fs.read(path).catch(() => undefined)
468    if (text === undefined) return { path }
469    const rollout = await confirmed(
470      $,
471      parseRollout(String(text), home, jobStart(job), jobStop(job)),
472      looks,
473    )
474
475    return { path, updatedAt, rollout, usd: codexUsd(await prices($, file), rollout.requests) }
476  }
477  const head = await $.process
478    .run(['grep', '-m', '2', '-E', '"type":"(turn_context|UserMessage)"', '--', path], {
479      timeoutMs: RUN_MS,
480    })
481    .catch(() => undefined)
482  const tail = await $.process
483    .run(['tail', '-n', String(TAIL_LINES), '--', path], { timeoutMs: RUN_MS })
484    .catch(() => undefined)
485  if (!head || !tail || tail.exitCode !== 0) return { path }
486
487  // the head and tail miss requests between them, so no usd
488  return {
489    path,
490    updatedAt,
491    isPartial: true,
492    rollout: await confirmed(
493      $,
494      parseRollout(`${head.stdout}\n${tail.stdout}`, home, jobStart(job), jobStop(job)),
495      looks,
496    ),
497  }
498}
499
500async function refresh(
501  $: EngineInterface,
502  sessionId: string,
503  codexHome: string,
504  jobPath: string,
505  entry: CodexJob,
506  items: RabeItem[],
507  looks: Looks,
508  file: string,
509): Promise<void> {
510  const saved = parseJson(String(await $.fs.read(jobPath).catch(() => '')))
511  const job: CodexJob = { ...entry, ...saved, id: entry.id }
512  if (job.sessionId !== sessionId) return
513  const end = jobEnd(job.status)
514  const now = await $.clock.now()
515  const endedAt = Date.parse(str(job.completedAt) ?? '') || now
516  const change = (item: NewItem) => (list: RabeItem[]) => {
517    const added = addItem(list, item, now)
518
519    return end ? endItem(added, item.id, end, endedAt) : added
520  }
521  const held = items.find((one): one is RabeItemOf<'codex'> => one.id === itemId('codex', job.id))
522  // a finished job the cap would drop again: its session file is not read
523  if (!held && commit(items, change(codexItem(job, undefined))) === undefined) return
524  const session = await readSession($, codexHome, job, held, looks, file)
525  await write($, change(codexItem(job, session)))
526}
527
528async function poll($: EngineInterface, file: string): Promise<void> {
529  const { claude, codex } = await homes($)
530  const stateRoot = `${claude}/${CODEX_DATA}/state`
531  const sessionId = await $.session.id()
532  const { startedAt: since } = await $.session.usage()
533  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
534  const looks: Looks = { asked: new Map(), known: new Map(), stop: new AbortController() }
535  // ponytail: one stat per workspace folder per poll; keep a folder list in $.state if there are hundreds
536  for (const dir of await $.fs.list(stateRoot).catch(() => [])) {
537    if (dir.kind !== 'dir') continue
538    const statePath = `${stateRoot}/${dir.name}/state.json`
539    const stat = await $.fs.stat(statePath).catch(() => undefined)
540    if (!stat || stat.mtimeMs < since) continue
541    const state = parseJson(String(await $.fs.read(statePath).catch(() => '')))
542    for (const entry of list(state?.jobs).map(rec)) {
543      const id = str(entry.id)
544      // the plugin's state.json can hold a bare status patch; its job file has the session
545      // the id names a file in jobs/, so it must stay a plain file name
546      if (!id || !/^\w[\w.-]*$/.test(id)) continue
547      if (entry.sessionId !== undefined && entry.sessionId !== sessionId) continue
548      const held = items.find((one): one is RabeItemOf<'codex'> => one.id === itemId('codex', id))
549      if (held && held.status !== 'running') continue
550      await refresh(
551        $,
552        sessionId,
553        codex,
554        `${stateRoot}/${dir.name}/jobs/${id}.json`,
555        { ...entry, id },
556        items,
557        looks,
558        file,
559      ).catch(() => undefined)
560    }
561  }
562  looks.stop.abort()
563  await write($, linkForwarders)
564}
565
566function firstLine(text: string): string {
567  return text.trim().split('\n')[0] ?? ''
568}
569
570async function cancel($: EngineInterface, id: string): Promise<{ text: string }> {
571  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
572  const item = items.find((one: RabeItem): one is RabeItemOf<'codex'> => one.id === id)
573  if (item?.status !== 'running') return { text: `No running Codex job ${id}.` }
574  const { claude } = await homes($)
575  const installed = parseJson(
576    String(await $.fs.read(`${claude}/plugins/installed_plugins.json`).catch(() => '')),
577  )
578  const root = str(rec(list(rec(installed?.plugins)[CODEX_PLUGIN])[0]).installPath)
579  if (!root) return { text: 'Stop refused: the Codex plugin is not installed.' }
580  const { workspaceRoot } = item.detail
581  const argv = [
582    'node',
583    `${root}/scripts/codex-companion.mjs`,
584    'cancel',
585    item.detail.jobId,
586    '--json',
587    ...(workspaceRoot ? ['--cwd', workspaceRoot] : []),
588  ]
589  const result = await $.process
590    .run(argv, { env: { CLAUDE_PLUGIN_DATA: `${claude}/${CODEX_DATA}` } })
591    .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error) }))
592  if (result.exitCode !== 0) {
593    return { text: `Stop refused: ${firstLine(result.stderr) || `exit ${result.exitCode}`}` }
594  }
595  const now = await $.clock.now()
596  // later polls skip the stopped job: its cost stays only while the session
597  // file is the one it was priced from
598  const { sessionPath, sessionUpdatedAt } = item.detail
599  const stat = sessionPath ? await $.fs.stat(sessionPath).catch(() => undefined) : undefined
600  const isPriced = stat !== undefined && stat.mtimeMs === sessionUpdatedAt
601  await write($, held => {
602    const ended = endItem(held, id, 'stopped', now)
603    return isPriced ? ended : updateItem(ended, id, { costUsd: undefined })
604  })
605
606  return { text: `Stopped codex ${item.detail.jobId}` }
607}
608
609export function codex(on: On, file: string): void {
610  on('session.start', { isInteractive: true }, async ($, e, next) => {
611    const started = await next(e)
612    await $.command.register({
613      name: 'rabe-stop',
614      description: 'Stop a background item that Rabe shows, by its id',
615      argumentHint: '<item id>',
616      immediate: true,
617    })
618    let isPolling = false
619    $.clock.every(POLL_MS, () => {
620      if (isPolling) return
621      isPolling = true
622      poll($, file)
623        .catch(() => undefined)
624        .finally(() => {
625          isPolling = false
626        })
627    })
628
629    return started
630  })
631
632  on('command.run', { command: 'rabe-stop', args: /^\s*codex:/ }, async ($, e) =>
633    cancel($, e.args.trim()),
634  )
635}
636
hooks/sources/crons.ts 241 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { type EndStatus, itemId, type RabeItem, type RabeItemOf } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5
6const LOOP_SENTINEL = '<<autonomous-loop-dynamic>>'
7const SENTINEL = /^<<[\w.-]+>>$/
8const WAKEUP = 'wakeup-'
9// A wakeup fires when the session is idle, up to 90 s early for :00 and :30.
10const FIRE_SLACK_MS = 90_000
11
12// Each event takes one hook without a matcher per plugin; these match every event.
13const ANY_START = { isInteractive: [true, false] }
14const ANY_STOP = { stop_hook_active: [true, false] }
15
16type Job = { id: string; schedule: string; prompt: string; humanSchedule?: string }
17
18async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
19  for (;;) {
20    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
21    const next = commit(value, change)
22    if (next === undefined) return
23    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
24      ifVersion: version,
25    })
26    if (isSet) return forget($, next.dropped)
27  }
28}
29
30// Drops the lines and turns of the items a write dropped.
31async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
32  if (dropped.length === 0) return
33  for (;;) {
34    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
35    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
36    const next = prune(value, dropped, items)
37    if (next === value) break
38    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
39    if (lines.isSet) break
40  }
41  for (;;) {
42    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
43    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
44    const next = prune(value, dropped, items)
45    if (next === value) return
46    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
47    if (turns.isSet) return
48  }
49}
50
51function runningCrons(items: RabeItem[]): RabeItemOf<'cron'>[] {
52  return items.flatMap(item => (item.kind === 'cron' && item.status === 'running' ? [item] : []))
53}
54
55function isWakeup(item: RabeItemOf<'cron'>): boolean {
56  return item.detail.jobId.startsWith(WAKEUP)
57}
58
59function endWakeups(items: RabeItem[], status: EndStatus, now: number, until = Infinity) {
60  return dueWakeups(items, until).reduce((next, item) => endItem(next, item.id, status, now), items)
61}
62
63function dueWakeups(items: RabeItem[], until: number) {
64  return runningCrons(items).filter(
65    item => isWakeup(item) && (item.detail.scheduledFor ?? 0) <= until,
66  )
67}
68
69// A due wakeup ends only on evidence that it fired: its own prompt, or the
70// sentinel it was given (`<<autonomous-loop-dynamic>>`), which its expanded
71// tick names. Other text, such as a recurring loop's tick, ends none.
72function fired(items: RabeItem[], text: string, now: number): RabeItem[] {
73  const prompt = text.trim()
74  const ends = dueWakeups(items, now + FIRE_SLACK_MS).filter(item => {
75    const own = item.detail.prompt.trim()
76
77    return own === prompt || (SENTINEL.test(own) && text.includes(own))
78  })
79
80  return ends.reduce((next, item) => endItem(next, item.id, 'done', now), items)
81}
82
83function sync(items: RabeItem[], jobs: Job[], now: number): RabeItem[] {
84  const listed = new Set(jobs.map(job => itemId('cron', job.id)))
85  const running = runningCrons(items)
86  const wakeupPrompts = new Set(running.filter(isWakeup).map(item => item.detail.prompt))
87  const ended = running
88    .filter(item => !isWakeup(item) && !listed.has(item.id))
89    .reduce((next, item) => endItem(next, item.id, 'done', now), items)
90
91  return jobs.reduce((next, job) => {
92    const id = itemId('cron', job.id)
93    if (wakeupPrompts.has(job.prompt) || next.some(item => item.id === id)) return next
94
95    return addItem(
96      next,
97      {
98        id,
99        kind: 'cron',
100        title: job.prompt,
101        status: 'running',
102        detail: {
103          jobId: job.id,
104          prompt: job.prompt,
105          schedule: job.schedule,
106          ...(job.humanSchedule && { humanSchedule: job.humanSchedule }),
107        },
108      },
109      now,
110    )
111  }, ended)
112}
113
114async function list($: EngineInterface): Promise<void> {
115  try {
116    const answer = await $.tool.call({ tool: 'CronList' })
117    if (!answer.result || answer.isError) return
118    const jobs = answer.result.jobs.map(job => ({
119      id: job.id,
120      schedule: job.cron,
121      prompt: job.prompt,
122      humanSchedule: job.humanSchedule,
123    }))
124    const now = await $.clock.now()
125    await write($, held => sync(held, jobs, now))
126  } catch {}
127}
128
129export function crons(on: On): void {
130  on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
131    const answer = await next(e)
132    try {
133      if (e.tool === 'CronCreate' && answer.result && !answer.isError) {
134        const { id, humanSchedule } = answer.result as { id: string; humanSchedule?: string }
135        const now = await $.clock.now()
136        await write($, held =>
137          addItem(
138            held,
139            {
140              id: itemId('cron', id),
141              kind: 'cron',
142              title: e.prompt,
143              status: 'running',
144              startedAt: now,
145              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
146              detail: {
147                jobId: id,
148                prompt: e.prompt,
149                schedule: e.cron,
150                ...(humanSchedule && { humanSchedule }),
151              },
152            },
153            now,
154          ),
155        )
156      }
157    } catch {}
158
159    return answer
160  })
161
162  on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
163    const answer = await next(e)
164    try {
165      if (e.tool === 'CronDelete' && answer.result && !answer.isError) {
166        const now = await $.clock.now()
167        const id = itemId('cron', e.id)
168        await write($, held =>
169          updateItem(endItem(held, id, 'stopped', now), id, { detail: { isDeleted: true } }),
170        )
171      }
172    } catch {}
173
174    return answer
175  })
176
177  on('tool.call', { tool: 'ScheduleWakeup' }, async ($, e, next) => {
178    const answer = await next(e)
179    try {
180      if (e.tool === 'ScheduleWakeup' && answer.result && !answer.isError) {
181        const { scheduledFor, stopped } = answer.result as {
182          scheduledFor: number
183          stopped?: boolean
184        }
185        const now = await $.clock.now()
186        const prompt = e.prompt ?? ''
187        await write($, held =>
188          stopped || e.stop
189            ? endWakeups(held, 'stopped', now)
190            : addItem(
191                endWakeups(held, 'done', now),
192                {
193                  id: itemId('cron', `${WAKEUP}${scheduledFor}`),
194                  kind: 'cron',
195                  title: prompt === LOOP_SENTINEL ? 'autonomous loop' : prompt,
196                  status: 'running',
197                  startedAt: now,
198                  ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
199                  detail: { jobId: `${WAKEUP}${scheduledFor}`, prompt, scheduledFor },
200                },
201                now,
202              ),
203        )
204      }
205    } catch {}
206
207    return answer
208  })
209
210  on('prompt.submit', { origin: { kind: 'scheduled-trigger' } }, async ($, e, next) => {
211    try {
212      const now = await $.clock.now()
213      await write($, held => fired(held, e.text, now))
214    } catch {}
215
216    return next(e)
217  })
218
219  on('classic.Stop', ANY_STOP, async ($, e, next) => {
220    try {
221      if (e.session_crons) {
222        const jobs = e.session_crons.map(job => ({
223          id: job.id,
224          schedule: job.schedule,
225          prompt: job.prompt,
226        }))
227        const now = await $.clock.now()
228        await write($, held => sync(held, jobs, now))
229      }
230    } catch {}
231
232    return next(e)
233  })
234
235  on('session.start', ANY_START, async ($, e, next) => {
236    await list($)
237
238    return next(e)
239  })
240}
241
hooks/sources/monitors.ts 218 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { type EndStatus, itemId, type RabeItem } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import { appendLines, endStatus, parseNotifications, parseOutput, taskOutput } from '../tasks'
6
7const POLL_MS = 2000
8const MAX_READ = 4 * 1024 * 1024
9
10// Each event takes one hook without a matcher per plugin; these match every event.
11const ANY_START = { isInteractive: [true, false] }
12const ANY_STOP = { stop_hook_active: [true, false] }
13
14type BackgroundTask = { id: string; type: string; description: string; command?: string }
15
16async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
17  for (;;) {
18    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
19    const next = commit(value, change)
20    if (next === undefined) return
21    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
22      ifVersion: version,
23    })
24    if (isSet) return forget($, next.dropped)
25  }
26}
27
28// Drops the lines and turns of the items a write dropped.
29async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
30  if (dropped.length === 0) return
31  for (;;) {
32    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
33    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
34    const next = prune(value, dropped, items)
35    if (next === value) break
36    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
37    if (lines.isSet) break
38  }
39  for (;;) {
40    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
41    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
42    const next = prune(value, dropped, items)
43    if (next === value) return
44    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
45    if (turns.isSet) return
46  }
47}
48
49// ponytail: reads files up to 4 MiB whole; a larger monitor output is not followed
50async function follow(
51  $: EngineInterface,
52  id: string,
53  path: string,
54): Promise<EndStatus | undefined> {
55  try {
56    const { size } = await $.fs.stat(path)
57    if (size > MAX_READ) return undefined
58    const { lines, ended } = parseOutput(String(await $.fs.read(path)))
59    const now = await $.clock.now()
60    for (;;) {
61      const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
62      const next = appendLines(value[id], lines, now)
63      if (!next) break
64      const { isSet } = await $.state.set(
65        { plugin: 'rabe', key: 'lines' },
66        { ...value, [id]: next },
67        { ifVersion: version },
68      )
69      if (isSet) break
70    }
71
72    return ended
73  } catch {
74    return undefined
75  }
76}
77
78function runningMonitors(items: RabeItem[]) {
79  return items.flatMap(item => (item.kind === 'monitor' && item.status === 'running' ? [item] : []))
80}
81
82async function poll($: EngineInterface): Promise<void> {
83  try {
84    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
85    for (const item of runningMonitors(items)) {
86      const path =
87        item.detail.outputPath ?? (item.detail.taskId && taskOutput(items, item.detail.taskId))
88      if (!path) continue
89      const ended = await follow($, item.id, path)
90      const now = await $.clock.now()
91      await write($, held => {
92        const next = updateItem(held, item.id, { detail: { outputPath: path } })
93
94        return ended ? endItem(next, item.id, ended, now) : next
95      })
96    }
97  } catch {}
98}
99
100async function onNotifications($: EngineInterface, text: string): Promise<void> {
101  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
102  for (const one of parseNotifications(text)) {
103    const id = itemId('monitor', one.taskId)
104    if (!one.status || !items.some(item => item.id === id)) continue
105    if (one.outputFile) await follow($, id, one.outputFile)
106    const now = await $.clock.now()
107    await write($, held =>
108      endItem(
109        updateItem(held, id, { detail: one.outputFile ? { outputPath: one.outputFile } : {} }),
110        id,
111        endStatus(one.status),
112        now,
113      ),
114    )
115  }
116}
117
118async function correct($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<void> {
119  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
120  const inFlight = new Set(tasks.map(task => task.id))
121  const now = await $.clock.now()
122  for (const item of runningMonitors(items)) {
123    if (!item.detail.taskId || inFlight.has(item.detail.taskId)) continue
124    // A subagent's task may be missing from the main session's list; the poll
125    // ends it from the exit line of its output file.
126    if (item.parentId?.startsWith('agent:')) continue
127    const ended = item.detail.outputPath && (await follow($, item.id, item.detail.outputPath))
128    await write($, held => endItem(held, item.id, ended || 'stopped', now))
129  }
130  await write($, held =>
131    tasks.reduce((next, task) => {
132      const id = itemId('monitor', task.id)
133      if (task.type !== 'monitor' || next.some(item => item.id === id)) return next
134      const outputPath = taskOutput(next, task.id)
135
136      return addItem(
137        next,
138        {
139          id,
140          kind: 'monitor',
141          title: task.description,
142          status: 'running',
143          detail: {
144            command: task.command ?? task.description,
145            description: task.description,
146            taskId: task.id,
147            ...(outputPath && { outputPath }),
148          },
149        },
150        now,
151      )
152    }, held),
153  )
154}
155
156export function monitors(on: On): void {
157  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
158    const answer = await next(e)
159    try {
160      const result = answer.result as
161        | { taskId?: string; timeoutMs?: number; persistent?: boolean }
162        | undefined
163      const taskId = result?.taskId
164      if (e.tool === 'Monitor' && taskId) {
165        const now = await $.clock.now()
166        await write($, held => {
167          const outputPath = taskOutput(held, taskId)
168
169          return addItem(
170            held,
171            {
172              id: itemId('monitor', taskId),
173              kind: 'monitor',
174              title: e.description,
175              status: 'running',
176              startedAt: now,
177              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
178              detail: {
179                command: e.command ?? e.ws?.url ?? '',
180                description: e.description,
181                taskId,
182                timeoutMs: result.timeoutMs ?? e.timeout_ms,
183                isPersistent: result.persistent === true,
184                ...(outputPath && { outputPath }),
185              },
186            },
187            now,
188          )
189        })
190      }
191    } catch {}
192
193    return answer
194  })
195
196  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
197    try {
198      await onNotifications($, e.text)
199    } catch {}
200
201    return next(e)
202  })
203
204  on('classic.Stop', ANY_STOP, async ($, e, next) => {
205    try {
206      if (e.background_tasks) await correct($, e.background_tasks)
207    } catch {}
208
209    return next(e)
210  })
211
212  on('session.start', ANY_START, async ($, e, next) => {
213    $.clock.every(POLL_MS, () => void poll($))
214
215    return next(e)
216  })
217}
218
hooks/sources/shells.ts 255 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { itemId, type RabeItem } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import {
6  appendLines,
7  endStatus,
8  guessPort,
9  outputPathOf,
10  parseNotifications,
11  parseOutput,
12  taskOutput,
13} from '../tasks'
14
15const POLL_MS = 2000
16const MAX_READ = 4 * 1024 * 1024
17const TAIL_BYTES = '1048576'
18const RUN_MS = 5000
19
20// Each event takes one hook without a matcher per plugin; these match every event.
21const ANY_START = { isInteractive: [true, false] }
22const ANY_STOP = { stop_hook_active: [true, false] }
23
24type BackgroundTask = { id: string; type: string; description?: string; command?: string }
25
26const shellTitle = (description: string | undefined, command: string, id: string) =>
27  `${(description || command).replace(/\s+/g, ' ').trim()} · ${id}`
28
29async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
30  for (;;) {
31    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
32    const next = commit(value, change)
33    if (next === undefined) return
34    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
35      ifVersion: version,
36    })
37    if (isSet) return forget($, next.dropped)
38  }
39}
40
41// Drops the lines and turns of the items a write dropped.
42async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
43  if (dropped.length === 0) return
44  for (;;) {
45    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
46    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
47    const next = prune(value, dropped, items)
48    if (next === value) break
49    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
50    if (lines.isSet) break
51  }
52  for (;;) {
53    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
54    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
55    const next = prune(value, dropped, items)
56    if (next === value) return
57    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
58    if (turns.isSet) return
59  }
60}
61
62async function readOutput($: EngineInterface, path: string): Promise<string | undefined> {
63  return (await readWhole($, path)).text
64}
65
66// `isWhole` is false when only the file's tail was read.
67async function readWhole(
68  $: EngineInterface,
69  path: string,
70): Promise<{ text?: string; isWhole: boolean }> {
71  try {
72    const { size } = await $.fs.stat(path)
73    if (size <= MAX_READ) return { text: String(await $.fs.read(path)), isWhole: true }
74    const { stdout } = await $.process.run(['tail', '-c', TAIL_BYTES, '--', path], {
75      timeoutMs: RUN_MS,
76    })
77
78    return { text: stdout, isWhole: false }
79  } catch {
80    return { isWhole: false }
81  }
82}
83
84// ponytail: lines are counted from the file's start, so a shell whose output
85// passed 4 MiB keeps the lines it had; the pane shows those.
86async function keepLines($: EngineInterface, id: string, lines: string[]): Promise<void> {
87  const now = await $.clock.now()
88  for (;;) {
89    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
90    const next = appendLines(value[id], lines, now)
91    if (!next) return
92    const { isSet } = await $.state.set(
93      { plugin: 'rabe', key: 'lines' },
94      { ...value, [id]: next },
95      { ifVersion: version },
96    )
97    if (isSet) return
98  }
99}
100
101function runningShells(items: RabeItem[]) {
102  return items.flatMap(item => (item.kind === 'shell' && item.status === 'running' ? [item] : []))
103}
104
105async function poll($: EngineInterface): Promise<void> {
106  try {
107    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
108    for (const item of runningShells(items)) {
109      const { text, isWhole } = item.detail.outputPath
110        ? await readWhole($, item.detail.outputPath)
111        : { isWhole: false }
112      if (!text) continue
113      const { exitCode, ended, lines } = parseOutput(text)
114      if (isWhole) await keepLines($, item.id, lines)
115      const port = item.detail.port ?? guessPort(text)
116      const now = await $.clock.now()
117      const portAt = item.detail.portAt ?? (port ? now : undefined)
118      await write($, held => {
119        const next = updateItem(held, item.id, {
120          detail: {
121            ...(port && { port, portAt }),
122            ...(exitCode !== undefined && { exitCode }),
123          },
124        })
125
126        return ended ? endItem(next, item.id, ended, now) : next
127      })
128    }
129  } catch {}
130}
131
132async function onNotifications($: EngineInterface, text: string): Promise<void> {
133  const notes = parseNotifications(text).filter(one => one.status)
134  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
135  for (const one of notes) {
136    const id = itemId('shell', one.taskId)
137    const item = items.find(held => held.id === id)
138    const path = one.outputFile ?? (item?.kind === 'shell' ? item.detail.outputPath : undefined)
139    const read = item && path ? await readWhole($, path) : undefined
140    if (read?.text && read.isWhole) await keepLines($, id, parseOutput(read.text).lines)
141  }
142  const now = await $.clock.now()
143  await write($, held =>
144    notes.reduce((items, one) => {
145      const id = itemId('shell', one.taskId)
146      const next = updateItem(items, id, {
147        detail: {
148          ...(one.exitCode !== undefined && { exitCode: one.exitCode }),
149          ...(one.outputFile && { outputPath: one.outputFile }),
150        },
151      })
152
153      return endItem(next, id, endStatus(one.status, one.exitCode), now)
154    }, held),
155  )
156}
157
158async function correct($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<void> {
159  const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
160  const inFlight = new Set(tasks.map(task => task.id))
161  const now = await $.clock.now()
162  for (const item of runningShells(items)) {
163    if (!item.detail.taskId || inFlight.has(item.detail.taskId)) continue
164    // A subagent's task may be missing from the main session's list; the poll
165    // ends it from the exit line of its output file.
166    if (item.parentId?.startsWith('agent:')) continue
167    const text = item.detail.outputPath && (await readOutput($, item.detail.outputPath))
168    const { exitCode, ended = 'stopped' } = parseOutput(text || '')
169    await write($, held =>
170      endItem(
171        updateItem(held, item.id, { detail: exitCode === undefined ? {} : { exitCode } }),
172        item.id,
173        ended,
174        now,
175      ),
176    )
177  }
178  await write($, held =>
179    tasks.reduce((next, task) => {
180      const id = itemId('shell', task.id)
181      // Claude Code lists a Monitor tool task as type 'shell' too.
182      const known = [id, itemId('monitor', task.id)]
183      if (task.type !== 'shell' || !task.command || next.some(item => known.includes(item.id))) {
184        return next
185      }
186      const outputPath = taskOutput(items, task.id)
187
188      return addItem(
189        next,
190        {
191          id,
192          kind: 'shell',
193          title: shellTitle(task.description, task.command, task.id),
194          status: 'running',
195          detail: { command: task.command, taskId: task.id, ...(outputPath && { outputPath }) },
196        },
197        now,
198      )
199    }, held),
200  )
201}
202
203export function shells(on: On): void {
204  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
205    const answer = await next(e)
206    try {
207      const result = answer.result as { backgroundTaskId?: string } | undefined
208      const taskId = result?.backgroundTaskId
209      if (e.tool === 'Bash' && taskId) {
210        const now = await $.clock.now()
211        const outputPath = outputPathOf(answer.text)
212        await write($, held =>
213          addItem(
214            held,
215            {
216              id: itemId('shell', taskId),
217              kind: 'shell',
218              title: shellTitle(e.description, e.command, taskId),
219              status: 'running',
220              startedAt: now,
221              ...(e.agentId && { parentId: itemId('agent', e.agentId) }),
222              detail: { command: e.command, taskId, ...(outputPath && { outputPath }) },
223            },
224            now,
225          ),
226        )
227      }
228    } catch {}
229
230    return answer
231  })
232
233  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
234    try {
235      await onNotifications($, e.text)
236    } catch {}
237
238    return next(e)
239  })
240
241  on('classic.Stop', ANY_STOP, async ($, e, next) => {
242    try {
243      if (e.background_tasks) await correct($, e.background_tasks)
244    } catch {}
245
246    return next(e)
247  })
248
249  on('session.start', ANY_START, async ($, e, next) => {
250    $.clock.every(POLL_MS, () => void poll($))
251
252    return next(e)
253  })
254}
255
hooks/sources/workflows.ts 114 lines
1import type { BuiltinToolResults, EngineInterface, On } from 'claude-code'
2
3import { itemId } from '../model'
4import { addItem, type Change, commit, endItem, prune, updateItem } from '../registry'
5import { endStatus, parseNotifications } from '../tasks'
6
7type Launched = BuiltinToolResults['Workflow']
8
9export function phaseNames(script: string): string[] | undefined {
10  // ponytail: a `}` inside an object entry's string still ends that entry.
11  const list =
12    /\bphases\s*:\s*\[\s*((?:(?:'[^']*'|"[^"]*"|`[^`]*`|\{[^}]*\})\s*(?:,\s*)?)*)\]/.exec(
13      script,
14    )?.[1]
15  if (list === undefined) return undefined
16
17  return [...list.matchAll(/\{[^}]*\}|(['"`])(.*?)\1/g)].flatMap(([whole, quote, text]) => {
18    if (quote) return [text ?? '']
19    const title = /\btitle\s*:\s*(['"`])(.*?)\1/.exec(whole)?.[2]
20    return title === undefined ? [] : [title]
21  })
22}
23
24async function write($: Pick<EngineInterface, 'state'>, change: Change): Promise<void> {
25  for (;;) {
26    const { value, version } = await $.state.get({ plugin: 'rabe', key: 'items' })
27    const next = commit(value, change)
28    if (next === undefined) return
29    const { isSet } = await $.state.set({ plugin: 'rabe', key: 'items' }, next.items, {
30      ifVersion: version,
31    })
32    if (isSet) return forget($, next.dropped)
33  }
34}
35
36// Drops the lines and turns of the items a write dropped.
37async function forget($: Pick<EngineInterface, 'state'>, dropped: string[]): Promise<void> {
38  if (dropped.length === 0) return
39  for (;;) {
40    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'lines' })
41    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
42    const next = prune(value, dropped, items)
43    if (next === value) break
44    const lines = await $.state.set({ plugin: 'rabe', key: 'lines' }, next, { ifVersion: version })
45    if (lines.isSet) break
46  }
47  for (;;) {
48    const { value = {}, version } = await $.state.get({ plugin: 'rabe', key: 'turns' })
49    const { value: items = [] } = await $.state.get({ plugin: 'rabe', key: 'items' })
50    const next = prune(value, dropped, items)
51    if (next === value) return
52    const turns = await $.state.set({ plugin: 'rabe', key: 'turns' }, next, { ifVersion: version })
53    if (turns.isSet) return
54  }
55}
56
57async function launched($: EngineInterface, result: Launched, name?: string): Promise<void> {
58  const runId = result.runId
59  if (!runId) return
60  const id = itemId('workflow', runId)
61  const now = await $.clock.now()
62  await write($, items =>
63    addItem(
64      items,
65      {
66        id,
67        kind: 'workflow',
68        title: result.workflowName ?? name ?? 'workflow',
69        status: 'running',
70        startedAt: now,
71        endedAt: undefined,
72        detail: {
73          runId,
74          taskId: result.taskId,
75          scriptPath: result.scriptPath,
76          transcriptDir: result.transcriptDir,
77        },
78      },
79      now,
80    ),
81  )
82  if (!result.scriptPath) return
83  const script = await $.fs.read(result.scriptPath).catch(() => undefined)
84  const phases = typeof script === 'string' ? phaseNames(script) : undefined
85  if (phases) await write($, items => updateItem(items, id, { detail: { phases } }))
86}
87
88async function notified($: EngineInterface, text: string): Promise<void> {
89  const notes = parseNotifications(text).filter(note => note.status)
90  if (notes.length === 0) return
91  const now = await $.clock.now()
92  await write($, items =>
93    notes.reduce((list, note) => {
94      const run = list.find(item => item.kind === 'workflow' && item.detail.taskId === note.taskId)
95      return run ? endItem(list, run.id, endStatus(note.status), now) : list
96    }, items),
97  )
98}
99
100export function workflows(on: On): void {
101  on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
102    const answer = await next(e)
103    if (answer.result && !answer.isError) await launched($, answer.result as Launched, e.name)
104
105    return answer
106  }).catch((_$, e, next) => next(e))
107
108  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
109    await notified($, e.text)
110
111    return next(e)
112  }).catch((_$, e, next) => next(e))
113}
114