Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
/target
/.venv
__pycache__/
.pytest_cache/
41 changes: 0 additions & 41 deletions CLAUDE.md

This file was deleted.

125 changes: 125 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Contributing to Heimdall

## Prerequisites

- Rust stable toolchain (rustup recommended)
- [just](https://github.com/casey/just) command runner
- [uv](https://docs.astral.sh/uv/) (for Python attach tests)
- Optional: `cargo-llvm-cov` for coverage reports

Run `just doctor` to verify your environment.

## Setup

```bash
git clone https://github.com/nazq/heimdall.git
cd heimdall
cargo build
uv sync # install Python test deps (pexpect, pytest)
just doctor
```

## Just targets

| Target | Description |
|--------------------|--------------------------------------------------|
| `just check` | Run all quality checks (clippy + fmt + tests) |
| `just test` | Run unit and Rust integration tests |
| `just test-attach` | Run Python attach tests (requires `uv sync`) |
| `just test-all` | Full test suite (Rust + Python) |
| `just fmt` | Format code |
| `just fmt-check` | Check formatting without modifying files |
| `just clippy` | Lint with clippy |
| `just build` | Debug build |
| `just release` | Release build |
| `just install` | Build release and install `hm` to `~/.local/bin` |
| `just cov` | Generate coverage report (requires cargo-llvm-cov)|

## Running locally

```bash
# Start a supervised session
just run my-session bash

# Attach to it from another terminal
just attach my-session

# List running sessions
just ls

# Check session status
just status my-session

# Kill a session
just kill my-session
```

## Test expectations

All PRs must pass `just check` (clippy + format check + full test suite).

### Testing philosophy

Tests exist to prove the system works, not to prove the code compiles. Every
test must satisfy three criteria:

1. **Setup is correct** — the test creates the right preconditions and waits
for them (e.g. socket appears before connecting).
2. **The operation runs** — the test actually exercises the code path it claims
to test, not a happy-path shortcut.
3. **All invariants are asserted** — don't assert one field when the response
has five. If a STATUS_RESP has pid, idle_ms, alive, state, and state_ms,
assert the ones that have known-good values. Skipping fields hides bugs.

### Wire-level protocol tests

The protocol is documented in [`docs/protocol.md`](docs/protocol.md). Protocol
tests come in two flavours, and both are required:

- **Round-trip tests** — pack through `pack_*`, parse through `read_frame`,
verify fields match. These catch regressions but have a blind spot: if pack
and parse have the same bug (e.g. both swap two fields), the test passes
while the wire format is silently wrong.
- **Golden byte tests** — assert that a known input produces an exact byte
sequence. These pin the wire format to the documented spec and catch
symmetric pack/parse bugs that round-trip tests cannot.

When adding a new frame type or modifying a payload layout, add both.

### Integration tests

- **Rust** (`tests/integration.rs`) — spawn real `hm` processes, connect over
Unix sockets, send/receive protocol frames, assert responses byte-by-byte.
These test the supervisor end-to-end without mocks.
- **Python** (`tests/test_attach.py`) — use pexpect over real PTYs to verify
the terminal UX: alt screen, status bar, detach, signal forwarding, resize.
Run via `just test-attach` (requires `uv sync`).

Both suites use temp directories for socket isolation and clean up processes
in fixtures/teardown.

## Commit style

Conventional commits. One logical change per commit.

```
feat: add scrollback size config option
fix: handle SIGCHLD race on rapid child exit
deps: bump nix to 0.29
ci: add aarch64-linux to release matrix
docs: clarify process group signaling in ARCH.md
```

## Code style

- `cargo fmt` -- all code must be formatted.
- `cargo clippy -- -W clippy::all` -- no warnings allowed.

## Documentation

See `docs/` for detailed documentation:

- [Architecture](docs/ARCH.md) -- design principles, process lifecycle, data flow
- [Protocol](docs/protocol.md) -- wire format and message types
- [Classifiers](docs/classifiers.md) -- state detection and custom classifiers
- [Configuration](docs/configuration.md) -- all config options
39 changes: 39 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
filetime = "0.2"

[profile.release]
strip = true
Expand Down
47 changes: 29 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

Fork. Watch. Control. From anywhere.

*Named for the Norse guardian who watches over Bifrost — Heimdall sees all, hears all, and nothing escapes on his watch.*

[![CI](https://github.com/nazq/heimdall/actions/workflows/ci.yml/badge.svg)](https://github.com/nazq/heimdall/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/nazq/heimdall/graph/badge.svg)](https://codecov.io/gh/nazq/heimdall)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Expand Down Expand Up @@ -35,7 +37,7 @@ tools, different jobs.
|---|---|---|---|---|
| Terminal multiplexer (splits, tabs) | Yes | Yes | Yes | No |
| PTY supervision (fork, own, reap) | Side effect | Side effect | Side effect | Core purpose |
| Process group kill (`kill -pgid`) | No | No | No | Yes |
| Process group kill (`kill -pgid`) | No | No | No | Yes (default, configurable) |
| Multi-client attach (concurrent) | One at a time | One at a time | One at a time | Unlimited |
| Binary socket protocol (5-byte frames) | No | No | No | Yes |
| Scrollback replay for late joiners | Per-pane buffer | Per-window | Per-pane | Ring buffer, streamed on subscribe |
Expand All @@ -44,7 +46,7 @@ tools, different jobs.
| Pre-exec seam (env, workdir, future: cgroups) | Limited | Limited | No | Full control of fork/exec boundary |
| Config per project | `.tmux.conf` | `.screenrc` | `config.kdl` | `./heimdall.toml` |
| Zero dependencies at runtime | Needs server | Needs server | Needs server | Single static binary |
| Grandchild cleanup on kill | No | No | No | Yes (`setsid` + `-pgid`) |
| Grandchild cleanup on kill | No | No | No | Yes (default; set `kill_process_group = false` to disable) |

Heimdall doesn't replace tmux — it replaces the part of tmux you were
misusing as a process supervisor.
Expand Down Expand Up @@ -118,13 +120,24 @@ hm kill my-session # SIGTERM to entire process group, SIGKILL after 5s

### Configure (optional)

Drop a `heimdall.toml` in your project directory, or at
`~/.config/heimdall/heimdall.toml` for global defaults:
Heimdall resolves configuration using a waterfall — the first file found wins:

1. **`--config <path>`** — explicit path passed on the command line
2. **`./heimdall.toml`** — in the current working directory (project-local)
3. **`~/.config/heimdall/heimdall.toml`** — global user defaults
4. **Built-in defaults** — sensible values if no file is found

```toml
classifier = "claude" # "claude", "simple", or "none"
idle_threshold_ms = 3000
scrollback_bytes = 65536
kill_process_group = true # set to false to only signal the direct child

# Classifier as a string (uses defaults):
classifier = "simple"

# Or with custom parameters:
# [classifier.claude]
# idle_threshold_ms = 3000
# debounce_ms = 200

[[env]]
name = "MY_API_KEY"
Expand All @@ -135,18 +148,16 @@ See [`heimdall.example.toml`](heimdall.example.toml) for all options.

## How it works

```
hm run --id foo -- bash
├─ fork() before async runtime (single-threaded safety)
│ ├─ child: setsid → new process group, pty slave → stdio, exec
│ └─ parent: own master fd, write PID file
├─ tokio event loop (single-threaded)
│ ├─ pty read → scrollback + broadcast to subscribers
│ ├─ SIGCHLD → reap, broadcast EXIT
│ └─ SIGTERM → kill(-pgid), reap, broadcast EXIT
└─ cleanup: remove socket + PID, exit with child's code
```
Heimdall acts as a middleman between you and the process you want to supervise. Think of it like a bodyguard that starts your program, keeps it alive, lets visitors talk to it, and handles the cleanup when it's done.

Here's what happens when you run `hm run --id foo -- bash`:

- **Launches as its own session leader.** The supervisor calls `setsid` to become the leader of a new process session. This means even if you close the terminal window that started it, the supervised process keeps running. You can always reattach later with `hm attach`.
- **Starts your command inside a virtual terminal.** Your program thinks it's running in a normal terminal, so interactive tools (editors, TUIs, colored output) all work as expected.
- **Owns the entire process tree.** The supervised command and everything it spawns belong to one process group. When you `hm kill`, the signal reaches every descendant — no orphaned grandchildren left behind. This is the default behavior; set `kill_process_group = false` in your config if you want only the direct child to receive signals.
- **Opens a Unix socket for clients.** Any number of terminals can attach simultaneously to watch output, send input, or query status. Late joiners get the scrollback buffer replayed so they don't miss anything.
- **Sets a session ID environment variable.** The child process (and everything it spawns) inherits `HEIMDALL_SESSION_ID=foo`. Scripts and hooks can read this to know which supervised session they belong to.
- **Cleans up on exit.** When the supervised process ends, Heimdall reaps it, removes the socket and PID file, and exits with the child's exit code.

Clients connect via Unix socket at `~/.local/share/heimdall/sessions/<id>.sock`.
The binary framing protocol is 5 bytes overhead per message — trivial to
Expand Down
Loading
Loading