diff --git a/CLAUDE.md b/CLAUDE.md index 0559b59..eb2c5ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co cargo build # debug build cargo build --release # optimized build cargo run # run the TUI -cargo run --release # run optimized cargo check # compile check without building cargo clippy # lint cargo fmt # format @@ -17,37 +16,46 @@ cargo test # run tests ## Architecture -Linkshell is a terminal multiplexer TUI built for AI coding agents. It manages up to 8 concurrent PTY sessions (Claude, Codex, shell, or custom commands) with real-time state inference, token tracking, cost estimation, and session-to-session pipes. +Linkshell is a terminal multiplexer TUI built for AI coding agents. It manages up to 8 concurrent PTY sessions (Claude, Codex, local agents, shells, or custom commands) with real-time state inference, token/cost tracking from JSONL logs, session-to-session pipes, multi-agent councils, an agent chat pane, and a resident orchestrator agent. It runs as a tmux-style client/server pair: the server owns sessions and survives detach (`alt-d`); `linkshell -r` reattaches. ### Module Overview | File | Responsibility | |------|---------------| -| `main.rs` | Async event loop, terminal init/cleanup, key routing, `--tcp`/`--council` flags | -| `app.rs` | App state, session lifecycle, all event handlers, council launch, command bar | +| `main.rs` | Client/server launch, async event loop, terminal init/cleanup, key routing, `--tcp`/`--council`/`--profile`/`--server`/`-r` flags, `doctor` subcommand | +| `reattach.rs` | Detach/reattach machinery: relay client, `SwappableWriter` backend redirection, reattach info file | +| `app.rs` | App state, session lifecycle, all event handlers, command bar + palette, profiles, chat pane state | | `session.rs` | Session model: PTY, vt100 screen, state, token stats, `BaseKind` identity resolution | -| `events.rs` | Event enum shared between tasks and the main loop | -| `ui.rs` | Ratatui rendering — output pane, session bar, status panel, overlays | +| `events.rs` | `AppEvent` enum shared between tasks and the main loop | +| `ui.rs` | Ratatui rendering — output pane(s), splits, session bar, status panel, chat pane, overlays | | `patterns.rs` | Pattern matching for session state inference (dispatched on `BaseKind`) and token/cost parsing | -| `pipe.rs` | Pipe definitions, extraction logic, trigger evaluation, Haiku summarizer | +| `pipe.rs` | Pipe definitions (`Pipe`, `ExtractMode`, `PipeTrigger`), extraction logic, trigger evaluation, Haiku summarizer | | `ipc.rs` | Unix socket + optional TCP listener, handshake, capability enforcement | | `protocol.rs` | Typed wire protocol: `Envelope`/`Message`, error codes, per-message capability requirements | -| `auth.rs` | Capability tiers (operator / worker / council) and token minting | +| `auth.rs` | Capability tiers (operator / worker / council / orchestrator) and token minting | | `council.rs` | council.toml parsing and the multi-agent routing engine (`CouncilRouter`) | -| `orchestrator/` | Resident orchestrator agent: `mod.rs` task/tools/prompts, `anthropic.rs` + `openai.rs` tool-use loops. CLI-class providers instead run as a session with `orchestrator_caps()` driving `linkshell-ctl` | +| `orchestrator/` | Resident orchestrator agent: `mod.rs` task/tools/prompts/memory, `skills.rs` on-demand markdown skills, `anthropic.rs` + `openai.rs` tool-use loops. CLI-class providers instead run as a session with `orchestrator_caps()` driving `linkshell-ctl` | +| `agent_llm.rs` | Chat-addressable local LLMs: any OpenAI-compatible endpoint under `[agents.*]` | | `claude_log.rs` | Watch `$CLAUDE_CONFIG_DIR/projects` JSONL for cumulative token/cost stats | | `codex_log.rs` | Watch `$CODEX_HOME/sessions` rollout JSONL for token/context stats | -| `config.rs` | linkshell.toml: commands, aliases, pricing, socket, keybindings | +| `opencode_log.rs` | Watch the OpenCode SQLite DB for token/cost stats | +| `ctx_probe.rs` | Probe local model backends (llama.cpp, LM Studio) for context window size | +| `notify.rs` | Desktop notifications (notify-send, OSC 9, bell) for WAITING/ERROR | +| `doctor.rs` | `linkshell doctor` — environment/config diagnostics | +| `config.rs` | linkshell.toml: commands, aliases, pricing, socket, agents, orchestrator, profiles, keybindings | | `keybindings.rs` | Configurable key chord parsing | | `bin/ctl.rs` | `linkshell-ctl` — CLI client speaking the typed protocol | ### Data Flow -Three background tasks communicate via `tokio::mpsc` to the main loop: +Background tasks communicate via `tokio::mpsc` to the main loop: 1. **Input reader** — keyboard/mouse → `Key`/`Mouse` events 2. **Tick generator** — 500ms → `Tick` event (timeout-based state transitions) 3. **PTY reader** (one per session) — raw bytes → `SessionBytes` + `SessionOutput`/`SessionCurrentLine` +4. **Log watchers** — Claude/Codex/OpenCode logs → token/cost events +5. **IPC listener** — `linkshell-ctl` / remote agents → state, input, pipe, chat messages +6. **Orchestrator task** (API class) — tool-use loop ↔ main loop via events The main loop calls `handle_event()` then re-renders with `ui::draw()`. @@ -63,278 +71,34 @@ This prevents escape sequences from corrupting state inference logic. `Starting → Ready ↔ Thinking/Running ↔ Waiting/Error → Dead` -States are inferred from output patterns in `patterns.rs`. A 2-second timeout with no output while `Running` or `Thinking` reverts to `Ready`. +States are inferred from output patterns in `patterns.rs` (dispatched on `BaseKind`), overridden by IPC `state` messages, and refined by JSONL log activity. A 2-second timeout with no output while `Running` or `Thinking` reverts to `Ready`. -### UI Layout - -Three vertical panes: -1. **Main output** — active session's vt100 screen (cell-by-cell color preservation) -2. **Session bar** — tabbed slots with colored state dots per session kind -3. **Status panel** — elapsed time, token counts, cost per session; `→ N` when a pipe is active - -Overlays: NewSession dialog, CommandBar (`:` prefix), Help (`?`). - -### Key Bindings (from README) - -| Key | Action | -|-----|--------| -| `Alt+N` | New session dialog | -| `Alt+1`–`8` | Switch to session | -| `Alt+Left/Right` | Previous/next session | -| `Alt+X` | Kill active session | -| `:` | Open command bar | -| `Alt+H` | Toggle help | -| `Ctrl+Q` | Quit | -| Mouse drag | Select text (auto-copies to clipboard) | - -### Command Bar Commands - -`new `, `kill `, `quit` - -`pipe 1 2`, `pipe 1 2 --extract=last-n=15`, `pipe 1 2 --extract=diff`, `pipe 1 2 --summarize=150`, `pipe 1 2 --on=waiting`, `pipe 1 2 --prefix="Review this:"`, `unpipe 1`, `unpipe 1 2` - -### Token/Cost Parsing - -`patterns.rs` extracts cost (`~$1.23`) and token counts (`12,345 input`, `3.4k out`) from Claude output lines. Falls back to cost estimation at $3/MTok input, $15/MTok output when only counts are available. - ---- - -## Pipe System - -Pipes are **edge-triggered on state change, not continuous**. When a source session hits a trigger state (e.g. READY), linkshell extracts a formatted snapshot of its output and forwards it to a destination session. You extract the artifact, not the process — this keeps token usage low. - -### Data Structures (`src/pipe.rs`) - -```rust -#[derive(Debug, Clone)] -pub enum ExtractMode { - LastBlock, // last ```...``` fenced code block - LastN(usize), // last N lines - Diff, // lines starting with + or - - Summarize(u32), // relay through Haiku first, max N tokens -} - -#[derive(Debug, Clone)] -pub enum PipeTrigger { - OnReady, // fires when source hits READY state - OnWaiting, // fires when source is blocked on user input - Manual, // only fires on explicit user command -} - -#[derive(Debug, Clone)] -pub struct Pipe { - pub source: usize, - pub dest: usize, - pub trigger: PipeTrigger, - pub extract: ExtractMode, - pub prefix: Option, - pub active: bool, -} -``` - -Add `pipes: Vec` to `App` in `src/app.rs`. - -### Trigger Logic - -Call `check_pipes` inside `handle_session_output` after state is updated. Collect triggered pipes, extract content, spawn `fire_pipe` tasks that send `AppEvent::PipeRelay { dest_id, message }` back through the event channel. - -### Extraction Modes - -| Mode | Token cost | When to use | -|------|-----------|-------------| -| `last-block` | Low | Implementer → reviewer | -| `last-n=N` | Low, bounded | Quick status relay | -| `diff` | Very low | Code review | -| `summarize=N` | Small API call + N output tokens | Noisy output, large context | - -`Summarize` relays through **Haiku** — not Sonnet. Fast, cheap, fractions of a cent per relay. Use model ID `claude-haiku-4-5-20251001`. - -### New AppEvent Variants (`src/events.rs`) - -```rust -pub enum AppEvent { - // ... existing variants - PipeRelay { dest_id: usize, message: String }, - IpcStateOverride { session_id: usize, state: SessionState }, - IpcTokenUpdate { session_id: usize, input: u64, output: u64 }, -} -``` - -`PipeRelay` is handled in `main.rs` by writing the message bytes to the dest session's PTY writer. - -### Status Panel - -Active pipes show as `→ N` in the source session's status row. When a pipe fires, briefly highlight the arrow (bold/flash for one tick). - -``` -1 🟠 READY → 2 last-block 1m 32s │ ~450 tok │ ~$0.02 -``` - -### Implementation Order - -1. Add `src/pipe.rs` with `Pipe`, `ExtractMode`, `PipeTrigger` -2. Add `pipes: Vec` to `App` -3. Add `PipeRelay` to `AppEvent` -4. Wire `check_pipes` into `handle_session_output` -5. Add `pipe` / `unpipe` to command bar parser in `execute_command` -6. Add `reqwest` to `Cargo.toml` for the summarizer call -7. Update status panel to show `→ N` for active pipes -8. Add `src/ipc.rs` for orchestrator integration (see below) - ---- - -## Orchestrator Integration - -### Option A — Observe Only (Zero Modification) - -Run an existing orchestrator in a shell session. Linkshell infers state from output patterns. No changes to the orchestrator. - -**Start here for council and foundry-x.** - -### Option B — IPC Socket (`src/ipc.rs`) - -Linkshell opens `/tmp/linkshell.sock` as a Unix domain socket. The orchestrator sends structured JSON messages to update session state, inject output lines, or report tokens. - -**Linkshell side:** - -```rust -pub async fn start_ipc_listener(tx: mpsc::Sender, session_id: usize) { - let path = "/tmp/linkshell.sock"; - let _ = std::fs::remove_file(path); - let listener = tokio::net::UnixListener::bind(path).unwrap(); - loop { - if let Ok((stream, _)) = listener.accept().await { - let tx = tx.clone(); - tokio::spawn(handle_ipc_connection(stream, tx, session_id)); - } - } -} -``` - -Messages: `{"type":"state","state":"THINKING","detail":"..."}`, `{"type":"tokens","input":4200,"output":890}`, `{"type":"output","line":"..."}`, `{"type":"state","state":"READY"}`. - -**Python shim (fire-and-forget, add to any orchestrator):** +### Pipes -```python -import socket, json +Pipes are **edge-triggered on state change, not continuous** (`src/pipe.rs`). When a source session hits a trigger state (`OnReady`, `OnWaiting`, or `Manual`), linkshell extracts a snapshot (`LastBlock`, `LastN(n)`, `Diff`, or `Summarize(n)` via Haiku, model `claude-haiku-4-5-20251001`) and forwards it to the destination's PTY through `AppEvent::PipeRelay`. Trigger checks run in `check_pipes` after state updates in output handling. Active pipes show as `→ N` in the status panel, bold for one tick when they fire. -def linkshell_status(msg: dict): - try: - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.connect("/tmp/linkshell.sock") - s.send(json.dumps(msg).encode() + b"\n") - s.close() - except Exception: - pass # linkshell not running, silently ignore -``` - ---- - -## Council Integration (`~/council`) - -Council runs 4 sequential phases: investigate (Claude + Codex in parallel) → stage (deterministic merge) → debate (turn-by-turn) → resolve. Each phase outputs markdown + JSON to a run directory. Agents are invoked as subprocesses. - -### Observe Mode (start here) - -```bash -# Session 1: council run -linkshell new shell council -# in that shell: -python3 -m council issue --repo ~/myproject --task "fix the foo bug" -``` - -Linkshell reads stdout, infers RUNNING/READY from patterns. You watch both investigate agents fire and the debate rounds scroll by. - -### Pipe Patterns for Council - -**Watch parallel investigation:** Pipe both agent logs into a shared reviewer session (requires splitting agent output into separate shell sessions — redirect each agent's log with `tail -f`). - -**Relay debate conclusion to resolver:** When the debate shell hits READY (last turn complete), pipe its last block to a session reading the resolve prompt. +### Capabilities -``` -pipe 2 4 --extract=last-block --prefix="Debate concluded. Now resolve:" -``` - -**Devil's advocate:** Pipe Claude's investigation conclusion to a Codex session to argue against it. - -``` -pipe 1 2 --summarize=100 --prefix="Argue against this design decision:" -``` - -### IPC Shim for Council - -Add `linkshell_status()` calls to `phases.py`: - -```python -# in run_investigate(): -linkshell_status({"type": "state", "state": "THINKING", "detail": "investigating"}) -# after both agents return: -linkshell_status({"type": "state", "state": "READY"}) -linkshell_status({"type": "tokens", "input": total_input, "output": total_output}) - -# in run_debate() per round: -linkshell_status({"type": "output", "line": f"Debate round {round_num}: {attacker} attacks"}) - -# after convergence check: -if converged: - linkshell_status({"type": "state", "state": "READY"}) -``` +Every IPC connection is scoped at handshake (`auth.rs`): **operator** (human, shell sessions — everything), **worker** (spawned AI sessions — report state/tokens, query, message, fire pipes), **council** (report own state only), **orchestrator** (operator-tier plus `chat_post`; kills still need human `/confirm-kill`). Sessions get `LINKSHELL_SESSION_ID`, `LINKSHELL_SOCK`, `LINKSHELL_TOKEN` at spawn. TCP requires a valid token. -This lets pipe triggers fire on phase boundaries, not just on process exit. +### Orchestrator ---- +Two provider classes (`[orchestrator]` in linkshell.toml): +- **API class** (`anthropic`, `openai`, `lmstudio`): in-process tool-use loop with tools for sessions, pipes, chat, `use_skill`, and `remember`. +- **CLI class** (`claude`, `codex`, `opencode`, `omp`): the CLI runs as a (by default hidden) session with orchestrator capabilities, driving linkshell via `linkshell-ctl`. -## Foundry-X Integration (`~/foundry-x-push`) +Skills are `*.md` files in `~/.config/linkshell/skills/` (name + description in prompt, body loaded on demand). Persistent memory lives in `~/.config/linkshell/memory.md` (`memory_file`), injected each turn, appended via the `remember` tool, truncated in-prompt at 8 KiB. See `docs/orchestrator-memory.md`. -Foundry-X is a FSM-driven code-generation pipeline: `PRD_OPEN → SPEC_READY → CODE_READY → TEST_READY → EXECUTED → VALIDATED → (human gate) → MERGED`. Agents are injected callables; artifacts are stored as SHA256 blobs in a content-addressed registry. State is persisted to SQLite. - -### Observe Mode (start here) - -```bash -linkshell new shell foundry -# in that shell: -python3 scripts/run_pipeline.py --prd prd.md --library ucc -``` - -State transitions, retry counts, and compilation errors stream to stdout. Linkshell shows RUNNING during code-gen and compilation, WAITING at the human approval gate. - -### Pipe Patterns for Foundry-X - -**Route compilation errors to a debug agent:** When the test session hits WAITING (compilation failed, waiting for retry), pipe the last N lines to a Claude session for analysis. - -``` -pipe 1 2 --on=waiting --extract=last-n=30 --prefix="Fix the compilation error:" -``` - -**Human approval workflow:** When the pipeline reaches VALIDATED (human gate), pipe the diff to a review session. - -``` -pipe 1 3 --extract=diff --prefix="Review this patch before approval:" -``` - -**Parallel model comparison:** Spin up two pipeline sessions with different `--model` flags, pipe both results to a comparator session. - -``` -pipe 1 5 --extract=last-block --prefix="Pipeline A output:" -pipe 2 5 --extract=last-block --prefix="Pipeline B output:" -``` - -### IPC Shim for Foundry-X - -Add `linkshell_status()` calls to `core/orchestrator.py` in `_drive()` at each state transition: - -```python -# after each FSM transition: -linkshell_status({"type": "state", "state": new_state.name}) -linkshell_status({"type": "output", "line": f"→ {prev_state.name} → {new_state.name} (attempt {run.attempt})"}) +### UI Layout -# on retry: -linkshell_status({"type": "output", "line": f"Retry {run.attempt}/{MAX_RETRIES}: {error_summary}"}) +Vertical panes: main output (optionally split into two session panes), session bar, status panel, and an optional chat pane (`alt-t`, dockable with `alt-g`). Overlays: NewSession dialog, command bar/palette (`alt-c`), pipes overlay, help (`alt-h`). -# at VALIDATED (human gate): -linkshell_status({"type": "state", "state": "WAITING", "detail": "awaiting human approval"}) -``` +Keybindings and command-bar commands are user-facing — keep the README's Keybindings and Command Bar sections as the source of truth and update them when defaults in `keybindings.rs` or the parser in `app.rs::execute_command` change. -This maps FSM states directly to linkshell session states, so pipe triggers fire at the right moments — especially `OnWaiting` at the approval gate. +## Documentation -The `--transport` flag in `scripts/run_pipeline.py` already abstracts the LLM provider. A `linkshell` transport could write prompts into a linkshell session and read responses back via the IPC socket — enabling human-in-the-loop editing mid-pipeline. +- `README.md` — user-facing feature docs; keep in sync with behavior changes +- `docs/config-reference.md` — full linkshell.toml reference +- `docs/recipes.md` — workflow recipes +- `docs/orchestrator-memory.md` — orchestrator memory design +- `CHANGELOG.md` — add entries under `## Unreleased` for user-visible changes diff --git a/README.md b/README.md index 1897016..6327a8b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ tmux doesn't know your Claude session is blocked waiting on you. It doesn't know ## Features - **Up to 8 sessions** — Claude, Codex, shell, or any custom command +- **Detach & reattach** — tmux-style client/server split; quit the client and sessions keep running (`alt-d` to detach, `linkshell` or `linkshell -r` to reattach) +- **Split panes** — view two sessions side by side (`alt-\` to split, `alt--` to rotate, `alt-o` to switch focus) +- **Startup profiles** — save a layout of sessions and pipes with `profile save `, relaunch it with `--profile ` - **Aliased identities** — env-prefixed commands (`CLAUDE_CONFIG_DIR=~/w claude`) and configured wrapper aliases get full Claude/Codex treatment, each with its own config home - **Local agents & LLMs** — opencode, oh-my-pi, pi, aider, and llama.cpp sessions get agent-style state inference; llama.cpp/Ollama/vLLM/LM Studio endpoints are chat-addressable via `[agents.*]` - **Agent chat pane** (`alt-t`) — talk to any session or local LLM by name, run commands with `/`, and orchestrate everything without leaving the pane @@ -84,6 +87,14 @@ linkshell --profile ucc-dev # launch a named session profile Then create your first session with `alt-n`. +Linkshell runs as a client/server pair, like tmux: the first `linkshell` +starts a background server that owns the sessions, and the foreground TUI is +a client attached to it. `alt-d` detaches — sessions keep running — and +running `linkshell` (or `linkshell -r`) again reattaches to the live server. + +If something looks wrong (missing logs, stale socket, nested multiplexer, +limited terminal colors), run `linkshell doctor` for a diagnostic report. + ## Aliased Claude / Codex sessions Sessions running Claude or Codex under a different config home are recognized @@ -213,6 +224,7 @@ name = "agent" # chat target: @agent ... # system = "extra instructions" # skills_dir = "~/.config/linkshell/skills" # *.md skill files; defaults to # # this path when the dir exists +# memory_file = "~/.config/linkshell/memory.md" # persistent notes (default) # hidden = true # CLI class: keep the agent out of the session bar # permission_mode = "accept-edits" # CLI class: start with safe auto-approval # # flags (claude: --permission-mode acceptEdits, @@ -249,6 +261,13 @@ into the prompt; the full text is loaded on demand — API-class orchestrators call a `use_skill` tool, CLI-class orchestrators get the file paths in their briefing and read them directly. +**Memory** persists across restarts. The orchestrator carries a small notes +file — `~/.config/linkshell/memory.md` by default, or `memory_file` — that is +injected into its prompt each turn and appended to via a `remember` tool +(project layout, user preferences, recurring commands; one sentence per note). +You curate the file by hand; it is scaffolded automatically on first start. +See [docs/orchestrator-memory.md](docs/orchestrator-memory.md) for details. + In chat, unaddressed messages default to the orchestrator when one is running. `:orchestrator start|stop|restart|reset|pause|resume|status|show|hide` manages it at runtime (also usable from chat as `/orchestrator …`). If the agent dies — its @@ -296,12 +315,18 @@ returns you to the live tail. | `alt-t` | Toggle agent chat pane | | `alt-h` | Toggle help | | `alt-x` | Kill active session | +| `alt-d` | Detach (sessions keep running) | +| `alt-\` | Toggle split pane | +| `alt--` | Rotate split layout | +| `alt-o` | Focus next pane | +| `alt-b` | Toggle broadcast input to all sessions | +| `alt-g` | Dock the chat pane | | `alt-1` … `alt-8` | Switch to session by number | | `alt-←` / `alt-→` | Cycle sessions | -| `ctrl-q` | Quit | +| `ctrl-q` | Quit (shuts down the server and all sessions; use `alt-d` to leave them running) | | `esc` | Dismiss overlay | -| `PageUp` / `PageDown` | Scroll output (20 lines) | -| `Shift-↑` / `Shift-↓` | Scroll output (3 lines) | +| `alt-shift-PageUp/PageDown` | Scroll output (page) | +| `alt-shift-↑` / `alt-shift-↓` | Scroll output (line) | All other input is passed through to the active session's PTY. @@ -323,6 +348,7 @@ council Launch a multi-agent council council status Show council round / completion state council stop Detach the council router (sessions keep running) restart [n] Respawn a session with the same command, name, and cwd +profile save Save the current sessions and pipes as a startup profile grant Set a session's IPC capabilities (operator|worker|council) config path Show the config file location config edit Open the config in $EDITOR (as a session) @@ -332,7 +358,8 @@ pipe [--extract=last-block|last-n=N|diff] [--summarize=N] [--on=read pipe fire [src] [dst] Manually fire a pipe with trigger=manual unpipe [dst] Remove pipe(s) from src pipes Inspect, pause, fire, or delete configured pipes -quit Exit linkshell +detach Detach the client; the server and sessions keep running +quit Exit linkshell (shuts down the server and all sessions) ``` ## New Session Dialog