SLOPSHOPPER

showrunner-mod

Reports session status to showrunner and shows the task context above the prompt

newpanespinnerguardcommandmodel
★ 7v0.1.0MITupdated 2026-10-14Bendzae/showrunner/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · showrunner-mod
│ ┃ ticket ✕ › fix the failing auth test and add an audit log call │ ┃ Loading ticket… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ticket
Loading ticket…
Pane · pr
Loading PR…
README

<h1><picture> <img src="assets/wordmark-light.svg" alt="showrunner" width="330">

Formerly claude-manager.

A terminal UI (TUI) for managing multiple coding-agent sessions — Claude Code, Codex CLI and Pi today, more harnesses planned — organized by projects and tasks. Built with Rust using ratatui.

Showrunner uses tmux to run agent sessions in the background, letting you organize them into projects and tasks, monitor their status, review diffs, and attach/detach freely. <img width="800" height="430" alt="showcase-gif" src="https://github.com/user-attachments/assets/63b6a5b1-b821-44b3-b764-481ee40fd33f" />

Prerequisites

  • Cargo (Rust 1.85+) — install via rustup
  • tmux — brew install tmux (macOS) or apt install tmux (Linux)
  • An agent CLI on your PATH — Claude Code (claude, the default), Codex CLI (codex) and/or Pi (pi) — see Agent harnesses
  • git — for worktree and branch management
  • gh (optional) — GitHub CLI, for PR creation features
  • hunk (optional) — the default diff review tool (r), a terminal diff viewer (modem-dev/hunk). Installed globally (npm i -g hunkdiff) it launches instantly; otherwise it runs via npx hunkdiff automatically (Node.js required), fetched on first use.
  • difit (optional) — an alternative browser-based diff review tool, used when review_tool = "difit" is set in config. Installed globally (npm i -g difit) it launches instantly; otherwise it runs via npx difit automatically (Node.js required).

Installation

Homebrew (macOS and Linux):

brew install Bendzae/tap/showrunner

Prebuilt binaries (macOS and Linux):

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Bendzae/showrunner/releases/latest/download/showrunner-installer.sh | sh

Or via cargo:

cargo install showrunner

Data lives in ~/.showrunner/. Upgrading from claude-manager keeps all projects, tasks and sessions: on first start the old ~/.showrunner/ dir is migrated automatically (renamed, worktree git links repaired, live sessions re-pointed). Remove the old binary with cargo uninstall claude-manager.

Or build from source:

git clone git@github.com:Bendzae/showrunner.git
cd showrunner
cargo install --path .

Usage

showrunner

Launch from any directory. Configuration is stored in ~/.showrunner/config.toml.

Tip: When you attach to a session (with Enter), you're inside a tmux session. To detach and get back to showrunner, use your tmux detach binding — with the default prefix that's Ctrl-b d. The session keeps running in the background.

Concepts

  • Project — A git repository you want to manage agent sessions for. Added by its filesystem path (p prompts for path and name).
  • Task — A unit of work within a project, tied to a git branch. Each task can have multiple sessions.
  • Session — An agent instance (Claude Code, Codex CLI or Pi, per default_agent/--agent) running in a tmux session. Sessions can be created with an optional initial prompt, and (by default) in their own git worktree so they don't collide.
  • Main session (◆ main) — Every task has one, created with the task. It works in a worktree with the task branch itself checked out, so its commits land on the task branch directly — no merge step. Extra sessions (n) get their own <task-branch>-<name> branch and merge back into it.
  • Adhoc session — A project-scoped session that runs the agent directly in the project directory on whatever branch is checked out, with no task or worktree. Created with A from a project's context menu and grouped under the project. Handy for quick, throwaway work that doesn't warrant a task.

Keybindings

All keybindings are customizable via ~/.showrunner/keybindings.toml. The tables below show the defaults. See keybindings.example.toml for a full template.

Global
KeyActionConfig key
j/k or Up/DownNavigatemove_down / move_up
EnterAttach to session (on a task: its main session), or collapse/expand project or group—
SpaceCollapse/expand project or tasktoggle_collapse
aOpen context menucontext_menu
pAdd projectadd_project
PAdd one of the remote's projects (with a [remote] configured)add_remote_project
/Filter projects/tasks/sessionssearch
ZToggle archived viewtoggle_archive_view
K / JMove the selected project, task or group up / down (order is saved)move_task_up / move_task_down
tCycle color themecycle_theme
qQuitquit

t cycles through the built-in themes: default, catppuccin, tokyo-night, and dracula.

Context Menu (press a to open)

The context menu shows actions relevant to the selected item. Press the hotkey character to execute directly, or navigate with j/k and confirm with Enter. Context menu keys are configured under the [context_menu_keys] section.

Project actions:

KeyActionConfig key
tAdd taskadd_task
TAdd task, choosing the agent harness firstadd_task_with_agent
ANew adhoc sessionnew_adhoc_session
xRun the project's configured run commandrun
bCheckout branch (fuzzy-search the branch list)checkout
fFetch & pull all branches (git fetch --all --prune + ff-only pull)fetch_pull
yCopy project path to clipboardcopy_path
dDeletedelete

The branch checkout picker opens a fuzzy finder over the project's local and remote branches — type to filter, ↑/↓ to navigate, Enter to check out (a remote-only branch is checked out as a new local tracking branch).

