Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Running several long-lived commands is pesky once they span terminal panes or ne
- Saves and reloads task recipes: directories, commands, group assignments, and display names.
- Reruns a completed task in place, keeping its identity, group, and name.
- Preserves `claude`, `codex`, and `grok` conversations, so saved or rerun tasks resume instead of starting fresh.
- Automatically snapshots the current task set for recovery.

## Documentation

Expand Down
8 changes: 7 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
| `r` | Rerun a finished task; supported agent tasks use the captured resume command |
| `X` | Kill a running task (`TERM`, then `KILL` after 2 s), or remove a finished one |
| `w` | Save the current tasks as a session |
| `o` | Load a saved session |
| `o` | Load a saved session or a recovery snapshot (opens the [session picker](#the-o-session-picker)) |
| `q` (or `Ctrl-C`) | Disconnect; leave the daemon and tasks running |
| `Q` | Quit; kill the tasks and stop the daemon |

Expand Down Expand Up @@ -130,6 +130,12 @@ Typing filters the rows; `Backspace` deletes one character and the matches re-fi

The daemon normalizes every group name received from the picker or a [session](sessions.md) file. It removes control characters, trims surrounding whitespace, and caps the result at 64 characters. An empty result or the exact name `Unassigned` means no group, preventing a user-defined name from colliding with the reserved section. Comparison remains case-sensitive, so `unassigned` is a valid group name.

## The `o` session picker

`o` opens a bottom panel listing the saved [sessions](sessions.md): `↑`/`↓` move the highlight, `Enter` loads, `Esc` cancels. While [recovery snapshots](sessions.md#recovery) exist, the hint adds `tab recovery (N)` and `Tab` (or `Shift-Tab`) flips the panel to them; `Tab` again returns to the saved list. Each list keeps its own highlight. With no snapshots, `Tab` does nothing and the hint omits it.

A recovery row reads `<age> ago · <tasks> task(s) · <label>`: the file's age, its command count, and its stored label (normally `autosaved <timestamp>`). `Enter` loads the highlighted snapshot; the status line confirms the load and suggests saving it. Press `w` to save the recovered fleet as a named session.

## Peek

A centered box over the dashboard showing the selected task's live screen (the last screenful). `↑`/`↓` (or `k`/`j`) switch which task you're peeking at; `Enter` attaches to it; `r` reruns it if it has finished; `Space`, `Esc`, or `q` closes.
Expand Down
15 changes: 15 additions & 0 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ A bare agent command does not identify its conversation, so saving it verbatim w
}
```

## Recovery

`fleetcom` automatically snapshots the current task set under `recovery/` inside the session directory. Each daemon (or `--foreground` core) writes to a file named for its start time and process ID. After each write, the new file and files whose process IDs are still live are protected; among the remaining files, the nine newest names survive. A shared recovery directory can therefore contain more than ten snapshots while multiple writers are live.

A snapshot pass runs two seconds after the last command that can change a saved recipe, coalescing a burst of commands. A pass writes a nonempty recipe when its content or destination changed, or when the expected snapshot file is missing. Every 60 seconds, `fleetcom` also checks for stored-command changes such as a newly captured agent resume ID.

- An empty fleet does not write a snapshot, so removing every task does not replace the previous snapshot with an empty recipe.
- Quitting, disconnecting, and `fleetcom --kill` leave snapshots in place.

A snapshot uses the session format above, with an `autosaved <timestamp>` UTC label in its `name` field. Loading one spawns its commands like a named session and suggests saving the recovered fleet under a permanent name.

In the dashboard, `o` opens the [session picker](commands.md#the-o-session-picker) on the saved list; while snapshots exist, `Tab` flips it to the recovery list.

Recovery files carry the same caveat as saved recipes: they persist full command lines, which can embed secrets. New recovery directories use mode 0700, and snapshot files use mode 0600.

## Saving and loading

- Save: `w` in the dashboard, type a name, `Enter`. Writes the session name plus each task's directory, command, and optional group and name to `<name>.json`.
Expand Down
81 changes: 65 additions & 16 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use crate::{
editbuf::EditBuffer,
path,
protocol::{
Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, ScreenView, ScrollAction,
TaskView,
Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, ScreenView,
ScrollAction, TaskView,
},
transport::{ExitIntent, SocketTransport, ThreadTransport, Transport},
ui,
Expand Down Expand Up @@ -94,6 +94,13 @@ impl GroupMode {
}
}

/// Active page in the session picker.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SessionPage {
Saved,
Recovery,
}

/// What Enter does with a picker row.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DirKind {
Expand Down Expand Up @@ -179,6 +186,12 @@ pub struct App {
// Load-session picker state.
pub session_names: Vec<String>,
pub session_sel: usize,
/// Recovery snapshots from the latest `Sessions` event, newest first.
pub session_recovery: Vec<RecoveryEntry>,
/// The displayed session-picker list; `o` resets it to `Saved`.
pub session_page: SessionPage,
/// Selection in the recovery list, clamped independently of `session_sel`.
pub recovery_sel: usize,
/// Transient one-line notice (save/load result), dismissed on the next key.
pub status: Option<String>,
/// Parsed terminal events from the stdin reader thread. crossterm owns the
Expand Down Expand Up @@ -354,6 +367,9 @@ impl App {
rename_target: None,
session_names: Vec::new(),
session_sel: 0,
session_recovery: Vec::new(),
session_page: SessionPage::Saved,
recovery_sel: 0,
status: None,
input_rx,
input_tx: Some(input_tx),
Expand Down Expand Up @@ -601,11 +617,15 @@ impl App {
self.focused_screen = Some(s);
}
Event::Status(s) => self.status = Some(s),
Event::Sessions(names) => {
// A shorter list can land while the picker is open; clamp
// the selection before it can index past the end.
Event::Sessions { names, recovery } => {
// Clamp both page selections to the refreshed lists.
self.session_sel = self.session_sel.min(names.len().saturating_sub(1));
self.recovery_sel = self.recovery_sel.min(recovery.len().saturating_sub(1));
if recovery.is_empty() {
self.session_page = SessionPage::Saved;
}
self.session_names = names;
self.session_recovery = recovery;
}
}
}
Expand Down Expand Up @@ -977,6 +997,9 @@ impl App {
self.transport.send(Command::ListSessions);
self.session_names.clear();
self.session_sel = 0;
self.session_recovery.clear();
self.session_page = SessionPage::Saved;
self.recovery_sel = 0;
self.mode = Mode::LoadSession;
}
// Restart only finished tasks.
Expand Down Expand Up @@ -1024,19 +1047,45 @@ impl App {
}

fn on_key_loadsession(&mut self, k: KeyEvent) {
match k.code {
KeyCode::Esc => self.mode = Mode::Dashboard,
KeyCode::Up => self.session_sel = self.session_sel.saturating_sub(1),
KeyCode::Down => {
self.session_sel = step_down(self.session_sel, self.session_names.len())
if matches!(k.code, KeyCode::Tab | KeyCode::BackTab) {
if !self.session_recovery.is_empty() {
self.session_page = match self.session_page {
SessionPage::Saved => SessionPage::Recovery,
SessionPage::Recovery => SessionPage::Saved,
};
}
KeyCode::Enter => {
if let Some(name) = self.session_names.get(self.session_sel).cloned() {
self.load_session(&name);
return;
}
match self.session_page {
SessionPage::Saved => match k.code {
KeyCode::Esc => self.mode = Mode::Dashboard,
KeyCode::Up => self.session_sel = self.session_sel.saturating_sub(1),
KeyCode::Down => {
self.session_sel = step_down(self.session_sel, self.session_names.len())
}
self.mode = Mode::Dashboard;
}
_ => {}
KeyCode::Enter => {
if let Some(name) = self.session_names.get(self.session_sel).cloned() {
self.load_session(&name);
}
self.mode = Mode::Dashboard;
}
_ => {}
},
SessionPage::Recovery => match k.code {
KeyCode::Esc => self.mode = Mode::Dashboard,
KeyCode::Up => self.recovery_sel = self.recovery_sel.saturating_sub(1),
KeyCode::Down => {
self.recovery_sel = step_down(self.recovery_sel, self.session_recovery.len())
}
KeyCode::Enter => {
if let Some(e) = self.session_recovery.get(self.recovery_sel) {
let stem = e.stem.clone();
self.transport.send(Command::LoadRecovery { stem });
}
self.mode = Mode::Dashboard;
}
_ => {}
},
}
}

Expand Down
Loading