# tmux-pilot

Spawn subagents as real coding agent processes in detached tmux windows — full TUI observability, external control, and session file result extraction.

## Features

- **Full TUI visibility** — each subagent runs in its own tmux window with the complete pi TUI, tool calls, thinking indicators, and model responses visible in real-time
- **One session per parent** — all subagents for a parent pi session are grouped in a single tmux session (`pilot-<id>`) with one window per subagent
- **Two tmux topologies** — `current-session` (default) puts subagent windows on your own tmux server, natively visible; `isolated` uses a dedicated per-parent socket/server
- **Party/raid frames widget** — WoW-style unit frames showing live subagent status, context headroom, turn budget, and current tool
- **External control** — steer subagents by typing directly in their tmux window, or use the `steer_subagent` tool
- **Result extraction** — completion detected by a per-harness lifecycle-end sentinel; results extracted from the last assistant message
- **Config-dir scope** — point individual roles or harnesses at curated config directories (settings, skills, agents, auth, MCP servers)
- **Guard lifecycle** — turn limits, grace periods, steer thresholds, concurrency caps, and optional wall-clock/staleness timeouts
- **Worktree isolation** — run subagents in isolated git worktrees with auto-merge for ephemeral branches
- **Opt-in OS sandbox** — static filesystem, network, environment, and IPC containment for child harnesses on supported Linux x64 hosts

## Installation

```bash
pi install npm:@r3b1s/tmux-pilot
```

## Requirements

- **tmux** must be installed on your system:
  - macOS: `brew install tmux`
  - Ubuntu/Debian: `apt install tmux`
  - Fedora: `dnf install tmux`
- **pi** CLI (obviously)

## Usage

Once installed, the extension registers:

- `subagent` — spawn one or more subagents in tmux windows, with model routing from `tmux-pilot.config.yaml` (pass a batch to fan out several at once)
- `get_subagent_result` — retrieve a subagent's result
- `steer_subagent` — send a message to a running subagent, or stop it
- `resume_subagent` — re-prompt an existing subagent (e.g. to continue an interrupted one)

### Slash commands

- `/tmux-pilot:toggle-ui` — show/hide the subagent widget (shortcut: `alt+a` by default)
- `/tmux-pilot:settings` — open the settings modal
- `/tmux-pilot:webui` — open the complete local browser configuration console
- `/tmux-pilot:log` — open the debug log viewer
- `/tmux-pilot:collect-subagent-garbage` — manually reap finished or stray subagents

### Full configuration console

`/tmux-pilot:webui` opens the full configuration surface in your default
browser. It starts a temporary server bound only to `127.0.0.1`, uses a
one-time URL token exchanged for a strict local session cookie, and never
opens a LAN port. If the browser launcher is unavailable, pi shows a local URL
you can copy into a browser yourself. `/tmux-pilot:settings` remains the
terminal quick-settings option.

The console edits only the entry `tmux-pilot.config.yaml`. Imports are visible
and contribute effective values, but are deliberately read-only; form edits
create entry-document overrides and preserve unrelated YAML comments and
structure. Save is explicit, validates first, and refuses to overwrite a file
changed by another editor. Routing, lifecycle, and role appearance changes
apply to future spawns; widget topology/presentation settings apply on the
next pi session. Existing agents are never restarted or mutated.

Each role detail page also exposes its effective `agent-definition` Markdown
document. This remains separately saved from YAML: imported role YAML is still
read-only, while an imported role's configured Markdown can be edited or
created through the authenticated local console. The server resolves the file
from the current role configuration (the browser never submits a path), reports
shared/global inheritance and unavailable documents, and detects external
edits before atomically saving. A definition's optional frontmatter
`description` enriches the role enum, while its Markdown body is delivered as
role instructions on the next new subagent spawn. Definitions are limited to
64 KiB and must be valid UTF-8 with a non-empty body; a configured missing,
invalid, or oversized document rejects that spawn before a tmux window is
created. Existing/running agents and resumed conversations are never changed.
Repair stale definitions in the console's Markdown editor, then start a new
subagent.

The console defaults to loopback port `41273`, so a remote host can use a
consistent tunnel:

```bash
ssh -L 127.0.0.1:41273:127.0.0.1:41273 user@remote-host
```