Run command (x, available on projects, tasks, and sessions) launches a per-project command in a dedicated tmux session and attaches to it. The first time you run it for a project you're prompted for the command (e.g. npm run dev); it's saved to that project's run_command config and reused everywhere afterwards. It runs in the selected item's working directory: the session's worktree for a session, the task's first session worktree (or the project dir) for a task, and the project dir for a project.

Each item shows a green run indicator while it has a live run session — an animated spinner (⠙) while the command is still executing, and a static ▷ once it finishes (the shell stays open so you can read its output). Detaching with Ctrl-b d leaves the command running in the background.

Pressing Run (x) again on an item that already has a live run session opens a small menu instead of relaunching:

KeyAction
aAttach — re-attach to the running session
rRestart — kill and relaunch the run command
kKill — stop the run session

Run sessions are independent per item, so running on a different item starts a separate session. (Outside the TUI you can also list them with tmux ls and attach via tmux attach -t cmrun-<name>.)

Task actions:

KeyActionConfig key
nNew session (with worktree)new_session
SNew session, choosing the agent harness firstnew_session_with_agent
NNew session (without worktree)new_session_no_worktree
rReview branch-vs-base diff (difit or hunk)review
xRun the project's configured run commandrun
uUpdate/rebase branch onto its base branch (default main)update
BSet base branchset_base_branch
PPush branchpush
bCheckout branch in project dircheckout
oOpen/create PRopen_pr
lOpen linked Linear ticketopen_linear
gSet group (fuzzy picker over existing groups; type a new name to create one)group
REnable/disable Remote Control on the main session (Claude)remote_control
AArchivearchive
dDeletedelete

Task group actions (on a group header):

KeyActionConfig key
tAdd task to the groupadd_task
gRename groupgroup
GUngroup tasks (dissolve the group)ungroup

Session actions:

KeyActionConfig key
rReview uncommitted changes (difit or hunk)review
mMerge into task branchmerge
uUpdate/rebase onto task branchupdate
tOpen/attach a terminal in the worktreeterminal
xRun the project's configured run commandrun
yCopy worktree path to clipboardcopy_path
REnable/disable Remote Control (Claude sessions)remote_control
dDeletedelete

The main session only offers Review, Terminal, Run, Copy path and Remote Control: it is already on the task branch, so Merge and Update have nothing to do, and it can't be deleted on its own — delete or archive the task instead.

Remote Control (R, Claude sessions, also adhoc and on a remote host) makes a session show up in the Claude desktop and mobile apps and on claude.ai/code, by sending /remote-control <name> into it — no restart. It's named <machine> · <project> / <task>[ / <session>], with <machine> from machine_name in that machine's config (default: its hostname). Pressing R again disconnects it. The setting is kept on the session, so a session recreated after tmux dies comes back connected. Same from the CLI: showrunner session remote-control <session> [on|off]. Needs a claude.ai login with Remote Control allowed by your org; the first time on a machine Claude asks for consent in the session.

The Review action (r) launches the configured diff review tool on the relevant diff — branch-vs-base for a task, uncommitted changes for a session. Choose the tool with review_tool in ~/.showrunner/config.toml ("hunk", the default, or "difit"):

  • hunk (default, modem-dev/hunk) opens a terminal viewer in the foreground, suspending the TUI until you exit. Comments you leave are polled from hunk's live review session while it runs and forwarded to the agent session on exit. (A comment added in the last fraction of a second before quitting may be missed, since hunk's session is gone once it closes.)
  • difit opens a browser-based viewer in the background, so the TUI stays interactive. Any comments you leave are captured on exit and forwarded back to the agent session as a new prompt, so you can review a diff and hand the feedback straight to the agent. When a task review ends with comments and the task has several sessions, a picker asks which session receives them.
# ~/.showrunner/config.toml
review_tool = "difit"   # or "hunk" (default)

Session Status Indicators

Sessions display their current status:

  • Running — the agent is actively working
  • Waiting for input — the agent is waiting for your response
  • Needs you — the agent stopped on a permission or question dialog, or (with the Claude mod) ended its answer with questions for you; shown as a solid orange ! badge (each theme's own orange, used for nothing else) at the end of the session's line; its task's line turns orange (with the badge after the name while collapsed), and a collapsed project shows how many of its sessions need you. While the TUI runs, a session that starts needing you also posts a macOS notification (not for the session you're attached to) — through terminal-notifier when installed, so a click brings your terminal forward, else osascript; set notifications = false in config.toml to turn them off
  • Finished — the agent process has exited

Hot tasks

