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
53 changes: 32 additions & 21 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
authors = ["Christopher Sardegna <fleetcom@reagentx.net>"]
categories = ["command-line-interface", "command-line-utilities"]
description = "A fleet-view supervisor for arbitrary shell commands."
description = "A fleet-view supervisor for concurrent shell commands."
edition = "2024"
exclude = [".github", "docs", "tests", ".gitignore", ".gitattributes"]
keywords = ["cli", "tui", "pty", "supervisor", "process"]
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### Fleet view

See every tasks state and latest output from one dashboard.
Monitor every task's state and live output from one dashboard: test results, dev servers, and which agent is waiting on you.

![`fleetcom` fleet view](docs/img/home.png)

Expand All @@ -22,6 +22,10 @@ Press `Enter` to take control of a task, then `Ctrl-\` to return to the dashboar

![`fleetcom` attach](docs/img/attach.png)

### Resume agent sessions

Start `claude`, `codex`, or `grok` normally. When you rerun the task or reload a saved session, `fleetcom` resumes the same conversation automatically.

## Operational model

Running several long-lived commands is pesky once they span terminal panes or need to survive a disconnect. `fleetcom`:
Expand All @@ -35,7 +39,7 @@ Running several long-lived commands is pesky once they span terminal panes or ne

## Documentation

The [`docs/`](docs/README.md) directory covers configuration, on-disk state, session files, commands, and a complete first run.
The [`docs/`](docs/README.md) directory covers configuration, on-disk state, session files, resuming supported agent sessions, commands, and a complete first run.

## Installation

Expand Down
41 changes: 40 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
- [Commands](commands.md): every key and launch flag, including the routing mechanics
- [How it works](how-it-works.md): the PTY emulation, input routing, and activity grouping
- [Sessions](sessions.md): the task recipe format and where it lives
- [Agent session resume](../src/harness/agent-resume.md): when `fleetcom` can save resumable `claude`, `codex`, and `grok` commands
- [Agent session resume](agent-resume.md): how `fleetcom` captures and resumes supported `claude`, `codex`, and `grok` sessions
- [Storage paths](#storage-paths): the socket, the lock, and the session paths
- [First-run walkthrough](#first-run-walkthrough): a first run, start to finish
- [Security](#security): the trust boundary, on-disk state, and what is not protected
- [Operational constraints](#operational-constraints): process and protocol boundaries

## Installation from source
Expand Down Expand Up @@ -136,6 +137,44 @@ The attached status bar shows both: `[attached] api tests · cargo watch -x test

`w`, a name, and `Enter` save the fleet as a [session](sessions.md). `q` then disconnects while the daemon and both tasks continue running. A subsequent `fleetcom` invocation reconstructs the dashboard from the daemon's current task state. `Q` or `fleetcom --kill` stops the tasks (`TERM`, then `KILL` after a two-second grace period) and exits the daemon.

## Security

`fleetcom` runs entirely as your user. It neither raises nor drops privileges. Access control comes from filesystem permissions rather than authentication: the socket is mode `0600` inside a mode-`0700` directory, and the daemon performs no peer check. Any process running as your user can therefore connect, spawn commands, and read task output. That is the trust boundary.

### What lands on disk