Override it under `web-configuration-console.port` (1024–65535). Use the same
number on both sides of the tunnel because local writes are intentionally bound
to that exact Host and Origin.

```yaml
web-configuration-console:
  port: 41273
```

For local web UI work, run `pnpm webui:dev`. It uses the same loopback server
and default port as `/tmux-pilot:webui`, serves directly from `src/web`, and
loads a disposable showcase configuration with every implemented harness and
all packaged art/theme variants. Refreshing the browser picks up HTML, CSS,
and JavaScript edits without a rebuild. Use `--port` to match a configured
console port; `--config-dir` is available when intentionally testing against a
real configuration.

### Attaching to the tmux session

In the default `current-session` topology, subagent windows live in a session on your own tmux server:

```bash
tmux attach -t pilot-<parentSessionId>
```

In `isolated` topology, each parent gets a dedicated socket. Use the attach hint shown in the spawn output (`tmux -S <socket> attach -t <session>`).

Use `tmux list-sessions` (or `tmux -S <socket> list-sessions` for isolated) to find active sessions.

### Steer by typing

While attached to the tmux session, you can type directly into any subagent's window — it's received as user input by the pi process.

### Kill subagents

- From tmux: press `Ctrl+C` in the subagent's window
- From the `steer_subagent` tool: set `kill: true`

## How it works

1. On the first subagent spawn, tmux-pilot creates a detached tmux session named `pilot-<parentSessionId>` (on your own server by default, or on a dedicated socket if `subagent-execution.tmux-server: isolated`)
2. Each subagent gets its own tmux window running its harness (e.g. `pi --session-id <id> "<prompt>"`)
3. A per-spawn ephemeral config directory is synthesized under tmux-pilot's host storage dir (`~/.pi/agent/tmp/subagents/<parentId>/<agentId>/`) when a harness-config-directory override is configured; otherwise the harness uses its default config source
4. The harness writes a lifecycle-end sentinel when its agent loop ends; the extension extracts the result from the session JSONL only after the sentinel fires
5. When `subagent-execution.cleanup-enabled` is true (default), the subagent's tmux window, TUI process, ephemeral config dir, temp files, and ephemeral worktree are removed after it reaches a terminal state (optionally delayed by `cleanup-linger-ms`). The parent `pilot-<id>` session is kept. Tracker records are kept for `get_subagent_result`

## Configuration

Configuration lives in `~/.pi/agent/tmux-pilot.config.yaml` (or `$PI_CODING_AGENT_DIR/tmux-pilot.config.yaml` if set). `agent-model-routing.yaml` is not loaded.

### Compose files

Any configuration document may import local files without a prescribed folder layout:

```yaml
# tmux-pilot.config.yaml
imports:
  - shared/defaults.yaml
  - teams/**/*.yaml
```

Imports resolve relative to the file that declares them (or may be absolute); glob matches are lexical. Imports are depth-first: maps deep-merge, lists replace, later values win, and entry-file values win last. Relative `agent-definition` and `harness-config-directory` values remain relative to the entry config directory. Duplicate role names are case-insensitive, warn once with both locations, and use the later declaration. Empty globs warn; missing files, malformed files, and cycles fail configuration loading. Imports do not support environment interpolation, URLs, commands, or cross-file YAML anchors.

```yaml
# tmux-pilot.config.yaml
subagent-execution:
  max-concurrent-agents: 5
  cleanup-enabled: true
  tmux-server: current-session

subagent-widget:
  display-mode: auto
  placement: above-editor
  toggle-key: alt+a

worktree-settings:
  keep-named-worktrees: false

harness-global-defaults:
  pi:
    stop-key-combo: "C-c"
    # cleanup-linger-ms: 0
    # harness-config-directory:
    #   path: ~/curated/pi
    #   mode: overlay
  cmd:
    stop-key-combo: "C-c"
  claude-code:
    stop-key-combo: "C-c"
    # cleanup-linger-ms: 15000
    # harness-config-directory:
    #   path: ~/.claude-work
    #   mode: replace

subagent-global-defaults:
  thinking: medium
  provider: opencode-go
  models:
    - deepseek-v4-flash
    - mimo-v2.5
  worktree-enabled: false
  # cleanup-linger-ms: 0   # global default delay before window teardown

  lifecycle-guards:
    session-file-timeout-ms: 60000
    max-turns: 50
    grace-turns: 5
    session-poll-interval-ms: 500
    staleness-timeout-ms: 0
    max-runtime-ms: 0

  # Opt-in; see docs/os-sandbox.md for the threat model and exact semantics.
  sandbox:
    enabled: false
    on-unavailable: refuse-spawn
    env-allowlist-extra: []
    filesystem:
      read: []
      write: []
      deny-read: []
      deny-write: []
    network:
      default: allow
      allow: []
      deny: []

subagent-roles:
  implementer:
    thinking: high
    harness: pi
    worktree-enabled: true
    lifecycle-guards:
      max-turns: 25
      turn-thresholds:
        - at-turns-remaining: 5
          action:
            type: steer
            prompt: >
              You have {{turnsRemaining}} turns remaining.
              Wrap up and hand off results.
    models:
      - deepseek-v4-flash:
          provider: opencode-go
          thinking: xhigh
```

