Status: Ready Created: 2026-03-09 Language: Go License: MIT Prior Art: androsovm/clorch (Python/Textual)
Running multiple Claude Code sessions in parallel is increasingly common — split a spec into sub-tasks, launch 5-10 agents across tmux windows, and let them work. The bottleneck isn't the agents. It's the human. You're alt-tabbing between terminals, hunting for permission prompts buried in scroll-back, and losing track of which agent is idle vs. blocked.
The Python clorch solved this with a Textual TUI. It works. But it carries Python as a runtime dependency, pulls in a TUI framework with its own event loop, and requires pip install in a world where the rest of the toolchain (claude, tmux, jq) is native binaries. A Go rewrite eliminates the runtime dependency, ships as a single static binary, and opens the door to tighter integration with the agentic runtime platform.
- Single static binary.
go installor download. No Python, no pip, no virtualenv. - Hook-based state ingestion. Read Claude Code hook events via the same file-based protocol — shell scripts write JSON to a state directory, the dashboard reads it. No terminal scraping, no API calls.
- TUI dashboard. Bubble Tea-based interface showing all active sessions, their status, pending permissions, and git context. Real-time updates via filesystem polling.
- Permission management. Approve/deny tool requests from the dashboard. Batch approve. YOLO mode with configurable deny rules.
- tmux integration. Jump to any agent's tmux pane with a keystroke. tmux status-bar widget for at-a-glance counts.
- Multi-terminal support. Navigate to agent sessions in iTerm2, Ghostty, and Apple Terminal via native APIs (osascript/AppleScript).
- Notifications. macOS native notifications, terminal bell, system sounds. Configurable per-event.
- Usage tracking. Parse Claude Code session logs for token usage, calculate cost by model, show burn rate.
- Rules engine. YAML-based auto-approve/deny rules. First-match-wins. Deny rules override YOLO.
- Hook installer.
clorch initinstalls hooks into~/.claude/settings.jsonnon-destructively (backup + merge).
- Launching or managing Claude Code processes. Clorch observes — it doesn't spawn agents. You start agents yourself in tmux; clorch discovers them via hooks.
- Web UI or remote access. Terminal only. If you want remote, run clorch inside tmux and SSH in.
- Multi-user. Single operator, single machine.
- Custom TUI theming. Ship one theme (Nord-derived). If someone wants to change colors, they can fork.
- Windows support. macOS and Linux only.
┌──────────────────────────────────────────────────────────────┐
│ clorch (single binary) │
│ │
│ ┌───────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ TUI │ │ State │ │ Hook Installer │ │
│ │ (Bubble │←→│ Manager │ │ (init / uninstall) │ │
│ │ Tea) │ │ │ └───────────────────────┘ │
│ └─────┬─────┘ └──────┬───────┘ │
│ │ │ │
│ │ ┌──────┴───────┐ │
│ │ │ State Dir │ /tmp/clorch/state/*.json │
│ │ │ (filesystem │←── Hook scripts write here │
│ │ │ polling) │ │
│ │ └──────────────┘ │
│ │ │
│ ┌─────┴─────────────────────────────────────────────────┐ │
│ │ Subsystems │ │
│ │ │ │
│ │ ┌─────────────┐ ┌────────────┐ ┌──────────────────┐ │ │
│ │ │ Rules │ │ Notifier │ │ Usage Tracker │ │ │
│ │ │ Engine │ │ │ │ │ │ │
│ │ │ (YAML) │ │ - bell │ │ - JSONL parser │ │ │
│ │ │ - approve │ │ - sound │ │ - cost calc │ │ │
│ │ │ - deny │ │ - macOS │ │ - burn rate │ │ │
│ │ │ - yolo │ │ notify │ │ │ │ │
│ │ └─────────────┘ └────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ tmux │ │ Terminal Backends │ │ │
│ │ │ │ │ │ │ │
│ │ │ - navigate │ │ - iTerm2 (osascript) │ │ │
│ │ │ - send-keys │ │ - Ghostty (osascript) │ │ │
│ │ │ - widget │ │ - Apple Terminal (osascript) │ │ │
│ │ │ - session │ │ - detect (TERM_PROGRAM) │ │ │
│ │ └─────────────┘ └────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
External data flow:
Claude Code hooks (event_handler.sh, notify_handler.sh)
→ atomic JSON write to /tmp/clorch/state/<session_id>.json
→ clorch watches state dir via fsnotify (fallback: 500ms poll)
→ TUI updates, notifications fire, rules evaluate
The state directory is the sole communication channel between Claude Code and clorch. Hook scripts are the writers; clorch is the reader.
State file: /tmp/clorch/state/<session_id>.json
{
"session_id": "abc123",
"status": "WORKING",
"cwd": "/home/user/myproject",
"project_name": "myproject",
"model": "claude-opus-4-6",
"last_event": "PreToolUse",
"last_event_time": "2026-03-09T10:05:30Z",
"last_tool": "Bash",
"notification_message": null,
"tool_request_summary": null,
"started_at": "2026-03-09T10:00:00Z",
"tool_count": 47,
"error_count": 1,
"subagent_count": 2,
"compact_count": 0,
"last_compact_time": "",
"task_completed_count": 0,
"activity_history": [0, 0, 2, 5, 3, 1, 0, 4, 2, 1],
"pid": 54321,
"git_branch": "feat/new-thing",
"git_dirty_count": 3,
"tmux_window": "agent-0",
"tmux_pane": "0",
"tmux_session": "claude",
"tmux_window_index": "3",
"term_program": "iTerm.app"
}Full field reference:
| Field | Type | Source | Description |
|---|---|---|---|
session_id |
string | stdin JSON | Claude Code session UUID |
status |
string | derived | IDLE, WORKING, WAITING_PERMISSION, WAITING_ANSWER, ERROR |
cwd |
string | stdin JSON | Agent's working directory |
project_name |
string | derived | basename(cwd) |
model |
string | stdin JSON | Model ID from SessionStart event |
last_event |
string | hook | Name of the most recent hook event |
last_event_time |
string | hook | ISO 8601 UTC timestamp of last event |
last_tool |
string | stdin JSON | Last tool name seen in PreToolUse/PermissionRequest |
notification_message |
string? | stdin JSON | Message from Notification event, cleared on next tool use |
tool_request_summary |
string? | derived | Human-readable summary of pending tool request (see §4.3) |
started_at |
string | hook | When this session's state file was first created |
tool_count |
int | derived | Running count of PreToolUse events |
error_count |
int | derived | Running count of PostToolUseFailure events |
subagent_count |
int | derived | Net active subagents (SubagentStart increments, SubagentStop decrements) |
compact_count |
int | derived | Number of PreCompact events (context compactions) |
last_compact_time |
string | hook | Timestamp of last compaction |
task_completed_count |
int | derived | Number of TaskCompleted events |
activity_history |
int[10] | derived | Sliding window of tool counts per interval (sparkline data) |
pid |
int | $PPID |
Claude Code process PID (parent of hook script) |
git_branch |
string | derived | Current git branch in cwd |
git_dirty_count |
int | derived | `git status --porcelain |
tmux_window |
string | derived | tmux window name (matched via TTY) |
tmux_pane |
string | derived | tmux pane index within window |
tmux_session |
string | derived | tmux session name |
tmux_window_index |
string | derived | tmux window index number |
term_program |
string | $TERM_PROGRAM |
Terminal emulator identifier |
Statuses: IDLE, WORKING, WAITING_PERMISSION, WAITING_ANSWER, ERROR (uppercase, matching upstream)
Atomicity: Hook scripts write to a temp file (mktemp in state dir) and mv into place. No partial reads.
Cleanup: clorch removes state files via three-pass maintenance:
- Dead process removal: If
pidis set, checkkill(pid, 0). IfProcessLookupError→ remove file. - Time-based removal: If no
pid, remove iflast_event_timeis older than 1 hour. - PID deduplication: When multiple state files share the same PID (session restart), keep the one with highest
tool_count, remove the rest.
Stale permission reset: If a state file has WAITING_PERMISSION status but the PID is dead, reset to IDLE and clear tool_request_summary. This handles the case where Claude Code doesn't fire a Stop event after permission denial.
Clorch hooks integrate with Claude Code's native hook system. Full documentation: Claude Code Hooks.
Calling convention: Claude Code invokes hook commands as shell processes. Input is JSON on stdin. Exit code determines behavior (0 = success, 2 = block, other = non-blocking error). Stdout is parsed as JSON on exit 0.
Common stdin fields (all events):
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/<hash>/<session>.jsonl",
"cwd": "/Users/.../my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}Event-specific additional fields:
| Event | Additional stdin fields |
|---|---|
SessionStart |
source ("startup"|"resume"|"clear"|"compact"), model |
PreToolUse |
tool_name, tool_input (object), tool_use_id |
PostToolUse |
tool_name, tool_input, tool_use_id, tool_response |
PostToolUseFailure |
tool_name, tool_input, tool_use_id, error (string), is_interrupt (bool) |
PermissionRequest |
tool_name, tool_input, tool_use_id, permission_suggestions |
Notification |
message, title (optional), notification_type ("permission_prompt"|"idle_prompt"|"auth_success"|"elicitation_dialog") |
Stop |
stop_hook_active, last_assistant_message |
SubagentStart |
agent_id, agent_type |
SubagentStop |
agent_id, agent_type, agent_transcript_path |
TaskCompleted |
task_id, task_subject |
PreCompact |
trigger ("manual"|"auto") |
SessionEnd |
reason |
UserPromptSubmit |
prompt |
Event type passing: The hook command itself doesn't receive the event type as an argument. Upstream solves this by prefixing the command with an env var: CLORCH_EVENT=PreToolUse /path/to/event_handler.sh. The Go templates should generate commands in this format.
Two bash scripts, generated from Go text/template templates embedded in the binary. Regenerated on every clorch init.
Template variables available to .sh.tmpl files:
type HookTemplateData struct {
StateDir string // e.g. "/tmp/clorch/state"
HooksDir string // e.g. "~/.local/share/clorch/hooks"
Version string // clorch version string
Event string // event name for CLORCH_EVENT prefix
}event_handler.sh.tmpl — Registered for: SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, Stop, SessionEnd, PermissionRequest, UserPromptSubmit, SubagentStart, SubagentStop, PreCompact, TeammateIdle, TaskCompleted.
Event handler logic per event type:
| Event | State mutation |
|---|---|
SessionStart |
Create new state file. Set status=IDLE, extract cwd, model, project_name. Detect tmux pane, git context. |
PreToolUse |
Set status=WORKING, last_tool=tool_name, increment tool_count, shift activity_history. Clear notification_message and tool_request_summary. |
PostToolUse |
Set status=WORKING. Clear notification_message and tool_request_summary. |
PostToolUseFailure |
Set status=ERROR. Increment error_count. |
PermissionRequest |
Set status=WAITING_PERMISSION, last_tool=tool_name. Build tool_request_summary from tool_input (see below). |
Stop |
Set status=IDLE unless current status is WAITING_ANSWER (preserve it — Stop fires before the user answers). |
SessionEnd |
Delete the state file. |
UserPromptSubmit |
Set status=WORKING. Clear notification_message and tool_request_summary. |
SubagentStart |
Increment subagent_count. |
SubagentStop |
Decrement subagent_count (floor 0). |
PreCompact |
Increment compact_count, set last_compact_time. Fire notification. |
TaskCompleted |
Increment task_completed_count. |
TeammateIdle |
Update last_event/last_event_time only. |
Tool request summary construction (for PermissionRequest): Build a human-readable summary from tool_input based on tool type:
| Tool | Summary format |
|---|---|
Bash |
$ <command> (truncated to 300 chars) |
Edit |
<file_path> + first 3 lines of old/new with -/+ prefixes |
Write |
<file_path> (N lines) + first 3 lines of content |
Read |
<file_path> |
WebFetch |
<url> |
Grep |
<pattern> in <path> |
Glob |
<pattern> in <path> |
Task (Agent) |
[<subagent_type>] <description> |
| Other | JSON string of tool_input truncated to 300 chars |
All summaries capped at 500 chars.
tmux pane detection: The hook script finds the Claude Code process's TTY via ps -p $PPID -o tty=, then matches it against tmux list-panes -a -F '#{pane_tty}|||#{window_name}|||#{pane_index}|||#{session_name}|||#{window_index}'. This handles the case where a tmux server is running but the agent is in a native terminal tab (no match = no tmux fields set).
VS Code detection: When TERM_PROGRAM is unset, check for VSCODE_PID or VSCODE_IPC_HOOK_CLI env vars, or grep the parent process command for .vscode/extensions/.
notify_handler.sh.tmpl — Registered for Notification events only.
Determines status from the message field via keyword matching:
- Contains "permission" (case-insensitive) →
WAITING_PERMISSION - Contains "question", "input", "answer", or "elicitation" →
WAITING_ANSWER - Otherwise → no status change, just update
notification_message
Also fires terminal bell (\a) and macOS native notification via osascript.
All hooks are registered with "async": true so they don't block Claude Code's execution. Exit code doesn't matter for async hooks.
Scripts are installed to ~/.local/share/clorch/hooks/ and referenced by absolute path in ~/.claude/settings.json.
When the TUI detects a waiting_permission state, it can send approval/denial by writing keystrokes into the agent's tmux pane:
tmux send-keys -t <pane> "y" Enter # approve
tmux send-keys -t <pane> "n" Enter # deny
This is the same mechanism the Python clorch uses. It works because Claude Code's permission prompt reads from stdin.
clorch/
├── cmd/
│ └── clorch/
│ └── main.go # entry point
├── internal/
│ ├── cli/
│ │ └── cli.go # cobra command tree
│ ├── config/
│ │ └── config.go # paths, env vars, defaults
│ ├── hooks/
│ │ ├── installer.go # install/uninstall hooks
│ │ ├── templates.go # go:embed for .sh.tmpl templates
│ │ ├── event_handler.sh.tmpl # hook script template
│ │ └── notify_handler.sh.tmpl # hook script template
│ ├── state/
│ │ ├── models.go # AgentState, ActionItem, StatusSummary
│ │ ├── manager.go # scan state dir, enrich, dedup, cleanup
│ │ ├── watcher.go # fsnotify watcher, fallback to polling
│ │ └── history.go # resolve session names from history.jsonl
│ ├── rules/
│ │ └── rules.go # YAML rule loading, first-match evaluation
│ ├── notify/
│ │ ├── bell.go # terminal bell + tmux bell
│ │ ├── sound.go # macOS afplay system sounds
│ │ └── macos.go # osascript native notifications
│ ├── tmux/
│ │ ├── navigator.go # jump to agent pane, cycle attention
│ │ ├── session.go # session/window/pane management, send-keys
│ │ └── statusbar.go # tmux status-right widget output
│ ├── terminal/
│ │ ├── backend.go # TerminalBackend interface
│ │ ├── detect.go # auto-detect from TERM_PROGRAM
│ │ ├── iterm.go # iTerm2 via osascript
│ │ ├── ghostty.go # Ghostty via osascript
│ │ └── apple.go # Apple Terminal via osascript
│ ├── usage/
│ │ ├── models.go # TokenUsage, SessionUsage, UsageSummary
│ │ ├── parser.go # incremental JSONL parser with byte offset
│ │ ├── pricing.go # per-model cost calculation
│ │ └── tracker.go # rolling window burn rate
│ └── tui/
│ ├── app.go # root Bubble Tea model
│ ├── keys.go # key bindings
│ ├── styles.go # lipgloss styles, Nord palette
│ ├── agent_table.go # agent list view
│ ├── action_queue.go # pending permissions/questions
│ ├── agent_detail.go # expanded single-agent view
│ ├── header.go # title bar with session counts
│ ├── footer.go # context bar with keybinding hints
│ ├── event_log.go # scrollable event history
│ └── settings.go # settings overlay (sound, yolo toggle)
├── go.mod
├── go.sum
└── Makefile
| Dependency | Purpose |
|---|---|
github.com/charmbracelet/bubbletea |
TUI framework |
github.com/charmbracelet/lipgloss |
TUI styling |
github.com/charmbracelet/bubbles |
TUI components (table, viewport, help) |
github.com/spf13/cobra |
CLI command parsing |
gopkg.in/yaml.v3 |
Rules file parsing |
github.com/fsnotify/fsnotify |
Filesystem event watching for state dir |
stdlib os, os/exec, encoding/json, text/template, path/filepath, time |
Everything else |
Seven dependencies. The full go.sum should be auditable in one sitting.
clorch # launch TUI dashboard (default)
clorch init # install hooks into ~/.claude/settings.json
clorch init --dry-run # preview hook changes without writing
clorch uninstall # remove hooks from settings
clorch status # one-line summary for scripting (e.g., "3 working, 1 waiting")
clorch list # table view of all agents (non-interactive)
clorch tmux-widget # output for tmux status-right integration
clorch version # print version and exit
| Variable | Default | Purpose |
|---|---|---|
CLORCH_STATE_DIR |
/tmp/clorch/state |
State file directory |
CLORCH_SESSION |
claude |
tmux session name to manage |
CLORCH_TERMINAL |
auto-detect | Force terminal backend: iterm, ghostty, apple_terminal |
CLORCH_POLL_MS |
500 |
Fallback poll interval when fsnotify unavailable |
CLORCH_RULES |
~/.config/clorch/rules.yaml |
Path to rules file |
┌─────────────────────────────────────────────────────────────┐
│ CLORCH ▪ 4 agents ▪ 2 working 1 idle 1 waiting │ $23 │
├────────────────────────────────────┬────────────────────────┤
│ AGENTS │ ACTIONS │
│ │ │
│ ● agent-0 working feat/auth │ a) agent-2: Bash │
│ /home/user/backend 2s ago │ rm -rf node_mod... │
│ │ │
│ ● agent-1 idle main │ b) agent-3: question │
│ /home/user/frontend 45s ago │ Which test frmwk? │
│ │ │
│ ◉ agent-2 WAITING feat/api │ │
│ /home/user/backend 8s ago │ │
│ │ │
│ ◉ agent-3 QUESTION main │ │
│ /home/user/docs 12s ago │ │
│ │ │
├────────────────────────────────────┴────────────────────────┤
│ j/k:navigate →:jump y/n:approve Y:all !:yolo ?:help │
└─────────────────────────────────────────────────────────────┘
| Key | Action |
|---|---|
j / k |
Move selection up/down in agent list |
Enter / → |
Jump to selected agent's tmux pane |
a-z |
Focus the corresponding action item |
y |
Approve focused permission |
n |
Deny focused permission |
Y |
Approve all pending permissions |
! |
Toggle YOLO mode |
s |
Toggle sound notifications |
d |
Toggle agent detail panel |
? |
Help overlay |
q |
Quit |
Agents that haven't updated recently get visual warnings:
- > 30s idle: Yellow indicator
- > 120s idle: Red indicator
This catches stuck agents or sessions where the hook failed to fire.
~/.config/clorch/rules.yaml:
yolo: false
rules:
- tools: [Read, Glob, Grep]
action: approve
- tools: [Bash]
pattern: "rm -rf"
action: deny
- tools: [Bash]
pattern: "git push --force"
action: deny
- tools: [Edit, Write]
action: approveEvaluation logic:
- Walk rules top-to-bottom. First match wins.
- If no rule matches and YOLO is on → approve.
- If no rule matches and YOLO is off → wait for human.
- Deny rules always require manual review, even in YOLO mode.
Implementation: rules.Evaluate(toolName string, summary string) Action returns Approve, Deny, or Ask.
Pattern matching uses strings.Contains on the tool request summary. No regex — keep it simple, keep it fast.
Parses Claude Code's JSONL session transcripts for token usage data.
Transcripts live at ~/.claude/projects/<project-hash>/<session-uuid>.jsonl. Each line is a JSON object. The format is not formally documented by Anthropic — this is derived from observation and the upstream parser.
Assistant message record (the only type we parse for usage):
{
"type": "assistant",
"parentUuid": "...",
"isSidechain": false,
"cwd": "/Users/.../project",
"sessionId": "abc123",
"version": "1.0.33",
"gitBranch": "main",
"timestamp": "2026-03-09T10:05:30.123Z",
"message": {
"id": "msg_...",
"type": "message",
"role": "assistant",
"model": "claude-opus-4-6-20260301",
"content": [...],
"usage": {
"input_tokens": 12500,
"output_tokens": 3200,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000
}
}
}Parsing strategy:
- Fast pre-filter: skip lines that don't contain
"assistant"(string match before JSON parse). - Navigate to
.message.role == "assistant"→ extract.message.usageand.message.model. - Track four token counters:
input_tokens,output_tokens,cache_creation_input_tokens,cache_read_input_tokens.
Other record types (ignored by usage parser):
type: "user"— user messagestype: "file-history-snapshot"— file state snapshotstype: "custom-title"— session rename via/renamecommand (used by history resolver, see §10.4)
AMBIGUITY FLAG: The transcript JSONL schema is undocumented. Field names, nesting, and record types may change across Claude Code versions. The parser should be defensive — skip records that don't match expected structure rather than erroring.
- File discovery: Scan
~/.claude/projects/*/for*.jsonlfiles modified today (comparest_mtimeagainst midnight local time). - Incremental reads: Track byte offset per file path. On each poll,
Seekto stored offset, read new lines only. Return new offset =file.Tell()after reading. - Full rescan: Every 60 seconds, reset all offsets and re-discover files. Catches rotated/new files.
- Token aggregation: Sum across all files into
TokenUsage{InputTokens, OutputTokens, CacheCreationTokens, CacheReadTokens}.
var Pricing = map[string]ModelPrice{
"opus-4-6": {Input: 15.0, Output: 75.0}, // per 1M tokens
"opus-4-5": {Input: 15.0, Output: 75.0},
"sonnet-4-6": {Input: 3.0, Output: 15.0},
"haiku-4-5": {Input: 0.80, Output: 4.0},
}Model name resolved from the full model ID (e.g. claude-opus-4-6-20260301) via prefix matching — strip the date suffix, match against known keys.
Rolling 10-minute window. Calculate tokens consumed in the window, extrapolate to $/hour. Displayed in the TUI header.
Session display names are resolved from two sources (highest priority first):
-
Custom title: Scan transcript
<session_id>.jsonlfor records with{"type": "custom-title", "customTitle": "..."}. These are written when the user runs/renamein Claude Code. -
History file:
~/.claude/history.jsonlcontains one JSON object per line:{"sessionId": "abc123", "display": "fix the auth bug", ...}The
displayfield is typically the first user prompt. Use the first occurrence per session ID.
Both sources are mtime-cached — only re-read when the file changes on disk.
Claude Code hooks are defined in ~/.claude/settings.json under the hooks key. Each event maps to an array of matcher groups:
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "CLORCH_EVENT=PreToolUse ~/.local/share/clorch/hooks/event_handler.sh",
"async": true
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.local/share/clorch/hooks/notify_handler.sh",
"async": true
}
]
}
]
}
}Matcher group structure:
matcher: regex string to filter (empty string = match all). ForPreToolUse/PostToolUse, matches against tool name. ForNotification, matches againstnotification_type.hooks: array of hook handler objects.
Hook handler fields:
type:"command"(the only type clorch uses)command: shell command string. Receives JSON on stdin.async:true— run in background, don't block Claude Code.timeout: optional, defaults to 600s for command type.
Clorch registers one matcher group per event, with matcher: "" (match all) and async: true.
Events clorch registers:
| Event | Script | Command prefix |
|---|---|---|
SessionStart |
event_handler.sh |
CLORCH_EVENT=SessionStart |
PreToolUse |
event_handler.sh |
CLORCH_EVENT=PreToolUse |
PostToolUse |
event_handler.sh |
CLORCH_EVENT=PostToolUse |
PostToolUseFailure |
event_handler.sh |
CLORCH_EVENT=PostToolUseFailure |
Stop |
event_handler.sh |
CLORCH_EVENT=Stop |
SessionEnd |
event_handler.sh |
CLORCH_EVENT=SessionEnd |
PermissionRequest |
event_handler.sh |
CLORCH_EVENT=PermissionRequest |
UserPromptSubmit |
event_handler.sh |
CLORCH_EVENT=UserPromptSubmit |
SubagentStart |
event_handler.sh |
CLORCH_EVENT=SubagentStart |
SubagentStop |
event_handler.sh |
CLORCH_EVENT=SubagentStop |
PreCompact |
event_handler.sh |
CLORCH_EVENT=PreCompact |
TeammateIdle |
event_handler.sh |
CLORCH_EVENT=TeammateIdle |
TaskCompleted |
event_handler.sh |
CLORCH_EVENT=TaskCompleted |
Notification |
notify_handler.sh |
(no prefix needed — dedicated script) |
clorch init:
- Read
~/.claude/settings.json. If it doesn't exist, create{}. - Create timestamped backup:
settings.json.bak.<unix_timestamp>. - Render hook script templates and write to
~/.local/share/clorch/hooks/. Setchmod +x. - Build the hooks structure (14 event entries, all
async: true). - Merge into existing settings:
- For each event key, if it doesn't exist → add the full entry.
- If it exists, scan the matcher group list for any entry where a hook command contains
clorch/hooks/(the marker substring). If found → replace in-place. If not → append.
- Write updated settings JSON with
indent=2.
clorch uninstall:
- For each event key in
hooks, filter out matcher groups where any hook command contains theclorch/hooks/marker substring. - If an event's matcher group list becomes empty, delete the event key.
- If the entire
hooksobject becomes empty, delete it. - Delete hook scripts from
~/.local/share/clorch/hooks/. - Backup before modifying.
clorch init --dry-run: Print the JSON diff that would be applied, write nothing.
Clorch expects agents to run in tmux windows within a configurable session (default: claude). The hook scripts detect the tmux pane ID by matching the agent's TTY against tmux list-panes -a -F '#{pane_tty} #{pane_id}'.
navigator.JumpToAgent(agent AgentState):
- If terminal backend supports native tabs (iTerm2) and agent has a mapped tab → activate tab.
- Otherwise →
tmux select-window -t <window>+tmux select-pane -t <pane>.
navigator.JumpToNextAttention(): Cycle through agents with waiting_permission or waiting_answer status.
clorch tmux-widget outputs a compact string for status-right:
#[fg=#a3be8c]●3 #[fg=#ebcb8b]◉1 #[fg=#bf616a]✕0
(3 working, 1 waiting, 0 errored — using tmux color escapes with Nord palette)
The root Model struct owns all TUI state:
type Model struct {
// State
agents []state.AgentState
summary state.StatusSummary
actionQueue []state.ActionItem
usage usage.UsageSummary
// UI state
selectedIdx int // cursor position in agent list
focusedAction string // letter of focused action item ("a"-"z" or "")
showDetail bool // agent detail panel visible
showHelp bool // help overlay visible
yoloEnabled bool // YOLO mode active
soundEnabled bool // sound notifications active
// Subsystems (not owned — references)
stateManager *state.Manager
watcher *state.Watcher
rules *rules.Engine
notifier *notify.Notifier
navigator *tmux.Navigator
usageTracker *usage.Tracker
// Dimensions
width, height int
}// StateUpdateMsg is sent by the watcher when state files change.
type StateUpdateMsg struct {
Agents []state.AgentState
Summary state.StatusSummary
Queue []state.ActionItem
}
// UsageUpdateMsg is sent by the usage tracker on each poll cycle.
type UsageUpdateMsg struct {
Summary usage.UsageSummary
}
// ApprovalResultMsg is sent after an approve/deny keystroke is dispatched.
type ApprovalResultMsg struct {
SessionID string
Action string // "approved" or "denied"
Err error
}The state.Watcher runs in its own goroutine. When fsnotify (or poll fallback) detects a change in the state directory:
- Watcher calls
stateManager.Scan()to read all state files. - Watcher diffs against its previous snapshot. If changed:
- Watcher calls
program.Send(StateUpdateMsg{...})to inject a message into the Bubble Tea event loop.
The tea.Program reference is passed to the watcher at startup. program.Send() is goroutine-safe.
The usage tracker follows the same pattern — runs in a goroutine, sends UsageUpdateMsg via program.Send() every 10 seconds.
main goroutine
└── tea.Program.Run() ← owns the terminal, processes Msgs
watcher goroutine
└── fsnotify event loop (or ticker for poll fallback)
└── on change → stateManager.Scan() → program.Send(StateUpdateMsg)
usage goroutine
└── ticker (10s interval)
└── usageTracker.Poll() → program.Send(UsageUpdateMsg)
cleanup goroutine
└── ticker (60s interval)
└── stateManager.CleanupStale()
Shutdown sequence:
- User presses
q→Update()returnstea.Quit. tea.Program.Run()returns.- Main calls
watcher.Stop()(closes fsnotify watcher, goroutine exits). - Main calls
usageTracker.Stop()(stops ticker, goroutine exits). - Main calls cleanup goroutine cancel via context.
- Process exits.
All goroutines accept a context.Context for cancellation. No shared mutable state between goroutines — all communication is via program.Send().
- Hook script sets state to
WAITING_PERMISSIONwithtool_request_summary. - Next watcher scan picks up the change →
StateUpdateMsgincludes the agent in the action queue. - TUI renders the action item with a letter key (
a-z). - User approves from clorch: TUI sends
tmux send-keys, then the next hook event (PreToolUseorUserPromptSubmit) updates state toWORKING, clearing the action. - User approves from terminal directly: Same outcome — next hook event clears the state. Clorch doesn't need to know how the permission was resolved.
- User denies: Claude Code doesn't fire a
Stopevent after denial. TheWAITING_PERMISSIONstate becomes stale. The cleanup routine detects the dead PID and resets toIDLE(see §4.2 stale permission reset). - Safety guard: Before sending
tmux send-keys, re-read the state file to confirm the agent is still inWAITING_PERMISSION. Prevents misfire if the state changed between the TUI render and the keypress.
Action queue sort order: WAITING_PERMISSION first (actionable), then WAITING_ANSWER, then ERROR. Within the same tier, agents with tmux panes sort before agents without (ensures approve/deny works).
When a StateUpdateMsg arrives with new WAITING_PERMISSION agents:
- For each new permission agent, call
rules.Evaluate(toolName, summary). - If result is
Approve→ immediately dispatchtmux send-keys "y" Enter. Log to event log. - If result is
Deny→ add to action queue with visual indicator that it was rule-blocked. Do NOT auto-deny (let the human review). - If result is
Ask→ add to action queue normally.
Three notification channels, independently toggleable:
| Channel | Trigger | macOS | Linux |
|---|---|---|---|
| Terminal bell | Any attention event | \a to stdout |
\a to stdout |
| System sound | Permission, question, error | afplay <sound> |
paplay <sound> (if available, else skip) |
| Native notification | Permission, question, error | osascript -e 'display notification ...' |
notify-send (if available, else skip) |
macOS sound mapping (system sounds):
- Permission request →
/System/Library/Sounds/Sosumi.aiff - Question →
/System/Library/Sounds/Ping.aiff - Error →
/System/Library/Sounds/Basso.aiff
Linux sound mapping: Use XDG sound theme if available, otherwise degrade gracefully. Sound and native notification are best-effort on Linux — check for paplay/notify-send on PATH at startup, disable the channel silently if missing.
Notification deduplication: Only fire notifications on state transitions (idle→waiting, not waiting→waiting). The watcher tracks previous status per agent to detect transitions.
Interface:
type TerminalBackend interface {
// GetTTYMap returns a map of TTY device → tab/window identifier
GetTTYMap() (map[string]string, error)
// ActivateTab brings a specific tab to the foreground
ActivateTab(id string) error
// BringToFront raises the terminal application
BringToFront() error
// CanResolveTabs reports whether this backend can map agents to native tabs
CanResolveTabs() bool
}Detection: read TERM_PROGRAM env var, fall back to CLORCH_TERMINAL. Map to backend:
iTerm.app/iTerm2→ iTerm backend (full AppleScript support)ghostty→ Ghostty backend (limited AppleScript, needs Accessibility)Apple_Terminal→ Apple Terminal backend- Anything else → nil backend (tmux-only navigation)
VERSION := $(shell git describe --tags --always --dirty)
build:
go build -ldflags "-s -w -X main.version=$(VERSION)" -o bin/clorch ./cmd/clorch
install:
go install -ldflags "-s -w -X main.version=$(VERSION)" ./cmd/clorchRelease artifacts: clorch-darwin-arm64, clorch-darwin-amd64, clorch-linux-amd64, clorch-linux-arm64. Distributed via GitHub releases and go install.
| Layer | Approach |
|---|---|
| State parsing | Unit tests: feed JSON files into state.Manager, assert AgentState output |
| Rules engine | Unit tests: load YAML, evaluate tool+summary combos, assert actions |
| Hook installer | Unit tests: mock filesystem, verify merge logic preserves existing hooks |
| Usage parser | Unit tests: fixture JSONL files, verify token counts and cost calculations |
| tmux commands | Integration tests: verify command strings without executing (mock exec.Command) |
| TUI | Manual testing. Bubble Tea's teatest package for critical flows if warranted |
The Go version is a clean rewrite, not a port. However, it maintains protocol compatibility:
- Same state directory (
/tmp/clorch/state/) and JSON schema - Same hook scripts (bash, not Python)
- Same approval mechanism (tmux
send-keys) - Same rules file format (
~/.config/clorch/rules.yaml)
Users can switch between Python and Go clorch without reinstalling hooks. Both read the same state files.
Items that need clarification or may break across Claude Code versions:
-
Transcript JSONL schema is undocumented. The
usagefield nesting (.message.usage.input_tokens), record types ("assistant","user","custom-title","file-history-snapshot"), and field names are derived from observation. A Claude Code update could change this structure silently. The parser must be defensive — skip unparseable lines, don't crash on missing fields. -
history.jsonlformat is undocumented. ThesessionIdanddisplayfields are inferred from upstream's parser. The file lives at~/.claude/history.jsonl. Unknown whether this is a stable interface or internal implementation detail. -
Notification message keyword matching is fragile. The notify handler determines
WAITING_PERMISSIONvsWAITING_ANSWERby checking if themessagefield contains "permission", "question", "input", "answer", or "elicitation". If Claude Code changes notification wording, status detection breaks. Thenotification_typefield ("permission_prompt","idle_prompt","elicitation_dialog") would be more reliable — upstream doesn't use it yet but we should prefer it when present and fall back to keyword matching. -
Stopevent after permission denial. Claude Code does not fire aStopevent when the user denies a permission. The session goes to an "Interrupted" state with no hook. This meansWAITING_PERMISSIONcan persist in the state file after the user has already acted. The stale permission reset (§4.2) handles this, but only for dead PIDs. For live sessions where the user denied from the terminal, the state will correct itself on the nextUserPromptSubmitorPreToolUseevent. -
AppleScript for Ghostty. Ghostty's osascript support requires the Accessibility permission to be granted. The spec doesn't detail what AppleScript commands are needed or how to handle the permission prompt. Upstream uses
osascriptbut the exact scripts are in terminal backend files not fully analyzed here.
- Claude Code Hooks Documentation — canonical reference for hook events, stdin schema, exit codes, settings.json format
- androsovm/clorch — upstream Python implementation, source of truth for state protocol and hook scripts
- Bubble Tea — Go TUI framework (Elm architecture)
- fsnotify — cross-platform filesystem notification library (kqueue on macOS, inotify on Linux)
- State dir location.
/tmp/clorch/state. It's ephemeral state that should die on reboot. No reason to complicate this with XDG. - Hook transport.
fsnotifyfor filesystem event watching instead of polling. Adds one dependency but gets sub-100ms response time on both macOS (kqueue) and Linux (inotify). Fall back to 500ms polling if fsnotify init fails. - tmux scope. Single tmux session. One session with many windows is the workflow. Multi-session adds complexity for no real gain.
- Hook scripts. Go
text/templategenerated at install time. Templates allow embedding the state dir path, version metadata, and any future per-install configuration directly into the scripts.clorch initregenerates them on every run so upgrades are automatic.