A task whose agent exchanged a message (your prompt or a turn — attaching doesn't count) in the last 2 hours shows a 🔥 after its name, so the tasks you're actually working on stand out from the ones waiting on something. It's read from the end of each Claude session's transcript; the order of tasks doesn't change.

PR Status

Tasks with a pull request (found via gh) show it in the PR column as #123 plus two glyphs, refreshed about once a minute. Selecting the task spells the same information out in the status bar.

CellMeaning
#123 / #123 draft / #123 merged / #123 closedPR number and state (open PRs have no suffix)
✓ / ✗ / ◌ / ·Review: approved / changes requested / review requested, no decision yet / no reviewers requested
● green / ● red / ◍ yellowCI: all checks passed / a check failed / checks still running (omitted when the PR has no checks)

Merged and closed PRs show only their state, since review and CI no longer apply.

Linear Tickets

A task can be linked to a Linear ticket; its id (ENG-123) shows next to the task name and l opens it. Linking is done through the CLI — the task's main session agent links a ticket itself when the task's name, branch or prompt refers to one:

showrunner task set-linear <project> <task> https://linear.app/acme/issue/ENG-123
showrunner task set-linear <project> <task> ENG-123   # needs linear_workspace
showrunner task create <project> <name> --linear ENG-123
# ~/.showrunner/config.toml — lets bare ids like ENG-123 be linked
linear_workspace = "acme"

Worktrees

Every session gets a git worktree so it works on an isolated copy of the codebase. The task's main session checks out the task branch itself; additional sessions created with n get their own <task-branch>-<name> branch. Use N to skip worktree creation and work directly in the project directory.

Because the main session's worktree owns the task branch, b (checkout in the project dir) fails while it exists — check out the branch in a worktree instead, or delete the task. Update branch (u) and session Merge (m) run inside that worktree rather than checking the branch out in the project dir. Worktrees are removed when their session is deleted, and all of a task's worktrees when the task (d) or project is deleted.

The project's .claude/ directory is always copied into new worktrees, so project-level agent config and skills are available there. You can copy additional file patterns (e.g. .env files) by adding copy_patterns to your project config. To run commands inside each freshly-created worktree (installing dependencies, configuring git hooks, etc.), add setup_commands (a single string or a list):

[[projects]]
name = "My App"
path = "/path/to/my-app"
copy_patterns = [".env", ".env.local"]
setup_commands = ["npm install", "./scripts/configure-hooks.sh"]

Configuration

The config file at ~/.showrunner/config.toml is managed automatically through the TUI, but can also be edited manually:

# Global: agent harness new sessions run by default ("claude", "codex" or "pi");
# override per creation with --agent. Codex sessions launch in yolo mode with
# the work dir pre-trusted, and resume via `codex resume --last`. Pi sessions
# launch with `--approve` (project files pre-trusted) and resume via `pi --continue`.
default_agent = "claude"

# Global: skills/slash-commands run in every new session before the initial
# prompt (a single string or a list). Useful for priming context.
startup_skills = ["/prime"]

# Global: names this machine in Remote Control session names (default: hostname).
machine_name = "work-laptop"

[[projects]]
name = "My App"
path = "/home/user/my-app"
copy_patterns = [".env"]               # files copied into each new worktree
setup_commands = ["npm install"]       # commands run in each new worktree
run_command = "npm run dev"            # command launched by the Run action (x)

[[projects.tasks]]
name = "fix-auth-bug"
branch = "fix/auth-bug"
base_branch = "develop"                # rebase/diff target (defaults to "main")

[[projects.tasks]]
name = "add-dark-mode"
branch = "feature/dark-mode"
group = "ui"                            # optional manual grouping in the task list

Most of these fields are set for you through the TUI (run_command on first Run, base_branch via B), so manual editing is rarely necessary. A running TUI picks up external edits on its next idle refresh.

Task groups and ordering

Projects and tasks are listed in the order they appear in the config. K / J move the selected task up / down within its project, or the selected project up / down in the list — local and remote projects alike, so a remote project can sit right under its local counterpart (the change is saved to the config, so the CLI and web UI see the same order). Members of a stack move together as one unit.

Tasks can also be grouped by hand: g on a task opens a fuzzy picker over the project's existing groups (typing a name that matches none offers to create it; (no group) removes the task from its group), and every task in a project sharing that name is listed under a collapsible group header, its members marked with a ┆ rail. A grouped task moves within its group; selecting the header moves the whole group, and its context menu adds a task straight into the group, renames it or dissolves it. The group is stored as group = "..." on each [[projects.tasks]] entry and shows up as group=... in showrunner list (a group field in --json); from the CLI, task create --group and task set-group set it.

Stacked PRs

Tasks whose base_branch is another task's branch form a stack — the same relationship GitHub's stacked pull requests use (each PR's base is the previous branch in the chain). Showrunner detects these chains automatically and marks each member with its position, ⧉ 2/3, in the TUI task list and in showrunner list (stack=2/3, or a stack object in --json). Stacked tasks are listed consecutively in chain order (root first), regardless of the order they were created in. To stack task B on task A, set B's base branch to A's branch with B. Creating a PR from a stacked task targets its base branch, so the PR lands stacked on GitHub.

Custom Keybindings

Create ~/.showrunner/keybindings.toml to override any default keybinding. Only the keys you specify are overridden; everything else keeps its default. Example:

quit = "Q"
context_menu = "o"

[context_menu_keys]
delete = "x"

Agent harnesses

Sessions can run any of the supported harnesses, chosen per creation (--agent, or the T/S picker keys) with default_agent as the fallback.

<img src="https://github.com/anthropics.png" height="20" alt=""/> Claude Code

The default harness (claude).

  • Launches with --dangerously-skip-permissions; the session briefing is passed via --append-system-prompt.
  • The showrunner skills are loaded as a Claude Code plugin (--plugin-dir), so they're available as /commit-push-task and /manage-sessions slash commands.
  • Permission prompts and question dialogs surface as Needs you.
  • Dead sessions resume their own conversation (--session-id at creation, --resume <id> after); sessions from before that use claude --continue.
Claude mod (optional)

claude-mod/ is a Claude Code mod that ties Claude sessions closer to showrunner. Inside a showrunner session it:

  • reports the session's status itself (CM_STATUS: running, waiting for input, needs you), which showrunner trusts over reading the pane — an answer that ends by asking you questions (checked by Claude Code's small model when the reply's tail has a ?) counts as needing you, like a question dialog;
  • draws a band above the prompt with the project, task, session, diff, PR and Linear ticket (with its state) — both clickable — and the status of sibling sessions (from showrunner session info);
  • adds /ticket, which opens the task's Linear ticket (description, labels, latest comments) in a pane (from showrunner session ticket);
  • adds /pr, which opens the task's PR in a pane: checks (failing and pending ones linked), review state and unresolved review comments, with a Send to Claude button that puts them in the prompt (from showrunner session pr);
  • keeps the session record on the conversation the session runs, so a session recreated after /clear resumes the right one.

Ticket state and content need a Linear token: LINEAR_API_KEY, or on macOS the one the linear CLI keeps in the keychain. Clickable links inside tmux need set -as terminal-features ',*:hyperlinks'.

Outside showrunner sessions it does nothing. Install it for every session on a machine:

/plugin install showrunner-mod --marketplace Bendzae/showrunner

<img src="https://github.com/openai.png" height="20" alt=""/> Codex CLI

codex — must be logged in via codex login.

  • Launches in yolo mode (--dangerously-bypass-approvals-and-sandbox). The work dir is pre-trusted via a [projects] entry in ~/.codex/config.toml, since the first-launch trust dialog appears even in yolo mode.
  • Codex has no system-prompt flag, so the session briefing is prepended to the first message.
  • The showrunner skills are installed as plain SKILL.md folders under .agents/skills/.
  • Dead sessions resume with codex resume --last (scoped to the work dir).

<img src="https://pi.dev/logo.svg" height="20" alt=""/> Pi

pi — needs a provider and default model configured (/login inside pi, or defaultProvider/defaultModel in ~/.pi/agent/settings.json).

  • No per-command approvals by design. Launches with --approve, which pre-trusts the project-local files showrunner injects (.agents/skills/) so the one-time project-trust dialog never blocks a session; the briefing is passed via --append-system-prompt.
  • The showrunner skills are installed under .agents/skills/, which pi discovers natively.
  • Dead sessions resume with pi --continue (scoped to the work dir).

Feature support

All showrunner features work with every harness — what differs is the mechanism:

Claude CodeCodex CLIPi
Session briefing--append-system-promptprepended to first message--append-system-prompt
Showrunner skillsplugin (slash commands).agents/skills/.agents/skills/
Auto-approval--dangerously-skip-permissionsyolo flag + pre-trusted work diryolo by design, --approve for project files
Attention dialogspermission & question promptsselectors (trust dialog etc.)trust dialog & modal selectors
Resumeclaude --continuecodex resume --lastpi --continue
Initial prompt, startup skills, status detection, ask/send/output✓✓✓

Mobile web UI (serve)

showrunner serve starts an HTTP server with a phone-friendly web UI for managing sessions remotely — view session statuses, read live output, send messages and keys (permission prompts included), review diffs, and create projects, tasks, sessions and adhoc sessions, delete tasks, or kill sessions.

showrunner serve                          # default 127.0.0.1:7878
showrunner serve --bind 0.0.0.0:7878     # bind another interface

The server has no authentication — keep it on localhost and expose it through your tailnet:

tailscale serve --bg 7878

This gives you a valid-HTTPS URL reachable only from your own devices. Open it on your phone and use "Add to Home Screen" to install it as an app.

CLI

Besides the TUI and serve, the binary exposes the same task/session operations as commands. They act on the shared state in ~/.showrunner/, so a running TUI picks the changes up on its next refresh. Agent

Source 2 files
hooks/register.tsx 603 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  Loaded,
6  PullRequest,
7  SessionInfo,
8  Sibling,
9  Status,
10  Thread,
11  TicketView,
12} from '../types'
13
14const info = atom({ plugin: 'showrunner-mod', key: 'info' } as const, null)
15const ticket = atom({ plugin: 'showrunner-mod', key: 'ticket' } as const, null)
16
17const pr = atom({ plugin: 'showrunner-mod', key: 'pr' } as const, null)
18
19const moduleTags = { plugin: 'monorepo-touched', key: 'tags' } as const
20
21/** The band's colors, each toned a little toward a dark background. */
22const PALETTE = {
23  text: '#F3F4F6',
24  project: tone('#7C3AED'),
25  task: tone('#2563EB'),
26  adhoc: tone('#475569'),
27  session: tone('#F59E0B'),
28  ok: tone('#22C55E'),
29  bad: tone('#DC2626'),
30  warn: tone('#D97706'),
31  idle: tone('#94A3B8'),
32  prOpen: tone('#16A34A'),
33  prFailing: tone('#DC2626'),
34  prPending: tone('#D97706'),
35  prMerged: tone('#8B5CF6'),
36  prDraft: tone('#6B7280'),
37  linear: tone('#5E6AD2'),
38  border: tone('#22D3EE'),
39} as const
40
41const TICKET_PANE = 'ticket'
42const PR_PANE = 'pr'
43
44// Tools that wait on the person even when permissions are skipped.
45const ASKS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
46
47const REFRESH_MS = 30_000
48
49let tmuxName: string | undefined
50let reported: Status | undefined
51let conversation: string | undefined
52
53/** Keep the session record on the conversation this session runs, so a
54 * recreated session resumes it (it changes on /clear). */
55async function syncConversation($: EngineInterface) {
56  if (tmuxName === undefined) return
57  const id = await $.session.id()
58  if (id === conversation) return
59  conversation = id
60  await $.process
61    .run(['showrunner', 'session', 'set-conversation', tmuxName, id])
62    .catch(() => undefined)
63}
64
65/** Whether an answer ends by asking the person something they need to
66 * answer: only a closing `?` is worth a quick check by the small model. */
67async function asksPerson($: EngineInterface, answer: string): Promise<boolean> {
68  const tail = answerTail(answer)
69  if (!tail.includes('?')) return false
70  const verdict = await $.model
71    .classify(
72      `Does this end of an assistant's reply ask the user one or more questions they need to answer before work can continue? Offers like "Want me to …?" count only if the reply waits on the answer.\n\n${tail}`,
73      ['asks', 'done'],
74    )
75    .catch(() => undefined)
76  return verdict === 'asks'
77}
78
79async function loadPr($: EngineInterface) {
80  if (tmuxName === undefined) return
81  await update($, pr, (): Loaded<PullRequest> => ({ status: 'loading' }))
82  const ran = await $.process
83    .run(['showrunner', 'session', 'pr', tmuxName], { timeoutMs: 30_000 })
84    .catch(() => undefined)
85  const view: Loaded<PullRequest> =
86    ran?.exitCode === 0
87      ? { status: 'ready', value: JSON.parse(ran.stdout) }
88      : {
89          status: 'error',
90          message: failure(ran?.stderr),
91        }
92  await update($, pr, () => view)
93}
94
95async function sendThreadsToClaude($: EngineInterface, threads: Thread[]) {
96  const text = threads
97    .map(
98      t =>
99        `- ${t.path}${t.line ? `:${t.line}` : ''} (${t.author ?? 'reviewer'}): ${firstParagraph(t.body)}`,
100    )
101    .join('\n')
102  await $.prompt.fill({
103    text: `Address these unresolved review comments on the PR:\n${text}\n`,
104    mode: 'insert',
105  })
106}
107
108async function report($: EngineInterface, status: Status) {
109  if (tmuxName === undefined || status === reported) return
110  reported = status
111  await $.process
112    .run(['tmux', 'set-environment', '-t', tmuxName, 'CM_STATUS', status])
113    .catch(() => undefined)
114}
115
116async function refresh($: EngineInterface) {
117  if (tmuxName === undefined) return
118  const ran = await $.process
119    .run(['showrunner', 'session', 'info', tmuxName], { timeoutMs: 20_000 })
120    .catch(() => undefined)
121  if (ran?.exitCode !== 0) return
122  const parsed = JSON.parse(ran.stdout) as SessionInfo
123  await update($, info, () => parsed)
124}
125
126/** What a failed `showrunner` call says, or that showrunner needs updating. */
127function failure(stderr: string | undefined): string {
128  const message = stderr?.replace(/^Error: /, '').trim()
129  if (message?.includes('unknown session command')) {
130    return 'This needs a newer showrunner (0.29.5 or later): update it and try again.'
131  }
132  return message || 'showrunner did not answer'
133}
134
135async function loadTicket($: EngineInterface) {
136  if (tmuxName === undefined) return
137  await update($, ticket, (): TicketView => ({ status: 'loading' }))
138  const ran = await $.process
139    .run(['showrunner', 'session', 'ticket', tmuxName], { timeoutMs: 20_000 })
140    .catch(() => undefined)
141  const view: TicketView =
142    ran?.exitCode === 0
143      ? { status: 'ready', ticket: JSON.parse(ran.stdout) }
144      : {
145          status: 'error',
146          message: failure(ran?.stderr),
147        }
148  await update($, ticket, () => view)
149}
150
151export const register: Register = on => {
152  tmuxName = undefined
153  reported = undefined
154  conversation = undefined
155
156  on('session.start', async ($, e, next) => {
157    const started = await next(e)
158    if (!e.isInteractive) return started
159
160    const pane = await $.env.get('TMUX_PANE')
161    if (pane === undefined) return started
162    const ran = await $.process
163      .run(['tmux', 'display-message', '-p', '-t', pane, '#S'])
164      .catch(() => undefined)
165    const name = ran?.stdout.trim()
166    if (!name?.startsWith('cm__')) return started
167
168    tmuxName = name
169    await $.command
170      .register({ name: 'ticket', description: "Show this task's Linear ticket in a pane" })
171      .catch(() => undefined)
172    await $.command
173      .register({ name: 'pr', description: "Show this task's PR: checks and open review comments" })
174      .catch(() => undefined)
175    await syncConversation($)
176    await report($, 'waiting_input')
177    void refresh($)
178    $.clock.every(REFRESH_MS, () => void refresh($))
179    return started
180  })
181
182  on('command.run', { command: 'ticket' }, async $ => {
183    const current = await read($, info)
184    const id = current?.linear ? linearId(current.linear) : undefined
185    if (id === undefined) return { text: 'No Linear ticket is linked to this task.' }
186    await $.ui.open({ id: TICKET_PANE, title: id, columns: 72, closeOnEscape: true })
187    void loadTicket($)
188    return { text: `Opened ${id}.` }
189  })
190
191  on('ui.render', { component: 'Pane', requestId: TICKET_PANE }, async ($, e) => {
192    const { Box, Link, Markdown, Text } = $.ui.resolve(e)
193    const view = await read($, ticket)
194    if (view === null || view.status === 'loading') {
195      return <Text color="subtle">Loading ticket…</Text>
196    }
197    if (view.status === 'error') {
198      return <Text color="error">{view.message}</Text>
199    }
200    const t = view.ticket
201    const comments = t.comments.nodes
202    return (
203      <Box flexDirection="column" gap={1} paddingX={1}>
204        <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
205          <Link href={t.url}>
206            <Pill Text={Text} bg={tone(t.state.color)} fg={PALETTE.text} bold>
207              {ticketGlyph(t.state.type)} {t.identifier} · {t.state.name}
208            </Pill>
209          </Link>
210          {t.labels.nodes.map(label => (
211            <Text key={label.name} color={label.color}>
212              ● {label.name}
213            </Text>
214          ))}
215        </Box>
216        <Text bold>{t.title}</Text>
217        <Text color="subtle">
218          {t.assignee ? `👤 ${t.assignee.displayName}` : '👤 unassigned'}
219          {t.priorityLabel !== 'No priority' ? `  ⚑ ${t.priorityLabel}` : ''}
220          {t.creator ? `  ✎ ${t.creator.displayName}` : ''}
221        </Text>
222        <Markdown text={t.description?.trim() || '_No description._'} />
223        {comments.length > 0 ? (
224          <Box flexDirection="column" gap={1}>
225            <Text bold color="claude">
226              💬 {comments.length} comment{comments.length === 1 ? '' : 's'}
227            </Text>
228            {comments.map(comment => (
229              <Box key={comment.createdAt} flexDirection="column">
230                <Text color="subtle">
231                  {comment.user?.displayName ?? 'someone'} · {comment.createdAt.slice(0, 10)}
232                </Text>
233                <Markdown text={comment.body} />
234              </Box>
235            ))}
236          </Box>
237        ) : null}
238      </Box>
239    )
240  })
241
242  on('command.run', { command: 'pr' }, async $ => {
243    const current = await read($, info)
244    if (current === null || current.task === null) return { text: 'Adhoc sessions have no PR.' }
245    await $.ui.open({ id: PR_PANE, title: 'PR', columns: 80, closeOnEscape: true })
246    void loadPr($)
247    return { text: 'Opened the PR pane.' }
248  })
249
250  on('ui.render', { component: 'Pane', requestId: PR_PANE }, async ($, e) => {
251    const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
252    const view = await read($, pr)
253    if (view === null || view.status === 'loading') {
254      return <Text color="subtle">Loading PR…</Text>
255    }
256    if (view.status === 'error') {
257      return <Text color="error">{view.message}</Text>
258    }
259    const p = view.value
260    const count = (state: string) => p.checks.filter(c => c.state === state).length
261    const attention = p.checks.filter(c => c.state === 'fail' || c.state === 'pending')
262    const checksState = count('fail') > 0 ? 'failing' : count('pending') > 0 ? 'pending' : null
263    return (
264      <Box flexDirection="column" gap={1} paddingX={1}>
265        <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
266          <Link href={p.url}>
267            <Pill Text={Text} bg={prColor(p.state, checksState)} fg={PALETTE.text} bold>
268              {prIcon(p.state, checksState)} PR #{p.number} {p.state}
269            </Pill>
270          </Link>
271          {p.review ? <Text color={reviewColor(p.review)}>{reviewLabel(p.review)}</Text> : null}
272          <Text>
273            <Text color="diffAdded" bold>
274              +{p.additions}
275            </Text>{' '}
276            <Text color="diffRemoved" bold>
277              −{p.deletions}
278            </Text>
279            <Text color="subtle"> in {p.changed_files} files</Text>
280          </Text>
281        </Box>
282        <Text bold>{p.title}</Text>
283        <Text>
284          <Text color={PALETTE.ok}>✓ {count('pass')} passed</Text>
285          <Text color="subtle"> · </Text>
286          <Text color={PALETTE.bad}>✗ {count('fail')} failed</Text>
287          <Text color="subtle"> · </Text>
288          <Text color={PALETTE.warn}>◷ {count('pending')} pending</Text>
289          <Text color="subtle"> · ⤼ {count('skipped')} skipped</Text>
290        </Text>
291        {attention.map((check, i) => (
292          <Text key={`check-${i}`}>
293            <Text color={check.state === 'fail' ? PALETTE.bad : PALETTE.warn}>
294              {check.state === 'fail' ? '✗' : '◷'}{' '}
295            </Text>
296            {check.url ? (
297              <Link href={check.url}>{check.name ?? 'check'}</Link>
298            ) : (
299              <Text>{check.name ?? 'check'}</Text>
300            )}
301          </Text>
302        ))}
303        <Box flexDirection="row" columnGap={1}>
304          <Text bold color="claude">
305            💬 {p.threads.length} unresolved review comment{p.threads.length === 1 ? '' : 's'}
306          </Text>
307          {p.threads.length > 0 ? (
308            <Button
309              key="send"
310              label="Send to Claude"
311              onPress={() => sendThreadsToClaude($, p.threads)}
312            />
313          ) : null}
314          <Button key="refresh" label="Refresh" onPress={() => loadPr($)} />
315        </Box>
316        {p.threads.map((thread, i) => (
317          <Box key={`thread-${i}`} flexDirection="column">
318            <Text>
319              <Link href={thread.url}>
320                {thread.path.split('/').pop()}
321                {thread.line ? `:${thread.line}` : ''}
322              </Link>
323              <Text color="subtle"> · {thread.author ?? 'reviewer'}</Text>
324            </Text>
325            <Markdown text={firstParagraph(thread.body)} />
326          </Box>
327        ))}
328      </Box>
329    )
330  })
331
332  on('session.end', async ($, e, next) => {
333    if (tmuxName !== undefined) {
334      await $.process
335        .run(['tmux', 'set-environment', '-u', '-t', tmuxName, 'CM_STATUS'])
336        .catch(() => undefined)
337    }
338    return next(e)
339  })
340
341  on('turn.start', async ($, e, next) => {
342    await report($, 'running')
343    await syncConversation($)
344    return next(e)
345  })
346
347  on('turn.complete', async ($, e, next) => {
348    if (e.agentId === undefined) {
349      const asks = e.reason === 'answer' && (await asksPerson($, e.answer))
350      await report($, asks ? 'waiting_permission' : 'waiting_input')
351      void refresh($)
352    }
353    return next(e)
354  })
355
356  on('tool.check', async ($, e, next) => {
357    const verdict = await next(e)
358    if (verdict.decision === 'ask') await report($, 'waiting_permission')
359    return verdict
360  }).catch(($, e, next) => next(e))
361
362  on('tool.call', async ($, e, next) => {
363    if (ASKS_PERSON.has(e.tool)) await report($, 'waiting_permission')
364    const ran = await next(e)
365    if (reported === 'waiting_permission') await report($, 'running')
366    return ran
367  }).catch(($, e, next) => next(e))
368
369  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
370    const hint = await next(e)
371    const current = await read($, info)
372    if (current === null) return hint
373
374    const { Box, Link, Text } = $.ui.resolve(e)
375    const diff = current.task_diff ?? current.session_diff
376    const isAdhoc = current.task === null
377    const ticket = current.linear ? linearId(current.linear) : undefined
378    const ticketState = current.linear_state ?? undefined
379    const { value: tags } = await $.state.get(moduleTags)
380
381    return (
382      <Box flexDirection="column">
383        {hint}
384        <Box flexDirection="row" flexWrap="wrap">
385          <Box
386            flexDirection="row"
387            flexWrap="wrap"
388            columnGap={1}
389            borderStyle="round"
390            borderColor={PALETTE.border}
391            paddingX={1}
392          >
393            <Pill Text={Text} bg={PALETTE.project} fg={PALETTE.text} bold>
394              ⌂ {current.project}
395            </Pill>
396            <Pill Text={Text} bg={isAdhoc ? PALETTE.adhoc : PALETTE.task} fg={PALETTE.text} bold>
397              {isAdhoc ? '✦ adhoc' : `◆ ${current.task}`}
398            </Pill>
399            <Text color={PALETTE.session} bold>
400              ▸ {current.session}
401            </Text>
402            {diff && diff.added + diff.removed > 0 ? (
403              <Text>
404                <Text color="diffAdded" bold>
405                  +{diff.added}
406                </Text>{' '}
407                <Text color="diffRemoved" bold>
408                  −{diff.removed}
409                </Text>
410              </Text>
411            ) : null}
412            {current.pr ? (
413              <Link href={current.pr.url}>
414                <Pill
415                  Text={Text}
416                  bg={prColor(current.pr.state, current.pr.checks)}
417                  fg={PALETTE.text}
418                  bold
419                >
420                  {prIcon(current.pr.state, current.pr.checks)} PR {prNumber(current.pr.url)}{' '}
421                  {current.pr.state}
422                </Pill>
423              </Link>
424            ) : null}
425            {ticket && current.linear ? (
426              <Link href={current.linear}>
427                <Pill
428                  Text={Text}
429                  bg={ticketState ? tone(ticketState.color) : PALETTE.linear}
430                  fg={PALETTE.text}
431                  bold
432                >
433                  {ticketGlyph(ticketState?.type)} {ticket}
434                  {ticketState ? ` · ${ticketState.name}` : ''}
435                </Pill>
436              </Link>
437            ) : null}
438            {current.siblings.map(sibling => (
439              <Text key={sibling.name}>
440                <Text color={statusColor(sibling)}>{statusGlyph(sibling)}</Text>
441                <Text color="subtle"> {sibling.name}</Text>
442              </Text>
443            ))}
444          </Box>
445          {tags?.length ? (
446            <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingX={1} paddingY={1}>
447              {tags.map(tag => (
448                <Text key={tag.label}>
449                  <Pill Text={Text} bg={tag.background} fg={tag.text} bold>
450                    {tag.label}
451                  </Pill>
452                  {tag.added > 0 ? <Text color="diffAdded">{` +${tag.added}`}</Text> : null}
453                  {tag.removed > 0 ? <Text color="diffRemoved">{` −${tag.removed}`}</Text> : null}
454                </Text>
455              ))}
456            </Box>
457          ) : null}
458        </Box>
459      </Box>
460    )
461  })
462}
463
464function Pill({
465  Text,
466  bg,
467  fg,
468  bold,
469  children,
470}: {
471  Text: any
472  bg: string
473  fg: string
474  bold?: boolean
475  children?: unknown
476}) {
477  return (
478    <Text backgroundColor={bg} color={fg} bold={bold}>
479      {' '}
480      {children}{' '}
481    </Text>
482  )
483}
484
485/** The last stretch of an answer, code blocks left out. */
486function answerTail(answer: string): string {
487  return answer
488    .replace(/```[\s\S]*?```/g, '')
489    .trim()
490    .slice(-800)
491}
492
493function reviewLabel(review: string): string {
494  switch (review) {
495    case 'APPROVED':
496      return '✓ approved'
497    case 'CHANGES_REQUESTED':
498      return '✗ changes requested'
499    default:
500      return '◷ review required'
501  }
502}
503
504function reviewColor(review: string): string {
505  if (review === 'APPROVED') return PALETTE.ok
506  if (review === 'CHANGES_REQUESTED') return PALETTE.bad
507  return PALETTE.warn
508}
509
510/** A review comment's text without bot footers, badges and HTML. */
511function firstParagraph(body: string): string {
512  return body
513    .split(/\n\s*\n/)
514    .filter(part => !/^\s*(\[!\[|<|!\[)/.test(part))
515    .slice(0, 2)
516    .join('\n\n')
517    .trim()
518}
519
520function statusGlyph(sibling: Sibling): string {
521  switch (sibling.status) {
522    case 'running':
523      return '●'
524    case 'waiting_permission':
525      return '▲'
526    case 'waiting_input':
527      return '○'
528    default:
529      return '·'
530  }
531}
532
533function statusColor(sibling: Sibling): string {
534  switch (sibling.status) {
535    case 'running':
536      return PALETTE.ok
537    case 'waiting_permission':
538      return PALETTE.warn
539    case 'waiting_input':
540      return PALETTE.idle
541    default:
542      return 'subtle'
543  }
544}
545
546function prColor(state: string, checks: string | null): string {
547  if (state === 'merged') return PALETTE.prMerged
548  if (state === 'closed' || checks === 'failing') return PALETTE.prFailing
549  if (state === 'draft') return PALETTE.prDraft
550  if (checks === 'pending') return PALETTE.prPending
551  return PALETTE.prOpen
552}
553
554function prIcon(state: string, checks: string | null): string {
555  if (state === 'merged') return '⇄'
556  if (state === 'closed') return '✕'
557  if (checks === 'failing') return '✗'
558  if (checks === 'pending') return '◷'
559  return '✓'
560}
561
562function prNumber(url: string): string {
563  return `#${url.split('/').pop()}`
564}
565
566function linearId(url: string): string | undefined {
567  return url.match(/\/issue\/([A-Za-z]+-\d+)/)?.[1]?.toUpperCase()
568}
569
570/** Linear's workflow state types, drawn the way Linear's own icons read. */
571function ticketGlyph(type: string | undefined): string {
572  switch (type) {
573    case 'completed':
574      return '✔'
575    case 'canceled':
576      return '✕'
577    case 'started':
578      return '◐'
579    case 'unstarted':
580      return '○'
581    case 'backlog':
582    case 'triage':
583      return '◌'
584    default:
585      return '◇'
586  }
587}
588
589/** A `#rrggbb` color blended a little (22%) toward a dark background. */
590function tone(hex: string): string {
591  const rgb = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
592  if (!rgb) return hex
593  const base = [0x1f, 0x20, 0x23]
594  return (
595    '#' +
596    rgb
597      .slice(1)
598      .map((c, i) => Math.round(parseInt(c, 16) * 0.78 + base[i]! * 0.22))
599      .map(v => v.toString(16).padStart(2, '0'))
600      .join('')
601  )
602}
603
types/index.d.ts 82 lines
1export type Status = 'running' | 'waiting_input' | 'waiting_permission'
2
3export type Diff = { added: number; removed: number }
4
5export type Sibling = { name: string; agent: string; status: Status | null }
6
7export type SessionInfo = {
8  project: string
9  task: string | null
10  session: string
11  branch: string | null
12  base_branch?: string
13  linear?: string | null
14  linear_state?: TicketState | null
15  task_diff?: Diff | null
16  session_diff: Diff | null
17  pr?: { url: string; state: string; checks: string | null; review: string | null } | null
18  siblings: Sibling[]
19}
20
21export type TicketState = { name: string; color: string; type: string }
22
23export type Ticket = {
24  identifier: string
25  title: string
26  url: string
27  description: string | null
28  priorityLabel: string
29  state: TicketState
30  assignee: { displayName: string } | null
31  creator: { displayName: string } | null
32  labels: { nodes: { name: string; color: string }[] }
33  comments: { nodes: { body: string; createdAt: string; user: { displayName: string } | null }[] }
34}
35
36export type Check = {
37  name: string | null
38  state: 'pass' | 'fail' | 'pending' | 'skipped'
39  url: string | null
40}
41
42export type Thread = {
43  path: string
44  line: number | null
45  author: string | null
46  body: string
47  url: string
48}
49
50export type PullRequest = {
51  number: number
52  title: string
53  url: string
54  state: 'open' | 'draft' | 'merged' | 'closed'
55  review: string | null
56  additions: number
57  deletions: number
58  changed_files: number
59  checks: Check[]
60  threads: Thread[]
61}
62
63export type Loaded<T> =
64  { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; value: T }
65
66export type TicketView =
67  { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; ticket: Ticket }
68
69/** Tags the monorepo-touched mod publishes, drawn beside the band. */
70export type ModuleTag = { label: string; background: string; text: string; added: number; removed: number }
71
72declare module 'claude-code' {
73  interface PluginState {
74    'showrunner-mod': {
75      info: SessionInfo | null
76      ticket: TicketView | null
77      pr: Loaded<PullRequest> | null
78    }
79    'monorepo-touched': { tags: ModuleTag[] | null }
80  }
81}
82