### Field reference

| Field | Scope | Description |
|---|---|---|
| `subagent-execution.max-concurrent-agents` | Global | Maximum agents running simultaneously. `0` = unlimited. |
| `subagent-execution.cleanup-enabled` | Global | When `true` (default), tear down terminal agents automatically (after optional linger). |
| `cleanup-linger-ms` | Global / harness / role | Ms to keep the finished TUI visible before cleanup. `0` = immediate. Chain: role > harness-global-defaults > subagent-global-defaults > `0`. |
| `worktree-settings.keep-named-worktrees` | Global | When `false` (default), all worktrees are ephemeral. |
| `harness-global-defaults.<id>.stop-key-combo` | Per-harness | tmux key combo sent when grace is exceeded. |
| `harness-global-defaults.<id>.harness-config-directory` | Per-harness | Default curated config dir for a harness. Object `{ path, mode }` with `mode: overlay` (default) or `replace`. |
| `harness-global-defaults.<id>.cleanup-linger-ms` | Per-harness | Per-harness cleanup linger override. |
| `subagent-execution.tmux-server` | Global | `current-session` (default) or `isolated`. Applies on next session. |
| `subagent-widget.display-mode` | Global | Widget layout: `auto`, `party`, or `raid`. |
| `subagent-widget.placement` | Global | Widget placement: `above-editor` or `below-editor`. |
| `subagent-widget.toggle-key` | Global | pi KeyId shortcut for toggling the widget. |
| `subagent-widget.party-frame-min-width` | Global | Minimum column width a party frame may shrink to. Positive int. |
| `subagent-widget.party-frame-max-width` | Global | Maximum column width a party frame may grow to. Positive int. |
| `subagent-global-defaults.thinking` | Global | Default thinking level inherited by bare-string models. |
| `subagent-global-defaults.provider` | Global | Default provider inherited by bare-string models. |
| `subagent-global-defaults.models` | Global | Default model fallback chain (replaced wholesale by a role's `models`). |
| `agent-definition` | Role | Path to a Markdown file whose body is delivered as the role's instructions on the next spawn. Resolved against `agent-definition-directory` (or the config dir). Optional frontmatter `description` enriches the role enum. |
| `agent-definition-directory` | Global / Role | Base directory for resolving relative `agent-definition` paths. |
| `subagent-global-defaults.worktree-enabled` | Global | Default worktree isolation. |
| `subagent-global-defaults.lifecycle-guards.*` | Global | Default guard values. |
| `subagent-global-defaults.cleanup-linger-ms` | Global | Global default cleanup linger (ms). |
| `subagent-global-defaults.sandbox.*` | Global | Default opt-in OS sandbox policy. Arrays replace wholesale; `on-unavailable` defaults to `refuse-spawn`. |
| `subagent-roles.<role>.harness` | Role | Harness for the role: `pi` (default), `cmd`, `claude-code`, `codex`, or `hermes`. |
| `subagent-roles.<role>.worktree-enabled` | Role | Override worktree isolation for this role. |
| `subagent-roles.<role>.harness-config-directory` | Role | Override config dir for this role. Object `{ path, mode }`. |
| `subagent-roles.<role>.cleanup-linger-ms` | Role | Per-role cleanup linger override. |
| `subagent-roles.<role>.sandbox.*` | Role | Per-leaf sandbox override for the role; applies on the next spawn. |
| `lifecycle-guards.session-file-timeout-ms` | Per-role | Max wait (ms) for a session file to appear. |
| `lifecycle-guards.max-turns` | Per-role | Target turn count before grace. `0` = no limit. |
| `lifecycle-guards.grace-turns` | Per-role | Extra turns after `max-turns` before forced stop. |
| `lifecycle-guards.session-poll-interval-ms` | Global | Polling interval for session analysis (ms). Minimum `100`. |
| `lifecycle-guards.staleness-timeout-ms` | Per-role | Max ms with no session-file growth before failing as wedged. `0` = disabled. |
| `lifecycle-guards.max-runtime-ms` | Per-role | Overall wall-clock timeout (ms). `0` = disabled. |
| `lifecycle-guards.turn-thresholds` | Per-role | Ordered list of thresholds that fire steer prompts. Replaced wholesale at role level. |

See [Subagent OS sandbox](docs/os-sandbox.md) for Linux read semantics,
default-allow network warnings, protected environment families, provider
requirements, the Hermes real-state consequence, audit behavior, and accepted
residual risks.

### Threshold actions

Each turn threshold fires when `max-turns - currentTurn === at-turns-remaining`. Currently supported:

- **`steer`** — Sends a prompt template to the agent via tmux `send-keys`. Supports `{{turnsRemaining}}` and `{{agentId}}` template variables. Each `at-turns-remaining` fires only once (deduplicated).

### Harness config-directory override

Point a role or harness at a curated directory with its own settings, skills, agents, auth, and MCP servers.

- **`overlay`** (default): core deep-merges the harness's base config with your overlay, symlinks auth from base, and places the result in an ephemeral per-spawn dir that is torn down with the agent.
- **`replace`**: the harness reads your directory directly. For pi / claude-code the dir IS the agent dir (`PI_CODING_AGENT_DIR` / `CLAUDE_CONFIG_DIR`); for cmd the dir is HOME-shaped and must contain `.commandcode/`.

A harness without a `configProfile` warns and ignores any `harness-config-directory` configured for it.

### Supported harnesses

| Harness | CLI | Models |
|---|---|---|
| `pi` (default) | `pi` | Any pi-routed provider/model |
| `cmd` | `cmd` | `provider/model` strings |
| `claude-code` | `claude` | **Anthropic models only** (`haiku` / `sonnet` / `opus` / …). Routing `provider` is ignored (never mapped to `ANTHROPIC_BASE_URL`). Use ambient Claude/Anthropic auth; custom gateways must set `ANTHROPIC_BASE_URL` in the environment themselves. |
| `codex` | `codex` | **OpenAI models only** (`gpt-5.4-mini`, …). Routing `provider` is ignored; uses your ambient `codex login` auth. Each spawn runs in a synthesized `CODEX_HOME` (so subagents don't see your codex memories/state). |
| `hermes` | `hermes` | Model and `provider` pass through verbatim to `-m` / `--provider`. Runs against your real `~/.hermes` (memories/skills/identity preserved). |

Harness-specific operator notes:

- **`codex`** — the stop key is `Escape`, not `Ctrl+C` (`C-c` quits the codex
  TUI). If you run on a ChatGPT plan, rate limits are account-global, so
  parallel codex spawns share one usage window — use `max-concurrent-agents`
  to throttle.
- **`hermes`** — the first spawn permanently installs a small env-gated
  telemetry plugin into `~/.hermes/plugins/tmux-pilot/` (inert for your own
  hermes sessions). `harness-config-directory` is not supported for hermes
  (configured values are warned-and-ignored). If you set
  `max_concurrent_sessions` in hermes' own `config.yaml`, parallel spawns can
  fail on its active-session lease.

See [`docs/harness-specific-notes/`](docs/harness-specific-notes/) for the full
per-harness reference (auth, config, session parsing, sentinels).

## Worktree Isolation

Each subagent can optionally run in its own [git worktree](https://git-scm.com/docs/git-worktree) — a fully independent working directory sharing the repository's `.git` object store. This provides filesystem isolation for parallel subagents: each gets its own branch checkout without interfering with others or the parent repo.

### Enabling worktree isolation

Pass `worktree_branch` to the `subagent` tool for explicit branch naming, or just provide a `description` to auto-generate a branch:

```yaml
# Explicit branch name — persistence is gated by worktree-settings.keep-named-worktrees
- subagent:
    prompt: "Implement OAuth2 login flow"
    description: "OAuth2 implementation"
    worktree_branch: "feat/oauth2"

# Auto-generated branch (ephemeral by default — merged and removed on completion)
- subagent:
    prompt: "Fix the flaky test in auth.spec.ts"
    description: "Fix flaky auth test"
```

### Branch name resolution

| `worktree_branch` | `description` | `worktree-settings.keep-named-worktrees` | Result |
|---|---|---|---|
| Set | Any | `true` | Uses `worktree_branch` as a persistent worktree |
| Set | Any | `false` | Uses `worktree_branch` as a display label; branch is auto-generated and ephemeral |
| Empty/whitespace | Set | any | Auto-generates `<description>-<hash>` (ephemeral) |
| Empty/whitespace | Not set | any | Auto-generates `agent-<hash>` (ephemeral) |

The hash is a 6-character FNV-1a hash of the agent ID, providing collision-resistant uniqueness even for concurrent spawns.

### Path convention

Worktrees are created at:

```
<repo-root>/.worktrees/<branch-name>/
```

The `.worktrees/` directory is automatically added to `.gitignore`.

### Merge behavior

- **Ephemeral worktrees** (auto-generated branch names): on agent completion, the worktree is squash-merged back to the base branch (`main`/`master`). If conflicts arise, they are surfaced in the agent result and the worktree is preserved for inspection.
- **Explicit worktrees** (`worktree_branch` set with `worktree-settings.keep-named-worktrees: true`): no automatic merge — the branch and worktree persist for the user to manage.

### Cache sharing configuration

Create `.pi/worktree.yaml` in your repo root to configure which gitignored directories are copied into new worktrees (avoiding cold `npm install` / `cargo build`):

```yaml
# .pi/worktree.yaml
cache_sharing:
  copy_ignored:
    - node_modules/
    - target/
    - .venv/
    - dist/

symlinks:
  - .pi/
  - .commandcode/
```

Project config directories (`.pi/`, `.commandcode/`) are symlinked into worktrees by default — no copy overhead.

### Error handling

- **Not a git repo**: if the CWD is not inside a git repository, worktree creation fails with a clear error message mentioning the path and suggesting you run from a git repo.
- **Invalid branch names**: branches starting with `-`, containing `..`, ending with `.lock`, or containing whitespace are rejected with descriptive errors before attempting `git worktree add`.
- **Branch already checked out**: if the branch is checked out in another worktree, an error is raised suggesting a unique name.

### Symlink staging prevention

When bootstrapping a worktree, symlinks for project config directories (`.pi/`, `.commandcode/`) are added to `<repo>/.git/info/exclude` so they do not get staged when the agent runs `git add -A`. This ensures the auto-merge path stays clean — symlinked config directories are already tracked as real directories in the main repo, and staging symlink artifacts would produce confusing diffs or break the squash merge.

The exclusion is idempotent: entries are only added once, and the same `info/exclude` file is shared by all worktrees (per git's `GIT_COMMON_DIR` semantics).

### Security notes

- **Branch name validation**: all branch names are validated before use. Names containing `..`, refspec characters (`^~:@{}`), leading `.` or `-`, `.lock` suffix, or whitespace are rejected with descriptive errors.
- **Symlink name validation**: symlink target names are checked for path traversal (`..`), absolute paths, and null bytes. The resolved symlink path is also verified to remain within the worktree directory.
- **No shell injection**: all `git` calls use `execFileSync` with argv arrays — no shell interpolation occurs, preventing command injection from branch names, paths, or config values.
- **Safe YAML parsing**: `.pi/worktree.yaml` is parsed with `js-yaml`'s `load()` (safe schema by default). Invalid or non-object YAML falls back to safe defaults with a warning.

## Development

This project uses [pnpm](https://pnpm.io/) and [mise](https://mise.jdx.dev/):

```bash
mise install   # install pnpm, node, etc.
mise run test  # run the test suite
mise run ci    # run check + lint + test
```

## License

Copyright (c) 2026 r3b1s

Released under the [MIT License](./LICENSE).