| Path | Mode | Contents |
| -- | -- | -- |
| [runtime directory](#runtime-directory-socket--lock) | `0700` | the socket, lock, daemon log, and any capture roots resolved beneath it |
| `<runtime>/default.sock` | `0600` | the client↔daemon socket |
| `<runtime>/daemon.lock` | `0666 & ~umask` when new; otherwise unchanged | the owning daemon's PID, trustworthy only while its `flock` is held |
| `<runtime>/daemon.log` | `0666 & ~umask` when new; otherwise unchanged | stderr from the autostarted daemon |
| [session directory](#config-directory-sessions) | `0700` | saved recipes |
| `<sessions>/<name>.json` | `0600` | directories, commands, groups, display names |
| `<sessions>/recovery/` | `0700` | [automatic snapshots](sessions.md#recovery) |
| `<sessions>/recovery/<snapshot>.json` | `0600` | one automatic session recipe |
| `<capture-root>/<pid>-<nonce>/` | `0700` | [agent hook and notifier assets plus per-run capture payloads](agent-resume.md#capture-state-and-isolation) |

Saves are atomic: `fleetcom` writes a mode-`0600` temporary file in the destination directory, syncs it, then renames it over the target. This does not expose a partial or world-readable recipe. New session and recovery directories use mode `0700`; each save also removes group and other permissions from the destination directory.

### The runtime directory must be trustworthy

`fleetcom` validates the runtime directory before trusting its contents. The path must be a real directory owned by the current user; symlinks and directories owned by another user are rejected. Group or other write access is fatal because another user could already have planted entries. Any remaining group or other permissions are removed in place.

### What is not protected

Recipes persist full command lines, which can embed secrets. A token passed as an argument is written to its session file and to every recovery snapshot that captures the task.

`fleetcom` does not persist the client environment. Each client sends its environment and working directory during the connection handshake, and the daemon retains that launch context in memory. Session and recovery files store only directories, commands, group assignments, and display names.

### Captured IDs cross a shell boundary

Agent resume writes a captured conversation ID into a command run through `$SHELL -c`, so validation is a security boundary. Accepted IDs contain only lowercase hexadecimal in the `8-4-4-4-12` UUID shape. Hook payloads, terminal scrapes, filesystem correlation, and the command builder all apply that check. Instrumentation applies only to a bare program word or its canonical resume form, never arbitrary shell text. [Agent session resume](agent-resume.md#validation-boundary) documents both boundaries.

### Copied text leaves through the terminal

When `fleetcom` copies a selection or forwards an attached task's clipboard store, it sends the text to the host terminal as an OSC 52 escape sequence. The sequence also crosses intermediaries such as SSH connections and terminal multiplexers.

## Operational constraints

### The fleet dies with the daemon
Expand Down
17 changes: 14 additions & 3 deletions src/harness/agent-resume.md → docs/agent-resume.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# Agent session resume

Session files preserve launch commands, not application state. That boundary is problematic for agent CLIs because relaunching a bare `claude`, `codex`, or `grok` command starts another conversation.
Session files preserve launch commands, not process state. Relaunching a bare `claude`, `codex`, or `grok` command ordinarily starts another conversation. For accepted commands, `fleetcom` captures a validated conversation ID when available and builds a canonical resume command when saving a session or rerunning a finished task (`r`).

To preserve that conversation, `fleetcom` captures a validated ID and builds the resume command used by session save or rerun (`r`). Instrumentation changes only the string executed through `$SHELL -c`; direct spawns and session loads still display the requested command. Rerun displays the generated resume command because that command becomes the task's new launch recipe.
## Workflow

Start a supported agent without flags:

1. Press `n` and run `claude`, `codex`, or `grok`. The task appears in the dashboard under the command you typed. Instrumentation changes only the string executed through `$SHELL -c`, so a direct spawn still displays the requested command.
2. Work in it. `Enter` attaches; `Ctrl-\` returns to the dashboard. Depending on the agent, `fleetcom` pins an ID at launch and may update it from a hook or notifier while the task runs or from terminal output after it exits.
3. Press `w`, enter a session name, and press `Enter`. If the earlier sources produced no ID, the save also checks the agent's on-disk session store. A captured bare command becomes its canonical resume form, such as `claude --resume '<uuid>'`.
4. Run `fleetcom <session>`, or press `o` in the dashboard, to start new processes from the saved commands. A stored resume command reopens its captured conversation.

On a finished agent task, `r` uses the captured launch, hook, notifier, or exit ID without performing save-time filesystem correlation. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string.

Capture is best-effort and narrow by design. A command carrying a prompt, extra flags, or shell syntax stays opaque and saves verbatim. An accepted command with no available ID also saves unchanged. In both cases, loading the recipe reruns the original command.

## Accepted command boundary

Expand Down Expand Up @@ -88,7 +99,7 @@ Every captured value eventually enters a shell command, which makes validation t

## Extending capture

Each tool implements the `Harness` trait in [`mod.rs`](mod.rs). The methods keep detection, evidence collection, and command construction separate:
Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harness/mod.rs). The methods keep detection, evidence collection, and command construction separate:

- `detect` classifies the accepted command shapes.
- `instrument` returns spawn-time arguments, environment entries, and an optional pinned ID.
Expand Down
2 changes: 1 addition & 1 deletion docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ Recovery files carry the same caveat as saved recipes: they persist full command
- Load in-app: `o`, pick from the list, `Enter`.
- Load at launch: `fleetcom <name>`.

Loading always spawns new processes from the stored commands. Existing tasks remain daemon state and never enter the session file. [Agent session resume](../src/harness/agent-resume.md) documents when supported agent commands can preserve their conversations across that relaunch.
Loading always spawns new processes from the stored commands. Existing tasks remain daemon state and never enter the session file. [Agent session resume](agent-resume.md) documents when supported agent commands can preserve their conversations across that relaunch.
47 changes: 43 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ const _: () = assert!(
/// How long an ephemeral notice remains visible.
const NOTICE_TTL: Duration = Duration::from_secs(5);

/// Minimum interval between non-forced repaints outside attached mode.
const PAINT_MIN: Duration = Duration::from_millis(33);

/// Maximum time the run loop blocks before checking for termination.
const WAIT_MAX: Duration = Duration::from_millis(100);

/// Return whether the current pass may paint.
fn paint_due(attached: bool, forced: bool, since_paint: Duration) -> bool {
attached || forced || since_paint >= PAINT_MIN
}

/// Return the next repaint or termination-check timeout.
fn wait_for_paint(due: bool, since_paint: Duration) -> Duration {
if due {
WAIT_MAX
} else {
PAINT_MIN.saturating_sub(since_paint).min(WAIT_MAX)
}
}

/// Priority of an ephemeral notice. Active warnings take precedence over info.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NoticeLevel {
Expand Down Expand Up @@ -179,6 +199,10 @@ pub struct App {
/// Bytes of the last painted frame; the renderer skips the write when the
/// next frame is identical.
pub last_frame: Vec<u8>,
/// Time of the last frame write, or `None` until the first write.
last_paint: Option<Instant>,
/// Whether the next pass bypasses `PAINT_MIN`.
force_paint: bool,
/// Directory `fleetcom` was launched from: base for relative `@` paths and
/// the "default" section that sorts first in "by dir" mode.
pub invocation_dir: PathBuf,
Expand Down Expand Up @@ -375,6 +399,8 @@ impl App {
rows,
cols,
last_frame: Vec::new(),
last_paint: None,
force_paint: false,
invocation_dir,
invocation_label,
dir_input: EditBuffer::default(),
Expand Down Expand Up @@ -763,18 +789,31 @@ impl App {
self.selection = None;
}

// Emit accepted clipboard stores before painting the next frame.
// Flush clipboard output and synchronize terminal input modes.
self.flush_clipboard(out)?;
self.sync_input_modes(out)?;
ui::render(out, self)?;

// Wake for input or core events; the timeout observes termination.
let _ = self.wait_rx.recv_timeout(Duration::from_millis(100));
let now = Instant::now();
// Treat an absent prior paint as one full interval elapsed.
let since_paint = self.last_paint.map_or(PAINT_MIN, |t| now.duration_since(t));
let due = paint_due(self.mode == Mode::Attached, self.force_paint, since_paint);
if due {
// Start a new interval only when the frame is written.
if ui::render(out, self)? {
self.last_paint = Some(now);
}
self.force_paint = false;
}

// Wait for input, a core event, or the next repaint deadline.
let _ = self.wait_rx.recv_timeout(wait_for_paint(due, since_paint));
while self.wait_rx.try_recv().is_ok() {} // coalesce wake tokens

// Handle every buffered key/resize in one pass: coalesces a paste and
// shaves the last keystroke's echo (no render between chars).
while let Ok(ev) = self.input_rx.try_recv() {
// Terminal events make the next pass bypass `PAINT_MIN`.
self.force_paint = true;
match ev {
// Accept Repeat too, so a held key still forwards when attached.
CtEvent::Key(k)
Expand Down
Loading