From db9fd3c4f7de6d5846c986a0dffb1e439c09c5e7 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 29 Apr 2026 16:19:14 -0700 Subject: [PATCH 1/7] docs: add Claude Code support plan --- docs/claude-code-support-plan.md | 789 +++++++++++++++++++++++++++++++ 1 file changed, 789 insertions(+) create mode 100644 docs/claude-code-support-plan.md diff --git a/docs/claude-code-support-plan.md b/docs/claude-code-support-plan.md new file mode 100644 index 0000000..c20439d --- /dev/null +++ b/docs/claude-code-support-plan.md @@ -0,0 +1,789 @@ +# Claude Code support: research and implementation plan + +> Linked issue: [#1 — Add support for Claude Code?](https://github.com/corwinm/coding-agents-tmux/issues/1) + +## Goal + +Add support for **Claude Code** sessions in `coding-agents-tmux` for: + +- pane discovery +- switching and popup navigation +- status line summaries +- higher-fidelity runtime state than simple `pane_current_command === claude` + +The main research question for this doc was: + +- can Claude Code support the same general pattern we already use elsewhere here, where a local integration publishes normalized runtime state by hooking into lifecycle events? + +## Short answer + +**Yes.** Claude Code has a strong hook system and a plugin system that can ship those hooks. + +The cleanest v1 path looks very similar to the existing Codex integration: + +- detect Claude Code panes in tmux +- install or generate Claude Code hook configuration +- have hook events call back into `coding-agents-tmux` +- write normalized state files under this repo's own state directory +- read those state files when rendering tmux UI + +The main difference from Pi is: + +- Pi has a native extension API with JS lifecycle listeners +- Claude Code primarily exposes **hook events** and **plugins that bundle hook definitions** + +So the Claude Code equivalent of a Pi extension is not a TypeScript extension module first; it is a **hook-backed integration**, optionally packaged as a Claude Code plugin later. + +## Research summary + +### 1. Claude Code exposes lifecycle hooks directly + +Claude Code docs include a full hooks reference and hook guide. + +Relevant supported events include: + +- `SessionStart` +- `SessionEnd` +- `UserPromptSubmit` +- `PreToolUse` +- `PermissionRequest` +- `PermissionDenied` +- `PostToolUse` +- `PostToolUseFailure` +- `PostToolBatch` +- `Notification` +- `SubagentStart` +- `SubagentStop` +- `Stop` +- `StopFailure` +- `Elicitation` +- `ElicitationResult` +- `CwdChanged` +- `ConfigChange` +- others not immediately relevant to tmux state + +Those hooks receive JSON on stdin for command hooks and can run shell commands, HTTP handlers, MCP tools, prompt hooks, or agent hooks. + +For this repo, **command hooks** are the most natural fit. + +### 2. Claude Code can package hooks inside a plugin + +Claude Code plugins can include: + +- skills +- agents +- hooks +- MCP servers +- LSP servers +- monitors + +Plugin hooks live in: + +- `hooks/hooks.json` + +and support the same lifecycle events as standalone hooks. + +So, from a capability perspective, **yes, Claude Code can support an “extension-like” packaged integration**. + +### 3. Standalone hooks are probably the best first install surface + +Claude Code supports hooks in: + +- `~/.claude/settings.json` +- `.claude/settings.json` +- `.claude/settings.local.json` +- plugin `hooks/hooks.json` + +That means we have two viable integration shapes: + +#### A. Standalone hook config + +Pros: + +- simple +- close to the existing Codex install model +- can be installed or merged by our CLI +- works without needing a plugin marketplace workflow + +Cons: + +- less “packaged” than a Claude plugin +- modifies Claude settings JSON directly + +#### B. Claude Code plugin with bundled hooks + +Pros: + +- cleaner long-term packaging story +- naturally shareable +- closest conceptual match to “extension support” + +Cons: + +- docs are more plugin/marketplace-oriented for installation +- likely more moving parts for users than a simple hook install +- not clearly the fastest path to working tmux support + +### 4. Claude Code hook events are rich enough for waiting/running/idle detection + +This was the key feasibility question. + +The answer is also **yes**. + +The hooks surface gives enough signal to derive the statuses this repo already uses: + +- `new` +- `running` +- `waiting-question` +- `waiting-input` +- `idle` +- `unknown` + +Important events for that mapping: + +- `SessionStart` → session initialized +- `UserPromptSubmit` → user has started a new turn, Claude is running +- `PreToolUse` / `PostToolUse` / `PostToolBatch` → Claude is still actively working +- `PermissionRequest` → Claude is blocked on permission UI +- `Elicitation` → Claude is blocked on user input requested by an MCP server +- `Stop` → Claude has finished a turn; can classify idle vs waiting from message text +- `SessionEnd` → cleanup state file + +### 5. Claude Code specifically exposes question-like tools/events + +Two especially useful pieces of hook data: + +#### `AskUserQuestion` tool + +`PreToolUse` supports matching on tool name, and Claude Code documents an `AskUserQuestion` tool. + +That means we can explicitly detect when Claude itself is prompting the user. + +This is better than relying only on text heuristics. + +#### `PermissionRequest` and `Elicitation` + +Claude Code has first-class events for: + +- permission dialogs +- MCP elicitation dialogs / responses + +Those are exactly the kinds of states that a tmux integration wants to surface as “waiting on the user”. + +## Recommendation + +## Recommended v1 architecture + +Implement Claude Code support as a **hook-backed runtime provider**, not a plugin-first provider. + +That means: + +1. add Claude Code pane detection +2. add a Claude-specific runtime reader/writer path +3. generate/install Claude hook config that invokes a `coding-agents-tmux` CLI ingest command +4. persist normalized Claude state files under the same state root pattern used for Codex and Pi +5. keep plugin packaging as a later optional layer + +This is the lowest-risk path and fits the current repo architecture best. + +## Why this is the right first step + +### It matches the repo’s existing support patterns + +Current models: + +- OpenCode: bundled plugin writes state +- Codex: hooks write state +- Pi: bundled extension writes state + +Claude Code most naturally matches **Codex-style hook ingestion**. + +### It avoids blocking on plugin packaging details + +Claude Code absolutely can package hooks inside a plugin, but that does not mean plugin packaging needs to be the first implementation milestone. + +We can get working support by using the core lifecycle hook system first. + +### It keeps a single authoritative state format + +Like the other providers, Claude Code should publish normalized state into: + +- `~/.local/state/coding-agents-tmux/claude-state` + +Then tmux rendering stays agent-neutral. + +## Proposed architecture + +### New runtime path + +Add a Claude-specific runtime module, likely: + +- `src/core/claude.ts` + +Responsibilities: + +- read Claude state files +- match them to tmux panes by target / pane id / safe cwd fallback +- classify runtime info into the shared `RuntimeInfo` model +- optionally expose install/template helpers for Claude hook config + +### New state directory + +Use a dedicated state directory: + +- preferred: `~/.local/state/coding-agents-tmux/claude-state` +- legacy alias only if needed later: `~/.local/state/opencode-tmux/claude-state` + +Recommended env overrides: + +- `CODING_AGENTS_TMUX_CLAUDE_STATE_DIR` +- optional legacy alias if we decide rename compatibility matters here too + +### New CLI ingestion command + +Add a command such as: + +- `coding-agents-tmux claude-hook-state` + +Behavior: + +- read one Claude Code hook payload from stdin +- inspect `hook_event_name` +- classify runtime state +- resolve `TMUX_PANE` to tmux target when possible +- write a normalized state file +- refresh tmux clients if needed + +This should be the Claude analogue of: + +- `coding-agents-tmux codex-hook-state` + +### New install/template commands + +Likely commands: + +- `coding-agents-tmux claude-hooks-template` +- `coding-agents-tmux install-claude` + +Suggested behavior: + +#### `claude-hooks-template` + +Print a hook config snippet or settings JSON fragment users can merge into: + +- `.claude/settings.json` +- or `~/.claude/settings.json` + +#### `install-claude` + +Merge managed hooks into: + +- `~/.claude/settings.json` + +This should be conservative and idempotent, similar in spirit to `install-codex`. + +## Event mapping proposal + +Below is a practical first-pass mapping from Claude hook events to this repo’s normalized statuses. + +### `SessionStart` + +Set: + +- `status: "new"` +- `activity: "idle"` +- `detail: "Claude Code session started"` + +### `UserPromptSubmit` + +Set: + +- `status: "running"` +- `activity: "busy"` +- `detail: "Claude Code is processing a user prompt"` + +### `PreToolUse` + +Default for most tools: + +- `status: "running"` +- `activity: "busy"` + +Special cases: + +#### `tool_name === "AskUserQuestion"` + +Classify as waiting instead of running. + +If the payload clearly has option-based questions: + +- `status: "waiting-question"` + +If it is freeform / no options: + +- `status: "waiting-input"` + +This is likely the highest-value explicit waiting signal. + +### `PermissionRequest` + +Set: + +- `status: "waiting-question"` +- `activity: "busy"` +- `detail: "Claude Code is waiting for permission approval"` + +Rationale: + +- it is a structured approval prompt +- from the user’s point of view it behaves like a question/decision state + +### `Elicitation` + +Set: + +- default `status: "waiting-question"` +- `activity: "busy"` + +Potential refinement later: + +- map some elicitation modes to `waiting-input` if the payload clearly describes freeform text entry + +### `PostToolUse`, `PostToolUseFailure`, `PostToolBatch`, `SubagentStart`, `SubagentStop` + +In general, set or preserve: + +- `status: "running"` +- `activity: "busy"` + +These are mostly “Claude is still in the middle of work” signals. + +### `PermissionDenied` + +Likely keep as: + +- `status: "running"` +- `activity: "busy"` + +unless we discover a better user-facing interpretation during implementation. + +### `Stop` + +This is the primary end-of-turn classifier. + +Use `last_assistant_message` to distinguish: + +- `idle` +- `waiting-input` +- `waiting-question` + +Initial heuristic: + +- explicit multiple-choice / select-like language → `waiting-question` +- question / confirmation text → `waiting-input` +- otherwise → `idle` + +This is similar to the current Codex and Pi best-effort stop-time classification, but should be stronger because Claude also gives us earlier explicit waiting events. + +### `Notification` + +Optional in v1. + +Possible uses: + +- `permission_prompt` → waiting-question +- `idle_prompt` → waiting-input or generic waiting +- `elicitation_dialog` → waiting-question +- `elicitation_complete` / `elicitation_response` → clear back to running + +This may be useful as an additional signal layer, but it is not required for the first version. + +### `SessionEnd` + +Remove the state file for the session/pane. + +## Proposed normalized Claude state file + +Suggested shape: + +```ts +interface ClaudeStateFile { + activity?: "busy" | "idle" | "unknown"; + detail?: string; + directory?: string; + paneId?: string | null; + sessionId?: string; + sourceEventType?: string; + status?: "running" | "waiting-question" | "waiting-input" | "idle" | "new" | "unknown"; + target?: string | null; + title?: string; + transcriptPath?: string | null; + updatedAt?: number; + version?: number; +} +``` + +This matches the shape already used for Codex/Pi closely enough to keep implementation straightforward. + +## Hook configuration strategy + +### Recommended managed hook set for v1 + +We do not need every Claude Code event. + +A focused initial set is probably enough: + +- `SessionStart` +- `UserPromptSubmit` +- `PreToolUse` +- `PermissionRequest` +- `Elicitation` +- `ElicitationResult` +- `PostToolUse` +- `PostToolUseFailure` +- `PostToolBatch` +- `Stop` +- `SessionEnd` + +All of them can call the same ingest command: + +```json +{ + "type": "command", + "command": "/path/to/coding-agents-tmux claude-hook-state", + "statusMessage": "Updating Claude Code tmux state" +} +``` + +Then the ingest command decides what to do based on the incoming `hook_event_name` and payload. + +### Config location recommendation + +For the first implementation, support two paths: + +#### Global install + +Update: + +- `~/.claude/settings.json` + +This mirrors the current Codex install approach and gives users one command to enable Claude Code support. + +#### Repo-local template + +Print a template users can redirect into: + +- `.claude/settings.json` + +This is useful for experimentation and for teams that want repo-scoped Claude Code support. + +## Should we also ship a Claude Code plugin? + +## Recommendation: not required for v1, but worth planning + +A Claude Code plugin is absolutely viable later. + +Possible future directory: + +- `plugin/claude-code/` + +Potential contents: + +- `.claude-plugin/plugin.json` +- `hooks/hooks.json` +- maybe scripts if we want a fully self-contained package + +That plugin could call back into: + +- the installed `coding-agents-tmux` CLI +- or scripts bundled in the plugin package + +### Why not make plugin packaging the first milestone? + +Because the real value for tmux support is the **hook event stream**, not the plugin container. + +Plugin packaging is mostly a distribution and install story. + +So the order should be: + +1. make hook-backed support work +2. optionally wrap it in a Claude plugin later + +## Pane detection proposal + +Add Claude Code pane detection in: + +- `src/core/tmux.ts` + +Likely signals: + +- `pane_current_command === claude` +- title hints like `Claude` or `Claude Code`, if they prove stable enough + +Recommendation: + +- start with command-based detection first +- add title hints only if needed after testing + +## Runtime model changes + +### Agent naming + +Use `"claude"` as the agent kind. + +That should be the name used for: + +- internal type values +- CLI filters like `--agent claude` +- pane detection and runtime routing code + +In prose and user-facing documentation we can still refer to the product as **Claude Code** where that is clearer. + +Reason: + +- the executable is `claude` +- it matches existing short internal names like `opencode`, `codex`, and `pi` +- it keeps CLI filters shorter and more natural + +### Type updates + +Likely changes in `src/types.ts`: + +- add Claude agent kind +- add Claude runtime source/provider values such as: + - `claude-hook` + - `claude-preview` + - `claude-command` +- extend provider/debug types as needed + +### Runtime dispatch + +Update runtime attachment so Claude panes do not flow through the OpenCode/Codex/Pi paths. + +Likely shape: + +- `src/core/claude.ts` +- runtime dispatcher handles Claude explicitly + +## Fallback behavior + +Like the other agents, Claude support should degrade safely. + +### Preferred source + +- Claude hook state file + +### Fallback 1 + +- tmux pane preview heuristics + +Possible preview heuristics: + +- visible approval prompt +- visible numbered options +- visible trailing question in recent lines + +### Fallback 2 + +- command-only classification + +If the pane is running `claude` and no stronger signal is available: + +- `status: "running"` +- `activity: "busy"` + +## Install / tmux plugin integration + +Claude support should fit into a more general tmux-plugin install selection model instead of adding yet another Claude-specific on/off switch. + +### Recommended install configuration model + +Keep the existing per-integration toggles working for compatibility, but add a higher-level tmux option that controls what the plugin should auto-install. + +Suggested shape: + +- `@coding-agents-tmux-auto-install 'auto'` + - install every supported integration +- `@coding-agents-tmux-auto-install 'off'` + - install nothing automatically +- `@coding-agents-tmux-auto-install 'opencode,pi,codex,claude'` + - install only the listed integrations + +This gives users the control they want: + +- `auto` for the current “just set everything up for me” behavior +- an explicit list when they only want some integrations managed +- `off` when they want to manage everything manually + +### Claude-specific recommendation + +For Claude specifically, the tmux plugin should support auto-install, but it should participate in that shared selector rather than introducing a one-off policy. + +Recommended first-pass behavior: + +- add `claude` to the supported values of the shared auto-install selector +- keep `install-claude` as the explicit manual command +- if we need a Claude-specific escape hatch for compatibility later, treat it as a secondary override rather than the primary UX + +### Safety note + +Claude auto-install is still more invasive than Pi extension symlinks or the OpenCode plugin symlink because it edits `~/.claude/settings.json`. + +So even if `auto` includes `claude`, the implementation should still be conservative: + +- idempotent merges only +- preserve unrelated user settings and hooks +- make it easy to opt out by switching to `off` or an explicit list that omits `claude` + +## Recommendation + +For the first Claude Code iteration: + +- implement `install-claude` +- document it in README +- plan a shared tmux plugin install selector with `auto | off | ` semantics +- wire Claude into that shared selector instead of adding a standalone Claude-only toggle + +## Suggested implementation phases + +### Phase 0: planning and research + +- [x] Confirm Claude Code supports lifecycle hooks +- [x] Confirm Claude Code plugins can ship hooks +- [x] Confirm waiting/running/idle states are derivable from documented events +- [x] Write this plan + +### Phase 1: core model and pane detection + +- [ ] Add Claude agent kind to `src/types.ts` +- [ ] Add Claude runtime source/provider values +- [ ] Add Claude pane detection in `src/core/tmux.ts` +- [ ] Update CLI help and validation to include Claude + +### Phase 2: hook-backed state ingestion + +- [ ] Add `src/core/claude.ts` +- [ ] Add Claude state file reader/writer helpers +- [ ] Add `claude-hook-state` CLI command +- [ ] Map documented hook events to normalized runtime status +- [ ] Remove state files on `SessionEnd` + +### Phase 3: install/template support + +- [ ] Add `claude-hooks-template` CLI command +- [ ] Add `install-claude` CLI command +- [ ] Implement JSON merge/update logic for `~/.claude/settings.json` +- [ ] Keep install idempotent and preserve unrelated user hooks +- [ ] Design a shared tmux-plugin auto-install selector with `auto | off | ` semantics +- [ ] Make `claude` one of the supported values in that selector + +### Phase 4: runtime attachment and fallback + +- [ ] Attach Claude runtime to discovered panes +- [ ] Add preview-based waiting heuristics +- [ ] Add command-only Claude fallback +- [ ] Ensure mixed-agent environments still render cleanly + +### Phase 5: tests + +Add or extend tests for: + +- [ ] Claude pane detection +- [ ] Claude hook payload classification +- [ ] settings.json merge/install behavior +- [ ] shared tmux auto-install selector parsing for `auto`, `off`, and explicit lists +- [ ] state matching by target / pane id / cwd fallback +- [ ] preview override behavior +- [ ] CLI help / filtering for `--agent claude` + +Suggested files: + +- `test/tmux.test.ts` +- new `test/claude.test.ts` +- `test/cli.test.ts` +- render/status tests for mixed environments + +### Phase 6: documentation + +- [ ] Update `README.md` with Claude Code support +- [ ] Document `install-claude` +- [ ] Document template usage for `.claude/settings.json` +- [ ] Document the shared tmux auto-install selector and how `claude` participates in it +- [ ] Document limitations and fallback behavior +- [ ] Document whether automatic install is supported or intentionally manual + +### Phase 7: optional plugin packaging + +- [ ] Prototype a Claude Code plugin directory in this repo +- [ ] Mirror the standalone managed hooks into `hooks/hooks.json` +- [ ] Validate dev flow with `claude --plugin-dir` +- [ ] Decide whether plugin packaging should be shipped for users or kept as a future enhancement + +## Risks and open questions + +### 1. Settings merge safety + +Unlike Codex, Claude hooks live inside a broader settings JSON file. + +Risk: + +- install logic could accidentally damage or duplicate unrelated user config + +Mitigation: + +- keep managed hook groups clearly identifiable +- make merge logic idempotent +- preserve unknown fields exactly +- support a template command even if users avoid auto-install + +### 2. Waiting-state fidelity + +Some Claude waiting states will be explicit, some heuristic. + +Mitigation: + +- prioritize explicit waiting sources first: + - `AskUserQuestion` + - `PermissionRequest` + - `Elicitation` +- use `Stop` message text only as a fallback classifier + +### 3. Plugin install story may be more complex than hooks + +Even though Claude plugins support hooks, plugin installation may not be the best first UX for this repo. + +Mitigation: + +- treat plugin packaging as a later distribution layer, not a blocker + +### 4. Agent naming + +Need to decide whether CLI/user-facing naming should be: + +- `claude` +- `claude-code` + +Recommendation remains: + +- internal `claude` +- user-facing `Claude Code` + +## Final recommendation + +Implement Claude Code support using a **Codex-style hook ingestion architecture** first. + +That means: + +- **yes**, Claude Code can support the same overall lifecycle-hook pattern we need +- **yes**, Claude Code can later package that integration as a plugin +- but **no**, plugin packaging should not be the first milestone + +The practical first implementation should be: + +- tmux pane detection for Claude Code +- a Claude-specific runtime reader/writer +- a `claude-hook-state` ingest command +- generated/installed Claude hook configuration +- normalized `claude-state` files consumed by the existing tmux UI + +That gets real support landed quickly while leaving room for a future Claude plugin wrapper if we want the more “extension-like” distribution story later. From c1bed58ed819bc0004c04efbb051007c5b9356c7 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 29 Apr 2026 18:32:41 -0700 Subject: [PATCH 2/7] feat: add Claude Code support --- README.md | 76 ++- coding-agents-tmux.tmux | 107 ++++- src/cli.ts | 67 ++- src/core/claude.ts | 805 ++++++++++++++++++++++++++++++++ src/core/opencode.ts | 19 +- src/core/tmux.ts | 18 + src/types.ts | 7 +- test/claude.test.ts | 321 +++++++++++++ test/cli.test.ts | 53 ++- test/render.test.ts | 22 +- test/tmux-plugin-rename.test.ts | 60 +++ test/tmux.test.ts | 20 +- 12 files changed, 1544 insertions(+), 31 deletions(-) create mode 100644 src/core/claude.ts create mode 100644 test/claude.test.ts diff --git a/README.md b/README.md index 1d15508..81089cd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It helps you: - show the current pane state plus a background session summary in the status line - use local plugin and hook state instead of relying only on sqlite or pane heuristics -Today the strongest runtime support is still for `opencode`, and the project also supports `codex` and `pi` panes for discovery, switching, popup navigation, and status summaries. +Today the strongest runtime support is still for `opencode`, and the project also supports `codex`, `pi`, and `claude` panes for discovery, switching, popup navigation, and status summaries. ## Rename status @@ -72,6 +72,7 @@ Requirements: - `opencode` sessions must be restarted after first install so the bundled plugin is loaded - `codex` sessions must be restarted after first install so newly installed hooks are loaded - `pi` sessions must be restarted after first install so the bundled extension is loaded +- `claude` sessions must be restarted after Claude hook installation so new hooks are loaded ## What TPM sets up @@ -133,6 +134,12 @@ It also installs or updates Codex hook integration under: ~/.codex/hooks.json ``` +It can also install or update Claude Code hook integration under: + +```text +~/.claude/settings.json +``` + You can disable the automatic symlink step with: ```tmux @@ -151,6 +158,22 @@ You can disable the automatic Codex hook setup with: set -g @coding-agents-tmux-install-codex-hooks 'off' ``` +You can disable the automatic Claude Code hook setup with: + +```tmux +set -g @coding-agents-tmux-install-claude-hooks 'off' +``` + +You can also control all tmux-managed installs together: + +```tmux +set -g @coding-agents-tmux-auto-install 'auto' +set -g @coding-agents-tmux-auto-install 'off' +set -g @coding-agents-tmux-auto-install 'opencode,pi,codex,claude' +``` + +When `@coding-agents-tmux-auto-install` is set, it takes precedence over the individual install toggles. + ## Usage Default key bindings: @@ -277,6 +300,8 @@ Available tmux options: - `@coding-agents-tmux-install-opencode-plugin` `on` or `off`, default `on` - `@coding-agents-tmux-install-pi-extension` `on` or `off`, default `on` - `@coding-agents-tmux-install-codex-hooks` `on` or `off`, default `on` +- `@coding-agents-tmux-install-claude-hooks` `on` or `off`, default `off` +- `@coding-agents-tmux-auto-install` `auto`, `off`, or a comma-separated list like `opencode,pi,codex,claude`; when set, it overrides the individual install toggles - `@coding-agents-tmux-provider` `auto`, `plugin`, `sqlite`, or `server`, default `plugin` - `@coding-agents-tmux-server-map` JSON object or JSON file path for explicit server endpoints - `@coding-agents-tmux-popup-filter` one of `all`, `busy`, `waiting`, `running`, `active` @@ -318,9 +343,9 @@ set -g @coding-agents-tmux-provider 'plugin' ## Pi -`pi` panes are detected from the live tmux pane command and common title patterns, so they show up in `list`, `switch`, `popup`, and `status` alongside `opencode` and `codex` panes. +`pi` panes are detected from the live tmux pane command and common title patterns, so they show up in `list`, `switch`, `popup`, and `status` alongside `opencode`, `codex`, and `claude` panes. -Use `--agent opencode`, `--agent codex`, `--agent pi`, or `--agent all` on `list`, `switch`, `popup`, `popup-ui`, and `status` when you want to narrow mixed tmux environments. +Use `--agent opencode`, `--agent codex`, `--agent pi`, `--agent claude`, or `--agent all` on `list`, `switch`, `popup`, `popup-ui`, and `status` when you want to narrow mixed tmux environments. For the best Pi runtime fidelity, let the tmux plugin install the bundled Pi extension automatically. It is linked into: @@ -345,9 +370,9 @@ After first install or update, restart Pi sessions in tmux so they load the bund ## Codex -`codex` panes are detected from the live tmux pane command, so they show up in `list`, `switch`, `popup`, and `status` alongside `opencode` and `pi` panes. +`codex` panes are detected from the live tmux pane command, so they show up in `list`, `switch`, `popup`, and `status` alongside `opencode`, `pi`, and `claude` panes. -Use `--agent opencode`, `--agent codex`, `--agent pi`, or `--agent all` on `list`, `switch`, `popup`, `popup-ui`, and `status` when you want to narrow mixed tmux environments. +Use `--agent opencode`, `--agent codex`, `--agent pi`, `--agent claude`, or `--agent all` on `list`, `switch`, `popup`, `popup-ui`, and `status` when you want to narrow mixed tmux environments. Default Codex runtime support is intentionally coarse: @@ -373,6 +398,45 @@ mkdir -p .codex With hooks enabled, `coding-agents-tmux` can mark Codex panes as `idle` or `waiting-input` between turns instead of showing every Codex pane as continuously `running`. +## Claude Code + +`claude` panes are detected from the live tmux pane command and common title patterns, so they show up in `list`, `switch`, `popup`, and `status` alongside `opencode`, `codex`, and `pi` panes. + +Default Claude Code runtime support is intentionally coarse: + +- if a tmux pane is running a `claude` process, it is classified as `running` +- waiting, question, and idle distinctions become higher fidelity when Claude hooks are installed + +To enable higher-fidelity Claude Code state with hooks: + +1. Install or update the global Claude hook config manually: + +```bash +./bin/coding-agents-tmux install-claude +``` + +2. Or let the tmux plugin manage it by setting either: + +```tmux +set -g @coding-agents-tmux-install-claude-hooks 'on' +``` + +or the shared selector: + +```tmux +set -g @coding-agents-tmux-auto-install 'opencode,pi,codex,claude' +``` + +3. Optionally inspect the managed hook template before merging it into project or user Claude settings: + +```bash +./bin/coding-agents-tmux claude-hooks-template +``` + +4. Restart `claude` sessions in tmux so they begin publishing hook-backed state. + +With hooks enabled, `coding-agents-tmux` can mark Claude panes as `idle`, `waiting-question`, or `waiting-input` between turns instead of showing every Claude pane as continuously `running`. + ## Troubleshooting - `prefix + O` or `prefix + P` does nothing: make sure `node` and `npm` are installed and reload tmux @@ -381,6 +445,7 @@ With hooks enabled, `coding-agents-tmux` can mark Codex panes as `idle` or `wait - waiting detection seems wrong: use the `plugin` provider and confirm the bundled plugin symlink exists at `~/.config/opencode/plugins/coding-agents-tmux.ts` - Pi still looks busy or unknown: confirm the bundled extension exists at `~/.pi/agent/extensions/coding-agents-tmux/index.ts` and restart the Pi session so it loads the extension - Codex still always looks busy: confirm `~/.codex/config.toml` has `codex_hooks = true`, `~/.codex/hooks.json` exists, and restart the Codex session +- Claude still always looks busy: confirm `~/.claude/settings.json` contains the managed `claude-hook-state` hook command and restart the Claude Code session - status looks stale with `sqlite` or `server`: set `@coding-agents-tmux-status-interval` to a positive value because event-driven refreshes are centered on the bundled plugin provider - TPM install changed but tmux still looks old: run `prefix + I` or `tmux source-file ~/.tmux.conf` @@ -410,6 +475,7 @@ Useful commands: ./bin/coding-agents-tmux list --provider plugin ./bin/coding-agents-tmux list --agent codex ./bin/coding-agents-tmux list --agent pi +./bin/coding-agents-tmux list --agent claude ./bin/coding-agents-tmux list --provider plugin --waiting ./bin/coding-agents-tmux inspect --provider plugin ./bin/coding-agents-tmux status --provider plugin --style tmux diff --git a/coding-agents-tmux.tmux b/coding-agents-tmux.tmux index e2ab384..f39cacd 100755 --- a/coding-agents-tmux.tmux +++ b/coding-agents-tmux.tmux @@ -235,6 +235,53 @@ normalize_toggle() { esac } +tmux_option_alias_is_set() { + local preferred_option="$1" + local legacy_option="$2" + local value + + value="$(tmux show-option -gqv "$preferred_option")" + if [ -n "$value" ]; then + return 0 + fi + + value="$(tmux show-option -gqv "$legacy_option")" + [ -n "$value" ] +} + +normalize_auto_install_value() { + local value lowered + value="${1// /}" + lowered="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" + + case "$lowered" in + auto|all) + printf '%s' 'auto' + ;; + off|none|disabled|false|0) + printf '%s' 'off' + ;; + *) + printf '%s' "$lowered" + ;; + esac +} + +auto_install_includes() { + local csv="$1" + local wanted="$2" + local item + + IFS=',' read -r -a items <<< "$csv" + for item in "${items[@]}"; do + if [ "$item" = "$wanted" ]; then + return 0 + fi + done + + return 1 +} + unbind_key_if_set() { local key="$1" @@ -371,6 +418,12 @@ install_codex_hooks() { fi } +install_claude_hooks() { + if ! "$CURRENT_DIR/bin/coding-agents-tmux" install-claude >/dev/null 2>&1; then + tmux display-message "coding-agents-tmux: failed to install Claude Code hook configuration" + fi +} + install_pi_extension() { local extension_source pi_dir extension_dir extension_target legacy_extension_dir legacy_extension_target existing_target installed_changed @@ -405,7 +458,7 @@ install_pi_extension() { } main() { - local menu_key popup_key waiting_menu_key waiting_popup_key provider server_map popup_filter popup_width popup_height popup_title status_enabled status_style status_position status_option status_interval status_mode install_plugin install_codex install_pi status_text_segment status_inline_segment status_tone_segment status_refresh_command + local menu_key popup_key waiting_menu_key waiting_popup_key provider server_map popup_filter popup_width popup_height popup_title status_enabled status_style status_position status_option status_interval status_mode install_plugin install_codex install_pi install_claude auto_install_value status_text_segment status_inline_segment status_tone_segment status_refresh_command local status_prefix status_color_neutral status_color_busy status_color_waiting status_color_idle status_color_unknown local previous_status_segment previous_status_option previous_menu_key previous_popup_key previous_waiting_menu_key previous_waiting_popup_key menu_key="$(normalize_binding_key "$(get_tmux_option_alias '@coding-agents-tmux-menu-key' '@opencode-tmux-menu-key' 'O')")" @@ -418,9 +471,51 @@ main() { popup_width="$(get_tmux_option_alias '@coding-agents-tmux-popup-width' '@opencode-tmux-popup-width' '100%')" popup_height="$(get_tmux_option_alias '@coding-agents-tmux-popup-height' '@opencode-tmux-popup-height' '100%')" popup_title="$(get_tmux_option_alias '@coding-agents-tmux-popup-title' '@opencode-tmux-popup-title' 'Coding Agent Sessions')" - install_plugin="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-opencode-plugin' '@opencode-tmux-install-opencode-plugin' 'on')")" - install_codex="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-codex-hooks' '@opencode-tmux-install-codex-hooks' 'on')")" - install_pi="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-pi-extension' '@opencode-tmux-install-pi-extension' 'on')")" + if tmux_option_alias_is_set '@coding-agents-tmux-auto-install' '@opencode-tmux-auto-install'; then + auto_install_value="$(normalize_auto_install_value "$(get_tmux_option_alias '@coding-agents-tmux-auto-install' '@opencode-tmux-auto-install' '')")" + + case "$auto_install_value" in + auto) + install_plugin='on' + install_codex='on' + install_pi='on' + install_claude='on' + ;; + off|"") + install_plugin='off' + install_codex='off' + install_pi='off' + install_claude='off' + ;; + *) + install_plugin='off' + install_codex='off' + install_pi='off' + install_claude='off' + + if auto_install_includes "$auto_install_value" 'opencode'; then + install_plugin='on' + fi + + if auto_install_includes "$auto_install_value" 'codex'; then + install_codex='on' + fi + + if auto_install_includes "$auto_install_value" 'pi'; then + install_pi='on' + fi + + if auto_install_includes "$auto_install_value" 'claude'; then + install_claude='on' + fi + ;; + esac + else + install_plugin="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-opencode-plugin' '@opencode-tmux-install-opencode-plugin' 'on')")" + install_codex="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-codex-hooks' '@opencode-tmux-install-codex-hooks' 'on')")" + install_pi="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-pi-extension' '@opencode-tmux-install-pi-extension' 'on')")" + install_claude="$(normalize_toggle "$(get_tmux_option_alias '@coding-agents-tmux-install-claude-hooks' '@opencode-tmux-install-claude-hooks' 'off')")" + fi status_enabled="$(get_tmux_option_alias '@coding-agents-tmux-status' '@opencode-tmux-status' 'on')" status_style="$(get_tmux_option_alias '@coding-agents-tmux-status-style' '@opencode-tmux-status-style' 'tmux')" status_position="$(get_tmux_option_alias '@coding-agents-tmux-status-position' '@opencode-tmux-status-position' 'right')" @@ -461,6 +556,10 @@ main() { install_pi_extension fi + if [ "$install_claude" = "on" ]; then + install_claude_hooks + fi + local popup_filter_arg="" case "$popup_filter" in busy|waiting|running|active) diff --git a/src/cli.ts b/src/cli.ts index d9cb7dd..e9c2607 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,11 @@ import { renderStatusTone, renderSwitchChoices, } from "./cli/render.ts"; +import { + buildClaudeHooksTemplate, + installClaudeIntegration, + persistClaudeHookState, +} from "./core/claude.ts"; import { buildCodexHooksTemplate, installCodexIntegration, @@ -81,7 +86,7 @@ interface StatusOptions extends RuntimeProviderOptions { } interface TmuxConfigOptions extends RuntimeProviderOptions { - agent?: "all" | "opencode" | "codex" | "pi"; + agent?: "all" | "opencode" | "codex" | "pi" | "claude"; menuKey?: string; popupKey?: string; waitingMenuKey?: string; @@ -95,6 +100,8 @@ interface InstallTmuxOptions extends TmuxConfigOptions { interface InstallCodexOptions {} +interface InstallClaudeOptions {} + function getWindowKey(sessionName: string, windowIndex: number): string { return `${sessionName}:${windowIndex}`; } @@ -284,7 +291,13 @@ export function filterPaneSummaries( ): PaneRuntimeSummary[] { const agent = options.agent ?? "all"; - if (agent !== "all" && agent !== "opencode" && agent !== "codex" && agent !== "pi") { + if ( + agent !== "all" && + agent !== "opencode" && + agent !== "codex" && + agent !== "pi" && + agent !== "claude" + ) { throw new Error(`Invalid agent filter: ${agent}`); } @@ -567,6 +580,27 @@ async function runInstallCodexCommand(_options: InstallCodexOptions): Promise { + console.log(buildClaudeHooksTemplate(buildSelfCommand(["claude-hook-state"]))); +} + +async function runClaudeHookStateCommand(): Promise { + const rawInput = await readStdinText(); + + if (!rawInput.trim()) { + throw new Error("claude-hook-state requires a JSON payload on stdin"); + } + + await persistClaudeHookState(rawInput); +} + +async function runInstallClaudeCommand(_options: InstallClaudeOptions): Promise { + const result = installClaudeIntegration(buildSelfCommand(["claude-hook-state"])); + + console.log(`Updated ${result.settingsPath}`); + console.log("Restart Claude Code sessions so new hooks are loaded"); +} + interface StatusOutputContext { currentTarget?: PaneTarget; tmuxAvailable: boolean; @@ -792,7 +826,7 @@ async function main(): Promise { .description("List likely coding agent tmux panes") .option("--compact", "Print tab-separated tmux-friendly output") .option("--json", "Print machine-readable JSON") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", @@ -833,7 +867,7 @@ async function main(): Promise { .command("switch") .description("Switch tmux to one discovered coding agent pane") .argument("[target]", "Pane target in session:window.pane format") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", @@ -874,10 +908,25 @@ async function main(): Promise { .description("Install or update Codex hook configuration under ~/.codex") .action(runInstallCodexCommand); + program + .command("claude-hooks-template") + .description("Print a Claude Code hooks template for higher-fidelity Claude tmux state") + .action(runClaudeHooksTemplateCommand); + + program + .command("claude-hook-state") + .description("Ingest one Claude Code hook payload from stdin and update local runtime state") + .action(runClaudeHookStateCommand); + + program + .command("install-claude") + .description("Install or update Claude Code hook configuration under ~/.claude") + .action(runInstallClaudeCommand); + program .command("popup") .description("Open a tmux popup chooser for switching between discovered coding agent panes") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", @@ -900,7 +949,7 @@ async function main(): Promise { program .command("popup-ui") .description("Run the interactive popup selector in the current terminal") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", @@ -920,7 +969,7 @@ async function main(): Promise { .command("status") .description("Print a tmux-friendly status summary") .option("--json", "Print machine-readable JSON") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--summary", "Summarize all discovered coding agent panes instead of the current tmux pane", @@ -941,7 +990,7 @@ async function main(): Promise { program .command("tmux-config") .description("Print a tmux config snippet for popup and status-line integration") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", @@ -969,7 +1018,7 @@ async function main(): Promise { program .command("install-tmux") .description("Install or update a coding-agents-tmux snippet in a tmux config file") - .option("--agent ", "Limit panes to all, opencode, codex, or pi", "all") + .option("--agent ", "Limit panes to all, opencode, codex, pi, or claude", "all") .option( "--provider ", "Runtime provider: auto, plugin, sqlite, or server", diff --git a/src/core/claude.ts b/src/core/claude.ts new file mode 100644 index 0000000..3ea8dc1 --- /dev/null +++ b/src/core/claude.ts @@ -0,0 +1,805 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; + +import { capturePanePreview } from "./tmux.ts"; +import { getPreferredStateDir, getStateDirCandidates } from "../naming.ts"; +import { runCommand } from "../runtime.ts"; +import type { + DiscoveredPane, + PaneRuntimeSummary, + RuntimeInfo, + RuntimeStatus, + SessionMatch, + TmuxPane, +} from "../types.ts"; + +export interface ClaudeStateFile { + activity?: RuntimeInfo["activity"]; + detail?: string; + directory?: string; + paneId?: string | null; + sessionId?: string; + sourceEventType?: string; + status?: RuntimeStatus; + target?: string | null; + title?: string; + transcriptPath?: string | null; + updatedAt?: number; + version?: number; +} + +interface ClaudeHookPayload { + action?: string; + content?: unknown; + cwd?: string; + hook_event_name?: string; + last_assistant_message?: string | null; + message?: string; + mode?: string; + requested_schema?: unknown; + session_id?: string; + tool_input?: unknown; + tool_name?: string; + transcript_path?: string; +} + +interface ClaudeHookCommand { + command: string; + statusMessage?: string; + type: "command"; +} + +interface ClaudeHookMatcherGroup { + hooks: ClaudeHookCommand[]; + matcher?: string; +} + +interface ClaudeHooksDocument { + hooks?: Record; +} + +interface ClaudeStateIndex { + exactPaneIdMatches: Map; + exactTargetMatches: Map; + statesByDirectory: Map; +} + +export interface ClaudeInstallResult { + settingsPath: string; +} + +function normalizeEnvValue(value: string | undefined): string | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function toFileName(input: { directory: string; paneId: string | null }): string { + if (input.paneId) { + return `pane-${Buffer.from(input.paneId).toString("hex")}.json`; + } + + return `cwd-${Buffer.from(input.directory).toString("hex")}.json`; +} + +async function resolveTmuxPaneTarget(paneId: string | null): Promise { + if (!paneId) { + return null; + } + + try { + const { exitCode, stdoutText } = await runCommand([ + "tmux", + "display-message", + "-p", + "-t", + paneId, + "#{session_name}:#{window_index}.#{pane_index}", + ]); + + if (exitCode !== 0) { + return null; + } + + const target = stdoutText.trim(); + return target ? target : null; + } catch { + return null; + } +} + +function countChoiceLines(message: string): number { + return message + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + /^(?:[›>]\s*)?\d+\.\s+\S/.test(line) || /^(?:[›>]\s*)?[-*]\s+\S/.test(line), + ).length; +} + +function classifyWaitingMessage(message: string | null | undefined): RuntimeStatus | null { + if (!message) { + return null; + } + + const trimmed = message.trim(); + + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + + if (countChoiceLines(trimmed) >= 2) { + return "waiting-question"; + } + + if ( + ["permission", "allow", "deny"].every((fragment) => lower.includes(fragment)) || + ["which option", "choose an option", "select an option"].some((fragment) => + lower.includes(fragment), + ) + ) { + return "waiting-question"; + } + + if (/\?\s*$/.test(trimmed)) { + return "waiting-input"; + } + + if ( + [ + "would you like", + "do you want", + "should i", + "can you", + "could you", + "please provide", + "please confirm", + "choose", + "select", + "confirm", + "what would you like", + ].some((fragment) => lower.includes(fragment)) + ) { + return "waiting-input"; + } + + return null; +} + +function getClaudeHome(): string { + return process.env.CLAUDE_HOME ?? join(homedir(), ".claude"); +} + +export function getClaudeSettingsPath(): string { + return join(getClaudeHome(), "settings.json"); +} + +export function getClaudeStateDir(): string { + return getPreferredStateDir({ + preferredEnv: "CODING_AGENTS_TMUX_CLAUDE_STATE_DIR", + legacyEnv: "OPENCODE_TMUX_CLAUDE_STATE_DIR", + subdirectory: "claude-state", + }); +} + +function getClaudeStateUpdatedAt(state: ClaudeStateFile): number { + return state.updatedAt ?? 0; +} + +function pickNewerClaudeState( + current: ClaudeStateFile | undefined, + candidate: ClaudeStateFile, +): ClaudeStateFile { + if (!current || getClaudeStateUpdatedAt(candidate) > getClaudeStateUpdatedAt(current)) { + return candidate; + } + + return current; +} + +function getClaudeSessionTitle(directory: string, existing: ClaudeStateFile | null): string { + if (existing?.title) { + return existing.title; + } + + const name = basename(directory); + return name ? name : "Claude Code session"; +} + +function readStateFile(filePath: string): ClaudeStateFile | null { + if (!existsSync(filePath)) { + return null; + } + + try { + return JSON.parse(readFileSync(filePath, "utf8")) as ClaudeStateFile; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function schemaContainsChoiceOptions(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((entry) => schemaContainsChoiceOptions(entry)); + } + + if (!isRecord(value)) { + return false; + } + + if (Array.isArray(value.enum) && value.enum.length > 0) { + return true; + } + + if (Array.isArray(value.oneOf) && value.oneOf.length > 0) { + return true; + } + + if (Array.isArray(value.anyOf) && value.anyOf.length > 0) { + return true; + } + + return Object.values(value).some((entry) => schemaContainsChoiceOptions(entry)); +} + +function classifyAskUserQuestion(toolInput: unknown): { + detail: string; + status: RuntimeStatus; +} { + const questions = isRecord(toolInput) && Array.isArray(toolInput.questions) ? toolInput.questions : []; + const hasOptions = questions.some( + (question) => isRecord(question) && Array.isArray(question.options) && question.options.length > 0, + ); + + if (hasOptions) { + return { + detail: "Claude Code is waiting for a multiple-choice response", + status: "waiting-question", + }; + } + + return { + detail: "Claude Code is waiting for user input", + status: "waiting-input", + }; +} + +function classifyElicitation(payload: ClaudeHookPayload): { + detail: string; + status: RuntimeStatus; +} { + const message = payload.message?.trim(); + const mode = payload.mode?.trim().toLowerCase(); + const status = + mode === "form" && !schemaContainsChoiceOptions(payload.requested_schema) + ? ("waiting-input" as const) + : ("waiting-question" as const); + + return { + detail: + status === "waiting-question" + ? `Claude Code is waiting for an MCP response${message ? `: ${message}` : ""}` + : `Claude Code is waiting for MCP input${message ? `: ${message}` : ""}`, + status, + }; +} + +function classifyHookPayload(payload: ClaudeHookPayload): { + activity: RuntimeInfo["activity"]; + detail: string; + sourceEventType: string; + status: RuntimeStatus; +} { + const eventName = payload.hook_event_name ?? "unknown"; + + switch (eventName) { + case "SessionStart": + return { + activity: "idle", + detail: "Claude Code session started", + sourceEventType: eventName, + status: "new", + }; + case "UserPromptSubmit": + return { + activity: "busy", + detail: "Claude Code is handling a user prompt", + sourceEventType: eventName, + status: "running", + }; + case "PreToolUse": + if (payload.tool_name === "AskUserQuestion") { + const questionState = classifyAskUserQuestion(payload.tool_input); + + return { + activity: "busy", + detail: questionState.detail, + sourceEventType: eventName, + status: questionState.status, + }; + } + + return { + activity: "busy", + detail: `Claude Code is running ${payload.tool_name ?? "a tool"}`, + sourceEventType: eventName, + status: "running", + }; + case "PermissionRequest": + return { + activity: "busy", + detail: "Claude Code is waiting for permission approval", + sourceEventType: eventName, + status: "waiting-question", + }; + case "PermissionDenied": + return { + activity: "busy", + detail: "Claude Code is handling a denied permission request", + sourceEventType: eventName, + status: "running", + }; + case "Elicitation": { + const elicitation = classifyElicitation(payload); + + return { + activity: "busy", + detail: elicitation.detail, + sourceEventType: eventName, + status: elicitation.status, + }; + } + case "ElicitationResult": + return { + activity: "busy", + detail: "Claude Code is processing an MCP elicitation response", + sourceEventType: eventName, + status: "running", + }; + case "PostToolUse": + return { + activity: "busy", + detail: `Claude Code is processing ${payload.tool_name ?? "tool"} output`, + sourceEventType: eventName, + status: "running", + }; + case "PostToolUseFailure": + return { + activity: "busy", + detail: `Claude Code is recovering from a ${payload.tool_name ?? "tool"} failure`, + sourceEventType: eventName, + status: "running", + }; + case "PostToolBatch": + return { + activity: "busy", + detail: "Claude Code is processing tool results", + sourceEventType: eventName, + status: "running", + }; + case "Stop": { + const waitingStatus = classifyWaitingMessage(payload.last_assistant_message); + + return waitingStatus + ? { + activity: "busy", + detail: + waitingStatus === "waiting-question" + ? "Claude Code is waiting for a multiple-choice response" + : "Claude Code is waiting for user input", + sourceEventType: eventName, + status: waitingStatus, + } + : { + activity: "idle", + detail: "Claude Code is idle between turns", + sourceEventType: eventName, + status: "idle", + }; + } + default: + return { + activity: "unknown", + detail: `Unhandled Claude Code hook event: ${eventName}`, + sourceEventType: eventName, + status: "unknown", + }; + } +} + +export async function persistClaudeHookState(rawInput: string): Promise { + const payload = JSON.parse(rawInput) as ClaudeHookPayload; + const directory = payload.cwd?.trim() || process.cwd(); + const paneId = normalizeEnvValue(process.env.TMUX_PANE); + const stateDir = getClaudeStateDir(); + const filePath = join(stateDir, toFileName({ directory, paneId })); + + if (payload.hook_event_name === "SessionEnd") { + if (!existsSync(filePath)) { + return; + } + + unlinkSync(filePath); + return; + } + + const existing = readStateFile(filePath); + const classified = classifyHookPayload(payload); + const sessionId = payload.session_id?.trim() || existing?.sessionId; + const transcriptPath = payload.transcript_path?.trim() || existing?.transcriptPath; + const nextState = { + version: 1, + paneId, + target: (await resolveTmuxPaneTarget(paneId)) ?? existing?.target ?? null, + directory, + title: getClaudeSessionTitle(directory, existing), + activity: classified.activity, + status: classified.status, + detail: classified.detail, + updatedAt: Date.now(), + sourceEventType: classified.sourceEventType, + ...(sessionId ? { sessionId } : {}), + ...(transcriptPath ? { transcriptPath } : {}), + } satisfies ClaudeStateFile; + + mkdirSync(stateDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(nextState, null, 2), "utf8"); +} + +export function readClaudeStates(): ClaudeStateFile[] { + return getStateDirCandidates({ + preferredEnv: "CODING_AGENTS_TMUX_CLAUDE_STATE_DIR", + legacyEnv: "OPENCODE_TMUX_CLAUDE_STATE_DIR", + subdirectory: "claude-state", + }) + .filter((stateDir) => existsSync(stateDir)) + .flatMap((stateDir) => + readdirSync(stateDir) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => join(stateDir, entry)) + .map((filePath) => readStateFile(filePath)) + .filter((state): state is ClaudeStateFile => Boolean(state?.directory)), + ); +} + +function buildClaudeStateIndex(states = readClaudeStates()): ClaudeStateIndex { + const exactPaneIdMatches = new Map(); + const exactTargetMatches = new Map(); + const statesByDirectory = new Map(); + + for (const state of states) { + const directory = state.directory; + + if (!directory) { + continue; + } + + const directoryStates = statesByDirectory.get(directory) ?? []; + directoryStates.push(state); + statesByDirectory.set(directory, directoryStates); + + if (state.paneId) { + exactPaneIdMatches.set( + state.paneId, + pickNewerClaudeState(exactPaneIdMatches.get(state.paneId), state), + ); + } + + if (state.target) { + exactTargetMatches.set( + state.target, + pickNewerClaudeState(exactTargetMatches.get(state.target), state), + ); + } + } + + return { + exactPaneIdMatches, + exactTargetMatches, + statesByDirectory, + }; +} + +function createClaudeRuntimeInfo(input: { + activity: RuntimeInfo["activity"]; + status: RuntimeStatus; + source: RuntimeInfo["source"]; + strategy: RuntimeInfo["match"]["strategy"]; + provider: RuntimeInfo["match"]["provider"]; + heuristic: boolean; + session: SessionMatch | null; + detail: string; +}): RuntimeInfo { + return { + activity: input.activity, + status: input.status, + source: input.source, + match: { + strategy: input.strategy, + provider: input.provider, + heuristic: input.heuristic, + }, + session: input.session, + detail: input.detail, + }; +} + +function toClaudeSessionMatch(state: ClaudeStateFile): SessionMatch | null { + if (!state.directory || !state.title) { + return null; + } + + return { + id: state.sessionId ?? `claude:${state.directory}`, + directory: state.directory, + title: state.title, + timeUpdated: state.updatedAt ?? Date.now(), + }; +} + +function classifyClaudeState( + state: ClaudeStateFile | null, + input: { + detail: string; + heuristic: boolean; + strategy: RuntimeInfo["match"]["strategy"]; + }, +): RuntimeInfo { + if (!state?.directory) { + return createClaudeRuntimeInfo({ + activity: "unknown", + status: "unknown", + source: "unmapped", + strategy: "unmapped", + provider: "none", + heuristic: false, + session: null, + detail: input.detail, + }); + } + + const status = state.status ?? "unknown"; + const activity = + state.activity ?? + (status === "idle" || status === "new" ? "idle" : status === "unknown" ? "unknown" : "busy"); + + return createClaudeRuntimeInfo({ + activity, + status, + source: "claude-hook", + strategy: input.strategy, + provider: "claude", + heuristic: input.heuristic, + session: toClaudeSessionMatch(state), + detail: state.detail ?? input.detail, + }); +} + +function matchesClaudeStateDirectory( + state: ClaudeStateFile | undefined, + pane: TmuxPane, +): state is ClaudeStateFile { + return Boolean(state?.directory && state.directory === pane.currentPath); +} + +function getExactClaudeState(index: ClaudeStateIndex, pane: TmuxPane): ClaudeStateFile | null { + const targetState = index.exactTargetMatches.get(pane.target); + + if (matchesClaudeStateDirectory(targetState, pane)) { + return targetState; + } + + const paneIdState = index.exactPaneIdMatches.get(pane.paneId); + + if (matchesClaudeStateDirectory(paneIdState, pane)) { + return paneIdState; + } + + return null; +} + +function getDirectoryFallbackClaudeState(index: ClaudeStateIndex, pane: TmuxPane): ClaudeStateFile | null { + const states = index.statesByDirectory.get(pane.currentPath) ?? []; + + if (states.length !== 1) { + return null; + } + + return states[0] ?? null; +} + +function classifyClaudePreview(lines: string[]): Pick | null { + const nonEmptyLines = lines.map((line) => line.trim()).filter(Boolean); + const recentLines = nonEmptyLines.slice(-8); + const recentText = recentLines.join("\n"); + const recentLower = recentText.toLowerCase(); + const lastLine = recentLines.at(-1) ?? ""; + + if ( + countChoiceLines(recentText) >= 2 || + ["permission", "allow", "deny"].every((fragment) => recentLower.includes(fragment)) + ) { + return { + activity: "busy", + detail: "Claude Code appears to be waiting for a multiple-choice response", + status: "waiting-question", + }; + } + + if ( + /\?\s*$/.test(lastLine) || + ["would you like", "do you want", "should i", "please confirm", "what would you like"].some( + (fragment) => recentLower.includes(fragment), + ) + ) { + return { + activity: "busy", + detail: "Claude Code appears to be waiting for user input", + status: "waiting-input", + }; + } + + return null; +} + +function createClaudePreviewRuntime( + preview: Pick, +): RuntimeInfo { + return createClaudeRuntimeInfo({ + activity: preview.activity, + status: preview.status, + source: "claude-preview", + strategy: "exact", + provider: "claude", + heuristic: true, + session: null, + detail: preview.detail, + }); +} + +async function loadClaudePreviewRuntime(target: TmuxPane["target"]): Promise { + try { + const lines = await capturePanePreview(target, 24); + const preview = classifyClaudePreview(lines); + return preview ? createClaudePreviewRuntime(preview) : null; + } catch { + return null; + } +} + +function buildManagedHook(command: string): ClaudeHookCommand { + return { + type: "command", + command, + statusMessage: "Updating Claude tmux state", + }; +} + +function buildManagedClaudeHooks(command: string): ClaudeHooksDocument { + const hook = buildManagedHook(command); + + return { + hooks: { + SessionStart: [{ matcher: "startup|resume", hooks: [hook] }], + UserPromptSubmit: [{ hooks: [hook] }], + PreToolUse: [{ matcher: "AskUserQuestion", hooks: [hook] }], + PermissionRequest: [{ hooks: [hook] }], + Elicitation: [{ hooks: [hook] }], + ElicitationResult: [{ hooks: [hook] }], + PostToolUse: [{ hooks: [hook] }], + PostToolUseFailure: [{ hooks: [hook] }], + PostToolBatch: [{ hooks: [hook] }], + Stop: [{ hooks: [hook] }], + SessionEnd: [{ hooks: [hook] }], + }, + }; +} + +function isManagedHookGroup(group: ClaudeHookMatcherGroup): boolean { + return group.hooks.some( + (hook) => hook.type === "command" && hook.statusMessage === "Updating Claude tmux state", + ); +} + +export function updateClaudeSettings(existing: string, command: string): string { + const parsed = existing.trim() ? (JSON.parse(existing) as Record) : {}; + const parsedHooks = isRecord(parsed.hooks) + ? (parsed.hooks as Record) + : {}; + const nextHooks = { ...parsedHooks }; + const managedHooks = buildManagedClaudeHooks(command).hooks ?? {}; + + for (const [eventName, managedGroups] of Object.entries(managedHooks)) { + const groups = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []; + nextHooks[eventName] = [ + ...groups.filter((group) => !isManagedHookGroup(group)), + ...managedGroups, + ]; + } + + return `${JSON.stringify({ ...parsed, hooks: nextHooks }, null, 2)}\n`; +} + +export function installClaudeIntegration(command: string): ClaudeInstallResult { + const settingsPath = getClaudeSettingsPath(); + const claudeHome = getClaudeHome(); + const existingSettings = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : ""; + + mkdirSync(claudeHome, { recursive: true }); + writeFileSync(settingsPath, updateClaudeSettings(existingSettings, command), "utf8"); + + return { settingsPath }; +} + +export function buildClaudeHooksTemplate(command: string): string { + return `${JSON.stringify(buildManagedClaudeHooks(command), null, 2)}\n`; +} + +export async function attachRuntimeWithClaude( + panes: DiscoveredPane[], + index = buildClaudeStateIndex(), +): Promise { + return Promise.all( + panes.map(async (entry) => { + const exactState = getExactClaudeState(index, entry.pane); + + if (exactState) { + return { + ...entry, + runtime: classifyClaudeState(exactState, { + detail: "matched Claude hook state by target or pane id", + heuristic: false, + strategy: "exact", + }), + }; + } + + const directoryState = getDirectoryFallbackClaudeState(index, entry.pane); + + if (directoryState) { + return { + ...entry, + runtime: classifyClaudeState(directoryState, { + detail: "matched unique Claude hook state by pane cwd", + heuristic: true, + strategy: "exact", + }), + }; + } + + const previewRuntime = await loadClaudePreviewRuntime(entry.pane.target); + + if (previewRuntime) { + return { + ...entry, + runtime: previewRuntime, + }; + } + + return { + ...entry, + runtime: createClaudeRuntimeInfo({ + activity: "busy", + status: "running", + source: "claude-command", + strategy: "exact", + provider: "claude", + heuristic: false, + session: null, + detail: `detected ${entry.pane.currentCommand} process in tmux pane`, + }), + }; + }), + ); +} diff --git a/src/core/opencode.ts b/src/core/opencode.ts index fe93701..c9d0b79 100644 --- a/src/core/opencode.ts +++ b/src/core/opencode.ts @@ -8,6 +8,7 @@ import { type CodexStateEntry, type CodexStateFile, } from "./codex.ts"; +import { attachRuntimeWithClaude, getClaudeStateDir } from "./claude.ts"; import { attachRuntimeWithPi } from "./pi.ts"; import { capturePanePreview } from "./tmux.ts"; import { @@ -1478,23 +1479,29 @@ export async function attachRuntimeToPanes( const opencodePanes = panes.filter((entry) => entry.detection.agent === "opencode"); const codexPanes = panes.filter((entry) => entry.detection.agent === "codex"); const piPanes = panes.filter((entry) => entry.detection.agent === "pi"); + const claudePanes = panes.filter((entry) => entry.detection.agent === "claude"); - if (codexPanes.length === 0 && piPanes.length === 0) { + if (codexPanes.length === 0 && piPanes.length === 0 && claudePanes.length === 0) { return attachRuntimeWithOpencodeProvider(panes, options); } - if (opencodePanes.length === 0 && piPanes.length === 0) { + if (opencodePanes.length === 0 && piPanes.length === 0 && claudePanes.length === 0) { return attachRuntimeWithCodex(codexPanes); } - if (opencodePanes.length === 0 && codexPanes.length === 0) { + if (opencodePanes.length === 0 && codexPanes.length === 0 && claudePanes.length === 0) { return attachRuntimeWithPi(piPanes); } + if (opencodePanes.length === 0 && codexPanes.length === 0 && piPanes.length === 0) { + return attachRuntimeWithClaude(claudePanes); + } + const resultGroups = await Promise.all([ opencodePanes.length > 0 ? attachRuntimeWithOpencodeProvider(opencodePanes, options) : [], codexPanes.length > 0 ? attachRuntimeWithCodex(codexPanes) : [], piPanes.length > 0 ? attachRuntimeWithPi(piPanes) : [], + claudePanes.length > 0 ? attachRuntimeWithClaude(claudePanes) : [], ]); const resultsByTarget = new Map(resultGroups.flat().map((entry) => [entry.pane.target, entry])); @@ -1530,6 +1537,12 @@ export function getRuntimeProviderHelpText(): string { " Override with CODING_AGENTS_TMUX_CODEX_STATE_DIR or OPENCODE_TMUX_CODEX_STATE_DIR.", ` Generate hooks.json with: ${PRIMARY_CLI_NAME} codex-hooks-template`, "", + "Claude hook state:", + ` Default path: ${getClaudeStateDir()}`, + " Override with CODING_AGENTS_TMUX_CLAUDE_STATE_DIR or OPENCODE_TMUX_CLAUDE_STATE_DIR.", + ` Generate settings hooks with: ${PRIMARY_CLI_NAME} claude-hooks-template`, + ` Install global Claude hooks with: ${PRIMARY_CLI_NAME} install-claude`, + "", "Server map:", " Pass --server-map with a JSON object or a path to a JSON file.", ' Example: {"work:1.2":"http://127.0.0.1:4096"}', diff --git a/src/core/tmux.ts b/src/core/tmux.ts index ec80448..f4fb3a1 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -103,6 +103,7 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { const opencodeReasons: string[] = []; const codexReasons: string[] = []; const piReasons: string[] = []; + const claudeReasons: string[] = []; const candidates: Array<{ agent: AgentKind; reasons: string[]; score: number }> = []; if (title === "OpenCode") { @@ -131,6 +132,7 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { const hasPiTitleHint = lowerTitle === "pi" || lowerTitle.startsWith("pi - ") || title.startsWith("π - "); + const hasClaudeTitleHint = lowerTitle === "claude" || lowerTitle.startsWith("claude code"); if (hasPiTitleHint) { piReasons.push("title:Pi"); @@ -142,6 +144,14 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { piReasons.push("command:pi-wrapper"); } + if (hasClaudeTitleHint) { + claudeReasons.push("title:Claude"); + } + + if (matchesCommand(command, "claude")) { + claudeReasons.push("command:claude"); + } + if (opencodeReasons.some((reason) => !reason.startsWith("path:"))) { candidates.push({ agent: "opencode", @@ -169,6 +179,14 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { }); } + if (claudeReasons.some((reason) => reason.startsWith("command:"))) { + candidates.push({ + agent: "claude", + reasons: claudeReasons, + score: hasClaudeTitleHint ? 5 : 4, + }); + } + const detected = pickDetectedAgent(candidates); if (detected) { diff --git a/src/types.ts b/src/types.ts index 0d44ca9..8ecb461 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ export type PaneTarget = `${string}:${number}.${number}`; -export type AgentKind = "opencode" | "codex" | "pi"; +export type AgentKind = "opencode" | "codex" | "pi" | "claude"; export interface TmuxPane { sessionName: string; @@ -58,6 +58,9 @@ export type RuntimeSource = | "pi-extension" | "pi-preview" | "pi-command" + | "claude-hook" + | "claude-preview" + | "claude-command" | "unmapped"; export interface RuntimeMatchInfo { @@ -68,7 +71,7 @@ export interface RuntimeMatchInfo { | "descendant-recent" | "descendant-only" | "unmapped"; - provider: "plugin" | "server" | "sqlite" | "codex" | "pi" | "none"; + provider: "plugin" | "server" | "sqlite" | "codex" | "pi" | "claude" | "none"; heuristic: boolean; } diff --git a/test/claude.test.ts b/test/claude.test.ts new file mode 100644 index 0000000..26311f2 --- /dev/null +++ b/test/claude.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + buildClaudeHooksTemplate, + installClaudeIntegration, + persistClaudeHookState, + readClaudeStates, + updateClaudeSettings, +} from "../src/core/claude.ts"; +import { attachRuntimeToPanes } from "../src/core/opencode.ts"; +import type { DiscoveredPane, TmuxPane } from "../src/types.ts"; + +function createPane(overrides: Partial = {}): TmuxPane { + const sessionName = overrides.sessionName ?? "work"; + const windowIndex = overrides.windowIndex ?? 1; + const paneIndex = overrides.paneIndex ?? 0; + + return { + sessionName, + windowIndex, + paneIndex, + paneId: overrides.paneId ?? `%${paneIndex + 1}`, + paneTitle: overrides.paneTitle ?? "Claude Code", + currentCommand: overrides.currentCommand ?? "claude", + currentPath: overrides.currentPath ?? "/tmp/claude-project", + isActive: overrides.isActive ?? false, + tty: overrides.tty ?? "/dev/ttys001", + target: overrides.target ?? `${sessionName}:${windowIndex}.${paneIndex}`, + }; +} + +function createDiscoveredClaudePane(overrides: Partial = {}): DiscoveredPane { + const pane = createPane(overrides); + + return { + pane, + detection: { + agent: "claude", + confidence: "medium", + reasons: ["command:claude"], + }, + }; +} + +function setEnv(updates: Record): () => void { + const previous = new Map(); + + for (const [key, value] of Object.entries(updates)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + return () => { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }; +} + +function installFakeTmux(script: string): { pathEntry: string } { + const dir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-claude-fake-tmux-")); + const tmuxPath = join(dir, "tmux"); + + writeFileSync( + tmuxPath, + `#!/usr/bin/env bash +set -euo pipefail +${script} +`, + "utf8", + ); + chmodSync(tmuxPath, 0o755); + + return { pathEntry: dir }; +} + +function createClaudeStateDir(states: Record[]): string { + const root = mkdtempSync(join(tmpdir(), "coding-agents-tmux-claude-state-")); + + states.forEach((state, index) => { + writeFileSync(join(root, `state-${index + 1}.json`), JSON.stringify(state), "utf8"); + }); + + return root; +} + +test("buildClaudeHooksTemplate emits the managed Claude hook events", () => { + const template = JSON.parse( + buildClaudeHooksTemplate("/tmp/coding-agents-tmux/bin/coding-agents-tmux claude-hook-state"), + ) as { + hooks: Record }>>; + }; + + assert.deepEqual(Object.keys(template.hooks), [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "Elicitation", + "ElicitationResult", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Stop", + "SessionEnd", + ]); + assert.equal( + template.hooks.Stop?.[0]?.hooks[0]?.command, + "/tmp/coding-agents-tmux/bin/coding-agents-tmux claude-hook-state", + ); +}); + +test("updateClaudeSettings merges managed hooks without dropping unrelated settings", () => { + const updated = JSON.parse( + updateClaudeSettings( + JSON.stringify({ + theme: "dark", + hooks: { + Stop: [ + { + hooks: [ + { + type: "command", + command: "/old/coding-agents-tmux claude-hook-state", + statusMessage: "Updating Claude tmux state", + }, + ], + }, + { + hooks: [{ type: "command", command: "python3 ~/.claude/custom-stop.py" }], + }, + ], + }, + }), + "/new/coding-agents-tmux claude-hook-state", + ), + ) as { + theme: string; + hooks: Record }>>; + }; + + assert.equal(updated.theme, "dark"); + assert.equal(updated.hooks.Stop?.[0]?.hooks[0]?.command, "python3 ~/.claude/custom-stop.py"); + assert.equal( + updated.hooks.Stop?.[1]?.hooks[0]?.command, + "/new/coding-agents-tmux claude-hook-state", + ); + assert.ok(updated.hooks.SessionStart); +}); + +test("installClaudeIntegration writes settings.json under CLAUDE_HOME", () => { + const claudeHome = mkdtempSync(join(tmpdir(), "coding-agents-tmux-claude-home-")); + const restoreEnv = setEnv({ CLAUDE_HOME: claudeHome }); + + try { + const result = installClaudeIntegration( + "/tmp/coding-agents-tmux/bin/coding-agents-tmux claude-hook-state", + ); + const settings = readFileSync(result.settingsPath, "utf8"); + + assert.match(result.settingsPath, /settings\.json$/); + assert.match(settings, /claude-hook-state/); + assert.match(settings, /SessionStart/); + } finally { + restoreEnv(); + } +}); + +test("persistClaudeHookState classifies AskUserQuestion and SessionEnd removes state", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "coding-agents-tmux-claude-state-")); + const restoreEnv = setEnv({ + CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: stateDir, + TMUX_PANE: undefined, + }); + + try { + await persistClaudeHookState( + JSON.stringify({ + hook_event_name: "PreToolUse", + cwd: "/tmp/claude-project", + session_id: "claude-session", + tool_name: "AskUserQuestion", + tool_input: { + questions: [ + { + question: "Which framework?", + options: [{ label: "React" }, { label: "Vue" }], + }, + ], + }, + }), + ); + + let states = readClaudeStates(); + assert.equal(states[0]?.status, "waiting-question"); + assert.equal(states[0]?.detail, "Claude Code is waiting for a multiple-choice response"); + + await persistClaudeHookState( + JSON.stringify({ + hook_event_name: "SessionEnd", + cwd: "/tmp/claude-project", + session_id: "claude-session", + }), + ); + + states = readClaudeStates(); + assert.equal(states.length, 0); + } finally { + restoreEnv(); + } +}); + +test("Claude runtime matches panes by target, pane id, and unique cwd fallback", async () => { + const stateDir = createClaudeStateDir([ + { + target: "work:1.0", + paneId: "%1", + directory: "/tmp/claude-a", + title: "Claude Session A", + status: "running", + activity: "busy", + updatedAt: 100, + }, + { + paneId: "%9", + directory: "/tmp/claude-b", + title: "Claude Session B", + status: "idle", + activity: "idle", + updatedAt: 200, + }, + { + directory: "/tmp/claude-c", + title: "Claude Session C", + status: "waiting-input", + activity: "busy", + updatedAt: 300, + }, + ]); + const restoreEnv = setEnv({ CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: stateDir }); + + try { + const summaries = await attachRuntimeToPanes([ + createDiscoveredClaudePane({ target: "work:1.0", paneId: "%1", currentPath: "/tmp/claude-a" }), + createDiscoveredClaudePane({ target: "work:1.1", paneId: "%9", currentPath: "/tmp/claude-b" }), + createDiscoveredClaudePane({ target: "work:1.2", paneId: "%3", currentPath: "/tmp/claude-c" }), + ]); + + assert.equal(summaries[0]?.runtime.status, "running"); + assert.equal(summaries[0]?.runtime.source, "claude-hook"); + assert.equal(summaries[0]?.runtime.match.provider, "claude"); + + assert.equal(summaries[1]?.runtime.status, "idle"); + assert.equal(summaries[1]?.runtime.source, "claude-hook"); + assert.equal(summaries[1]?.runtime.match.provider, "claude"); + + assert.equal(summaries[2]?.runtime.status, "waiting-input"); + assert.equal(summaries[2]?.runtime.match.heuristic, true); + assert.equal(summaries[2]?.runtime.session?.title, "Claude Session C"); + } finally { + restoreEnv(); + } +}); + +test("Claude runtime falls back to preview and command classification", async () => { + const fakeTmux = installFakeTmux(` +if [ "$1" = "capture-pane" ]; then + printf 'What would you like me to do next?\n' + printf '› 1. Apply the fix\n' + printf '2. Explain the change first\n' + exit 0 +fi +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const restoreEnv = setEnv({ + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync(join(tmpdir(), "coding-agents-tmux-empty-claude-state-")), + }); + + try { + const previewSummaries = await attachRuntimeToPanes([ + createDiscoveredClaudePane({ target: "work:1.0", paneId: "%1", currentPath: "/tmp/claude-project" }), + ]); + + assert.equal(previewSummaries[0]?.runtime.status, "waiting-question"); + assert.equal(previewSummaries[0]?.runtime.source, "claude-preview"); + assert.equal(previewSummaries[0]?.runtime.match.provider, "claude"); + } finally { + restoreEnv(); + } + + const restoreEmptyEnv = setEnv({ + CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync(join(tmpdir(), "coding-agents-tmux-empty-claude-state-")), + }); + + try { + const summaries = await attachRuntimeToPanes([ + createDiscoveredClaudePane({ currentCommand: "claude", currentPath: "/tmp/claude-project" }), + ]); + + assert.equal(summaries[0]?.runtime.status, "running"); + assert.equal(summaries[0]?.runtime.source, "claude-command"); + assert.equal(summaries[0]?.runtime.match.provider, "claude"); + assert.match(summaries[0]?.runtime.detail ?? "", /detected claude process in tmux pane/); + } finally { + restoreEmptyEnv(); + } +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index e9df53c..22b32e5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -175,6 +175,14 @@ test("filterPaneSummaries applies agent, active, waiting, busy, and running filt match: { strategy: "exact", provider: "pi", heuristic: false }, }), }), + createSummary("running", { + pane: createPane({ target: "work:1.6", paneIndex: 6, currentCommand: "claude" }), + detection: { agent: "claude", confidence: "medium", reasons: ["command:claude"] }, + runtime: createRuntime("running", { + source: "claude-command", + match: { strategy: "exact", provider: "claude", heuristic: false }, + }), + }), ]; assert.deepEqual( @@ -187,7 +195,7 @@ test("filterPaneSummaries applies agent, active, waiting, busy, and running filt ); assert.deepEqual( filterPaneSummaries(panes, { busy: true }).map((entry) => entry.pane.target), - ["work:1.1", "work:1.2", "work:1.3", "work:1.4"], + ["work:1.1", "work:1.2", "work:1.3", "work:1.4", "work:1.6"], ); assert.deepEqual( filterPaneSummaries(panes, { waiting: true, busy: true }).map((entry) => entry.pane.target), @@ -195,7 +203,7 @@ test("filterPaneSummaries applies agent, active, waiting, busy, and running filt ); assert.deepEqual( filterPaneSummaries(panes, { running: true }).map((entry) => entry.pane.target), - ["work:1.3", "work:1.4"], + ["work:1.3", "work:1.4", "work:1.6"], ); assert.deepEqual( filterPaneSummaries(panes, { agent: "codex" }).map((entry) => entry.pane.target), @@ -211,6 +219,10 @@ test("filterPaneSummaries applies agent, active, waiting, busy, and running filt filterPaneSummaries(panes, { agent: "pi" }).map((entry) => entry.pane.target), ["work:1.5"], ); + assert.deepEqual( + filterPaneSummaries(panes, { agent: "claude" }).map((entry) => entry.pane.target), + ["work:1.6"], + ); }); test("getWindowKeyFromTarget parses valid targets and rejects malformed ones", () => { @@ -422,6 +434,25 @@ test("CLI install-codex writes Codex config and hooks files", async () => { } }); +test("CLI install-claude writes Claude settings hooks", async () => { + const claudeHome = mkdtempSync(join(tmpdir(), "coding-agents-tmux-claude-home-")); + const restoreEnv = setEnv({ CLAUDE_HOME: claudeHome }); + + try { + const result = await runCommand([BIN_PATH, "install-claude"]); + const settingsPath = join(claudeHome, "settings.json"); + const settings = readFileSync(settingsPath, "utf8"); + + assert.equal(result.exitCode, 0); + assert.match(result.stdoutText, /Updated .*settings\.json/); + assert.match(settings, /claude-hook-state/); + assert.match(settings, /SessionStart/); + assert.match(settings, /SessionEnd/); + } finally { + restoreEnv(); + } +}); + test("CLI inspect emits JSON for a discovered pane", async () => { const fakeTmux = installFakeTmux(` if [ "$1" = "list-panes" ]; then @@ -694,6 +725,15 @@ test("CLI codex-hooks-template prints a hooks.json scaffold", async () => { assert.match(result.stdoutText, /codex-hook-state/); }); +test("CLI claude-hooks-template prints a hooks scaffold", async () => { + const result = await runCommand([BIN_PATH, "claude-hooks-template"]); + + assert.equal(result.exitCode, 0); + assert.match(result.stdoutText, /"SessionStart"/); + assert.match(result.stdoutText, /"SessionEnd"/); + assert.match(result.stdoutText, /claude-hook-state/); +}); + test("CLI list supports compact and json output with runtime filters", async () => { const fakeTmux = installFakeTmux(` if [ "$1" = "list-panes" ]; then @@ -701,6 +741,7 @@ if [ "$1" = "list-panes" ]; then printf 'work\t1\t1\t%%2\tOpenCode\topencode\t/tmp/project-b\t0\t/dev/ttys002\n' printf 'work\t1\t2\t%%4\tShell\tcodex\t/tmp/codex-project\t0\t/dev/ttys004\n' printf 'work\t1\t5\t%%5\tπ - pi-project\tpi\t/tmp/pi-project\t0\t/dev/ttys005\n' + printf 'work\t1\t6\t%%6\tClaude Code\tclaude\t/tmp/claude-project\t0\t/dev/ttys006\n' printf 'work\t2\t0\t%%3\tShell\tbash\t/tmp/other\t0\t/dev/ttys003\n' exit 0 fi @@ -749,6 +790,7 @@ exit 1 ]); const codexResult = await runCommand([BIN_PATH, "list", "--compact", "--agent", "codex"]); const piResult = await runCommand([BIN_PATH, "list", "--compact", "--agent", "pi"]); + const claudeResult = await runCommand([BIN_PATH, "list", "--compact", "--agent", "claude"]); assert.equal(compactResult.exitCode, 0); assert.equal( @@ -760,7 +802,7 @@ exit 1 JSON.parse(jsonResult.stdoutText).map( (entry: { pane: { target: string } }) => entry.pane.target, ), - ["work:1.1", "work:1.2", "work:1.5"], + ["work:1.1", "work:1.2", "work:1.5", "work:1.6"], ); assert.equal(codexResult.exitCode, 0); assert.equal( @@ -772,6 +814,11 @@ exit 1 piResult.stdoutText.trim(), "work:1.5\tbusy\trunning\tpi-command\t0\t(unmatched)\tπ - pi-project\t/tmp/pi-project", ); + assert.equal(claudeResult.exitCode, 0); + assert.equal( + claudeResult.stdoutText.trim(), + "work:1.6\tbusy\trunning\tclaude-command\t0\t(unmatched)\tClaude Code\t/tmp/claude-project", + ); } finally { restoreEnv(); } diff --git a/test/render.test.ts b/test/render.test.ts index 2874972..2bade32 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -312,7 +312,7 @@ test("renderCompactPaneList falls back to unmatched and untitled labels", () => ); }); -test("renderPaneTable and renderCompactPaneList handle mixed OpenCode, Codex, and Pi panes", () => { +test("renderPaneTable and renderCompactPaneList handle mixed OpenCode, Codex, Pi, and Claude panes", () => { const opencodePane = createSummary("idle", { pane: createPane({ target: "work:1.0" }), }); @@ -344,14 +344,30 @@ test("renderPaneTable and renderCompactPaneList handle mixed OpenCode, Codex, an session: null, }), }); + const claudePane = createSummary("running", { + pane: createPane({ + target: "work:1.3", + paneIndex: 3, + paneTitle: "Claude Code", + currentCommand: "claude", + }), + detection: { agent: "claude", confidence: "high", reasons: ["title:Claude", "command:claude"] }, + runtime: createRuntime("running", { + source: "claude-command", + match: { strategy: "exact", provider: "claude", heuristic: false }, + session: null, + }), + }); - const tableOutput = renderPaneTable([opencodePane, codexPane, piPane]); - const compactOutput = renderCompactPaneList([opencodePane, codexPane, piPane]); + const tableOutput = renderPaneTable([opencodePane, codexPane, piPane, claudePane]); + const compactOutput = renderCompactPaneList([opencodePane, codexPane, piPane, claudePane]); assert.match(tableOutput, /opencode/); assert.match(tableOutput, /codex/); assert.match(tableOutput, /pi/); + assert.match(tableOutput, /claude/); assert.match(compactOutput, /work:1\.2\tbusy\trunning\tpi-command/); + assert.match(compactOutput, /work:1\.3\tbusy\trunning\tclaude-command/); }); test("renderInspectResult includes pane, detection, and session details", () => { diff --git a/test/tmux-plugin-rename.test.ts b/test/tmux-plugin-rename.test.ts index daa1159..a186a11 100644 --- a/test/tmux-plugin-rename.test.ts +++ b/test/tmux-plugin-rename.test.ts @@ -178,3 +178,63 @@ exit 1 restoreEnv(); } }); + +test("coding-agents-tmux.tmux honors @coding-agents-tmux-auto-install lists including claude", async () => { + const fakeTmux = installFakeTmux(` +log_path='__LOG_PATH__' +option="\${!#}" + +case "$1" in +show-option) + case "$option" in + @coding-agents-tmux-status) + printf 'off\n' + ;; + @coding-agents-tmux-auto-install) + printf 'pi,claude\n' + ;; + esac + exit 0 + ;; +bind-key|set-option|set-hook|refresh-client|display-message|unbind-key) + printf '%s\n' "$*" >> "$log_path" + exit 0 + ;; +esac + +printf 'unexpected args: %s\n' "$*" >&2 +exit 1 +`); + const home = mkdtempSync(join(tmpdir(), "coding-agents-tmux-home-")); + const configHome = join(home, ".config-home"); + const piHome = join(home, ".pi-home"); + const codexHome = join(home, ".codex-home"); + const claudeHome = join(home, ".claude-home"); + installFakeNpm(fakeTmux.pathEntry); + const restoreEnv = setEnv({ + HOME: home, + PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, + XDG_CONFIG_HOME: configHome, + PI_CODING_AGENT_DIR: piHome, + CODEX_HOME: codexHome, + CLAUDE_HOME: claudeHome, + }); + + try { + const result = await runCommand([join(process.cwd(), "coding-agents-tmux.tmux")]); + const newPluginPath = join(configHome, "opencode", "plugins", "coding-agents-tmux.ts"); + const newPiExtensionPath = join(piHome, "extensions", "coding-agents-tmux", "index.ts"); + const claudeSettingsPath = join(claudeHome, "settings.json"); + const codexHooksPath = join(codexHome, "hooks.json"); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderrText.trim(), ""); + assert.equal(existsSync(newPluginPath), false); + assert.ok(existsSync(newPiExtensionPath)); + assert.ok(existsSync(claudeSettingsPath)); + assert.equal(existsSync(codexHooksPath), false); + assert.match(readFileSync(claudeSettingsPath, "utf8"), /claude-hook-state/); + } finally { + restoreEnv(); + } +}); diff --git a/test/tmux.test.ts b/test/tmux.test.ts index 46b05b3..c7dbcc0 100644 --- a/test/tmux.test.ts +++ b/test/tmux.test.ts @@ -128,6 +128,15 @@ test("detectAgentPane recognizes OpenCode, Codex, Pi, and no-signal panes", () = reasons: ["title:Pi", "command:pi-wrapper"], }); + assert.deepEqual( + detectAgentPane(createPane({ paneTitle: "Claude Code", currentCommand: "claude" })), + { + agent: "claude", + confidence: "high", + reasons: ["title:Claude", "command:claude"], + }, + ); + assert.deepEqual(detectAgentPane(createPane({ paneTitle: "π - work", currentCommand: "bash" })), { agent: null, confidence: "low", @@ -205,12 +214,19 @@ test("discoverAgentPanesFromList filters non-agent panes and sorts targets", () currentCommand: "bash", currentPath: "/tmp/pi-project", }), - createPane({ target: "work:1.3", paneIndex: 3 }), + createPane({ + target: "work:1.3", + paneIndex: 3, + paneTitle: "Claude Code", + currentCommand: "claude", + currentPath: "/tmp/claude-project", + }), + createPane({ target: "work:1.4", paneIndex: 4 }), ]; assert.deepEqual( discoverAgentPanesFromList(panes).map((entry) => entry.pane.target), - ["work:1.1", "work:1.2", "work:1.3", "work:2.1"], + ["work:1.1", "work:1.2", "work:1.3", "work:1.4", "work:2.1"], ); }); From d73c05bb6593ffa576eeb542c6dc7a28281b5124 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 29 Apr 2026 18:35:55 -0700 Subject: [PATCH 3/7] docs: update Claude Code support plan progress --- docs/claude-code-support-plan.md | 105 +++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/docs/claude-code-support-plan.md b/docs/claude-code-support-plan.md index c20439d..3cf0c21 100644 --- a/docs/claude-code-support-plan.md +++ b/docs/claude-code-support-plan.md @@ -34,6 +34,47 @@ The main difference from Pi is: So the Claude Code equivalent of a Pi extension is not a TypeScript extension module first; it is a **hook-backed integration**, optionally packaged as a Claude Code plugin later. +## Progress tracker + +### Current status + +- [x] Research completed +- [x] Claude pane detection implemented +- [x] Claude hook-backed state ingestion implemented +- [x] Claude install/template commands implemented +- [x] Claude runtime attachment and fallback behavior implemented +- [x] Tests added and passing +- [x] README and user-facing docs updated +- [ ] Optional Claude plugin packaging explored + +### Implemented in this iteration + +Shipped support includes: + +- `claude` as a first-class internal agent kind +- tmux pane detection for `claude` processes and common Claude Code title hints +- `src/core/claude.ts` for Claude hook ingestion, state reads, runtime matching, preview fallback, and command fallback +- new CLI commands: + - `claude-hooks-template` + - `claude-hook-state` + - `install-claude` +- runtime dispatch updates so Claude panes are handled explicitly alongside OpenCode, Codex, and Pi +- tmux plugin install support for Claude hooks +- shared tmux auto-install selector support: + - `@coding-agents-tmux-auto-install 'auto'` + - `@coding-agents-tmux-auto-install 'off'` + - `@coding-agents-tmux-auto-install 'opencode,pi,codex,claude'` +- README updates for Claude support, install options, and troubleshooting +- test coverage for Claude runtime behavior, CLI commands, tmux detection, render output, and tmux auto-install selection + +### Remaining follow-up + +Still intentionally left for later: + +- packaging the Claude integration as a Claude Code plugin +- validating a `claude --plugin-dir` development/distribution flow +- deciding whether plugin packaging should become a supported user-facing install path + ## Research summary ### 1. Claude Code exposes lifecycle hooks directly @@ -655,46 +696,46 @@ For the first Claude Code iteration: ### Phase 1: core model and pane detection -- [ ] Add Claude agent kind to `src/types.ts` -- [ ] Add Claude runtime source/provider values -- [ ] Add Claude pane detection in `src/core/tmux.ts` -- [ ] Update CLI help and validation to include Claude +- [x] Add Claude agent kind to `src/types.ts` +- [x] Add Claude runtime source/provider values +- [x] Add Claude pane detection in `src/core/tmux.ts` +- [x] Update CLI help and validation to include Claude ### Phase 2: hook-backed state ingestion -- [ ] Add `src/core/claude.ts` -- [ ] Add Claude state file reader/writer helpers -- [ ] Add `claude-hook-state` CLI command -- [ ] Map documented hook events to normalized runtime status -- [ ] Remove state files on `SessionEnd` +- [x] Add `src/core/claude.ts` +- [x] Add Claude state file reader/writer helpers +- [x] Add `claude-hook-state` CLI command +- [x] Map documented hook events to normalized runtime status +- [x] Remove state files on `SessionEnd` ### Phase 3: install/template support -- [ ] Add `claude-hooks-template` CLI command -- [ ] Add `install-claude` CLI command -- [ ] Implement JSON merge/update logic for `~/.claude/settings.json` -- [ ] Keep install idempotent and preserve unrelated user hooks -- [ ] Design a shared tmux-plugin auto-install selector with `auto | off | ` semantics -- [ ] Make `claude` one of the supported values in that selector +- [x] Add `claude-hooks-template` CLI command +- [x] Add `install-claude` CLI command +- [x] Implement JSON merge/update logic for `~/.claude/settings.json` +- [x] Keep install idempotent and preserve unrelated user hooks +- [x] Design a shared tmux-plugin auto-install selector with `auto | off | ` semantics +- [x] Make `claude` one of the supported values in that selector ### Phase 4: runtime attachment and fallback -- [ ] Attach Claude runtime to discovered panes -- [ ] Add preview-based waiting heuristics -- [ ] Add command-only Claude fallback -- [ ] Ensure mixed-agent environments still render cleanly +- [x] Attach Claude runtime to discovered panes +- [x] Add preview-based waiting heuristics +- [x] Add command-only Claude fallback +- [x] Ensure mixed-agent environments still render cleanly ### Phase 5: tests Add or extend tests for: -- [ ] Claude pane detection -- [ ] Claude hook payload classification -- [ ] settings.json merge/install behavior -- [ ] shared tmux auto-install selector parsing for `auto`, `off`, and explicit lists -- [ ] state matching by target / pane id / cwd fallback -- [ ] preview override behavior -- [ ] CLI help / filtering for `--agent claude` +- [x] Claude pane detection +- [x] Claude hook payload classification +- [x] settings.json merge/install behavior +- [x] shared tmux auto-install selector parsing for `auto`, `off`, and explicit lists +- [x] state matching by target / pane id / cwd fallback +- [x] preview override behavior +- [x] CLI help / filtering for `--agent claude` Suggested files: @@ -705,12 +746,12 @@ Suggested files: ### Phase 6: documentation -- [ ] Update `README.md` with Claude Code support -- [ ] Document `install-claude` -- [ ] Document template usage for `.claude/settings.json` -- [ ] Document the shared tmux auto-install selector and how `claude` participates in it -- [ ] Document limitations and fallback behavior -- [ ] Document whether automatic install is supported or intentionally manual +- [x] Update `README.md` with Claude Code support +- [x] Document `install-claude` +- [x] Document template usage for `.claude/settings.json` +- [x] Document the shared tmux auto-install selector and how `claude` participates in it +- [x] Document limitations and fallback behavior +- [x] Document whether automatic install is supported or intentionally manual ### Phase 7: optional plugin packaging From 195e566a2b40e3bce190f614e0140978ce9753d9 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 29 Apr 2026 18:38:46 -0700 Subject: [PATCH 4/7] docs: clarify optional Claude plugin packaging phase --- docs/claude-code-support-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/claude-code-support-plan.md b/docs/claude-code-support-plan.md index 3cf0c21..c68ec06 100644 --- a/docs/claude-code-support-plan.md +++ b/docs/claude-code-support-plan.md @@ -75,6 +75,8 @@ Still intentionally left for later: - validating a `claude --plugin-dir` development/distribution flow - deciding whether plugin packaging should become a supported user-facing install path +These are packaging and distribution follow-ups only. They are **not required** for the shipped Claude tmux functionality in this repo, including pane detection, switching, popup navigation, status summaries, and hook-backed runtime state. + ## Research summary ### 1. Claude Code exposes lifecycle hooks directly @@ -755,6 +757,8 @@ Suggested files: ### Phase 7: optional plugin packaging +This phase is explicitly optional. It is about Claude Code plugin packaging and distribution, not core tmux support. Claude pane detection, switching, popup navigation, status summaries, and hook-backed runtime state do **not** depend on this phase. + - [ ] Prototype a Claude Code plugin directory in this repo - [ ] Mirror the standalone managed hooks into `hooks/hooks.json` - [ ] Validate dev flow with `claude --plugin-dir` From b8f9eda4d90c3bc974cf694eba487b619dd0efec Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Thu, 7 May 2026 11:23:37 -0700 Subject: [PATCH 5/7] Handle decorated Claude tmux titles --- src/core/tmux.ts | 12 +++++++++--- test/cli.test.ts | 4 ++-- test/tmux.test.ts | 9 +++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/core/tmux.ts b/src/core/tmux.ts index f4fb3a1..1eab4df 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -98,6 +98,7 @@ function pickDetectedAgent( export function detectAgentPane(pane: TmuxPane): PaneDetection { const title = pane.paneTitle.trim(); const lowerTitle = title.toLowerCase(); + const normalizedLowerTitle = lowerTitle.replace(/^[^a-z0-9]+/, ""); const path = pane.currentPath.toLowerCase(); const command = pane.currentCommand.toLowerCase(); const opencodeReasons: string[] = []; @@ -132,7 +133,8 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { const hasPiTitleHint = lowerTitle === "pi" || lowerTitle.startsWith("pi - ") || title.startsWith("π - "); - const hasClaudeTitleHint = lowerTitle === "claude" || lowerTitle.startsWith("claude code"); + const hasClaudeTitleHint = + normalizedLowerTitle === "claude" || normalizedLowerTitle.startsWith("claude code"); if (hasPiTitleHint) { piReasons.push("title:Pi"); @@ -179,11 +181,15 @@ export function detectAgentPane(pane: TmuxPane): PaneDetection { }); } - if (claudeReasons.some((reason) => reason.startsWith("command:"))) { + if (claudeReasons.length > 0) { candidates.push({ agent: "claude", reasons: claudeReasons, - score: hasClaudeTitleHint ? 5 : 4, + score: claudeReasons.some((reason) => reason.startsWith("command:")) + ? hasClaudeTitleHint + ? 5 + : 4 + : 4, }); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 22b32e5..c75a475 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -741,7 +741,7 @@ if [ "$1" = "list-panes" ]; then printf 'work\t1\t1\t%%2\tOpenCode\topencode\t/tmp/project-b\t0\t/dev/ttys002\n' printf 'work\t1\t2\t%%4\tShell\tcodex\t/tmp/codex-project\t0\t/dev/ttys004\n' printf 'work\t1\t5\t%%5\tπ - pi-project\tpi\t/tmp/pi-project\t0\t/dev/ttys005\n' - printf 'work\t1\t6\t%%6\tClaude Code\tclaude\t/tmp/claude-project\t0\t/dev/ttys006\n' + printf 'work\t1\t6\t%%6\t✳ Claude Code\t2.1.132\t/tmp/claude-project\t0\t/dev/ttys006\n' printf 'work\t2\t0\t%%3\tShell\tbash\t/tmp/other\t0\t/dev/ttys003\n' exit 0 fi @@ -817,7 +817,7 @@ exit 1 assert.equal(claudeResult.exitCode, 0); assert.equal( claudeResult.stdoutText.trim(), - "work:1.6\tbusy\trunning\tclaude-command\t0\t(unmatched)\tClaude Code\t/tmp/claude-project", + "work:1.6\tbusy\trunning\tclaude-command\t0\t(unmatched)\t✳ Claude Code\t/tmp/claude-project", ); } finally { restoreEnv(); diff --git a/test/tmux.test.ts b/test/tmux.test.ts index c7dbcc0..35e5a63 100644 --- a/test/tmux.test.ts +++ b/test/tmux.test.ts @@ -137,6 +137,15 @@ test("detectAgentPane recognizes OpenCode, Codex, Pi, and no-signal panes", () = }, ); + assert.deepEqual( + detectAgentPane(createPane({ paneTitle: "✳ Claude Code", currentCommand: "2.1.132" })), + { + agent: "claude", + confidence: "high", + reasons: ["title:Claude"], + }, + ); + assert.deepEqual(detectAgentPane(createPane({ paneTitle: "π - work", currentCommand: "bash" })), { agent: null, confidence: "low", From 72adb5ae839bb694f7854552bad52751f6f73b4d Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Thu, 7 May 2026 11:54:13 -0700 Subject: [PATCH 6/7] style: format Claude support files --- src/core/claude.ts | 30 +++++++++++++++++++++--------- test/claude.test.ts | 32 ++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/core/claude.ts b/src/core/claude.ts index 3ea8dc1..21c2783 100644 --- a/src/core/claude.ts +++ b/src/core/claude.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; @@ -116,10 +123,8 @@ function countChoiceLines(message: string): number { return message .split(/\r?\n/) .map((line) => line.trim()) - .filter( - (line) => - /^(?:[›>]\s*)?\d+\.\s+\S/.test(line) || /^(?:[›>]\s*)?[-*]\s+\S/.test(line), - ).length; + .filter((line) => /^(?:[›>]\s*)?\d+\.\s+\S/.test(line) || /^(?:[›>]\s*)?[-*]\s+\S/.test(line)) + .length; } function classifyWaitingMessage(message: string | null | undefined): RuntimeStatus | null { @@ -257,9 +262,11 @@ function classifyAskUserQuestion(toolInput: unknown): { detail: string; status: RuntimeStatus; } { - const questions = isRecord(toolInput) && Array.isArray(toolInput.questions) ? toolInput.questions : []; + const questions = + isRecord(toolInput) && Array.isArray(toolInput.questions) ? toolInput.questions : []; const hasOptions = questions.some( - (question) => isRecord(question) && Array.isArray(question.options) && question.options.length > 0, + (question) => + isRecord(question) && Array.isArray(question.options) && question.options.length > 0, ); if (hasOptions) { @@ -609,7 +616,10 @@ function getExactClaudeState(index: ClaudeStateIndex, pane: TmuxPane): ClaudeSta return null; } -function getDirectoryFallbackClaudeState(index: ClaudeStateIndex, pane: TmuxPane): ClaudeStateFile | null { +function getDirectoryFallbackClaudeState( + index: ClaudeStateIndex, + pane: TmuxPane, +): ClaudeStateFile | null { const states = index.statesByDirectory.get(pane.currentPath) ?? []; if (states.length !== 1) { @@ -619,7 +629,9 @@ function getDirectoryFallbackClaudeState(index: ClaudeStateIndex, pane: TmuxPane return states[0] ?? null; } -function classifyClaudePreview(lines: string[]): Pick | null { +function classifyClaudePreview( + lines: string[], +): Pick | null { const nonEmptyLines = lines.map((line) => line.trim()).filter(Boolean); const recentLines = nonEmptyLines.slice(-8); const recentText = recentLines.join("\n"); diff --git a/test/claude.test.ts b/test/claude.test.ts index 26311f2..676efe9 100644 --- a/test/claude.test.ts +++ b/test/claude.test.ts @@ -253,9 +253,21 @@ test("Claude runtime matches panes by target, pane id, and unique cwd fallback", try { const summaries = await attachRuntimeToPanes([ - createDiscoveredClaudePane({ target: "work:1.0", paneId: "%1", currentPath: "/tmp/claude-a" }), - createDiscoveredClaudePane({ target: "work:1.1", paneId: "%9", currentPath: "/tmp/claude-b" }), - createDiscoveredClaudePane({ target: "work:1.2", paneId: "%3", currentPath: "/tmp/claude-c" }), + createDiscoveredClaudePane({ + target: "work:1.0", + paneId: "%1", + currentPath: "/tmp/claude-a", + }), + createDiscoveredClaudePane({ + target: "work:1.1", + paneId: "%9", + currentPath: "/tmp/claude-b", + }), + createDiscoveredClaudePane({ + target: "work:1.2", + paneId: "%3", + currentPath: "/tmp/claude-c", + }), ]); assert.equal(summaries[0]?.runtime.status, "running"); @@ -287,12 +299,18 @@ exit 1 `); const restoreEnv = setEnv({ PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`, - CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync(join(tmpdir(), "coding-agents-tmux-empty-claude-state-")), + CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync( + join(tmpdir(), "coding-agents-tmux-empty-claude-state-"), + ), }); try { const previewSummaries = await attachRuntimeToPanes([ - createDiscoveredClaudePane({ target: "work:1.0", paneId: "%1", currentPath: "/tmp/claude-project" }), + createDiscoveredClaudePane({ + target: "work:1.0", + paneId: "%1", + currentPath: "/tmp/claude-project", + }), ]); assert.equal(previewSummaries[0]?.runtime.status, "waiting-question"); @@ -303,7 +321,9 @@ exit 1 } const restoreEmptyEnv = setEnv({ - CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync(join(tmpdir(), "coding-agents-tmux-empty-claude-state-")), + CODING_AGENTS_TMUX_CLAUDE_STATE_DIR: mkdtempSync( + join(tmpdir(), "coding-agents-tmux-empty-claude-state-"), + ), }); try { From 6c2e8e9885c63dd3787f168673a3ec6faa031fdc Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Thu, 7 May 2026 11:58:23 -0700 Subject: [PATCH 7/7] docs: recommend enabling managed agent installs --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 81089cd..2d0b9d9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Recommended settings: ```tmux set -g @coding-agents-tmux-provider 'plugin' +set -g @coding-agents-tmux-auto-install 'opencode,pi,codex,claude' set -g @coding-agents-tmux-menu-key 'O' set -g @coding-agents-tmux-popup-key 'P' set -g @coding-agents-tmux-waiting-menu-key 'W' @@ -44,7 +45,7 @@ set -g @coding-agents-tmux-status-position 'right' set -g @coding-agents-tmux-status-interval '0' ``` -Those defaults favor the bundled plugin provider and event-driven status redraws so tmux stops polling Node in the background. +Those settings favor the bundled plugin provider, explicitly enable tmux-managed installs for the bundled OpenCode plugin plus the Pi extension and Codex/Claude hooks, and use event-driven status redraws so tmux stops polling Node in the background. To match your tmux theme, you can also override the status colors: @@ -76,7 +77,9 @@ Requirements: ## What TPM sets up -By default, the TPM plugin also installs the bundled `opencode` plugin by creating these symlinks: +With the recommended settings above, the tmux plugin manages the bundled `opencode` plugin, the Pi extension, and the Codex and Claude hook installs for you. + +It installs the bundled `opencode` plugin by creating these symlinks: ```text ~/.config/opencode/plugins/coding-agents-tmux.ts @@ -134,7 +137,7 @@ It also installs or updates Codex hook integration under: ~/.codex/hooks.json ``` -It can also install or update Claude Code hook integration under: +With the recommended `@coding-agents-tmux-auto-install 'opencode,pi,codex,claude'` setting, it also installs or updates Claude Code hook integration under: ```text ~/.claude/settings.json @@ -172,7 +175,7 @@ set -g @coding-agents-tmux-auto-install 'off' set -g @coding-agents-tmux-auto-install 'opencode,pi,codex,claude' ``` -When `@coding-agents-tmux-auto-install` is set, it takes precedence over the individual install toggles. +The explicit `opencode,pi,codex,claude` list is the recommended README setting because it makes the intended managed installs obvious in your tmux config. When `@coding-agents-tmux-auto-install` is set, it takes precedence over the individual install toggles. ## Usage