Skip to content

Commit a9f7d41

Browse files
authored
feat(sessions): list and resume past sessions (#51)
1 parent 1b4db9a commit a9f7d41

13 files changed

Lines changed: 1415 additions & 125 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.105"
3+
version = "0.1.106"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,15 @@ remain resumable and are copied to the global location on resume. When reported
9999
context use reaches 80% of the model's window, Kit automatically checkpoints older
100100
history while preserving bootstrap instructions and a tool-safe recent tail; the
101101
replacement is persisted for resume.
102-
The session id is shown in the header. Resume it with:
102+
The session id is shown in the header. List workspace sessions or resume one with:
103103

104104
```sh
105+
cargo run -- sessions --root /path/to/project
105106
cargo run -- tui --root /path/to/project --resume <session-id>
106107
```
107108

109+
`kit sessions` prints the shared newest-first session catalog with ID, updated time, title, and preview.
110+
108111
A per-session filesystem lock prevents two live Kit processes from mutating the
109112
same transcript. If a crashed process left its lock file behind, add `--force`;
110113
Kit will reclaim it only when no live process still holds the OS lock.
@@ -583,6 +586,7 @@ child process with `KIT_RUNTIME_EVENTS=1`; other ACP hosts never see them.
583586
| --- | --- |
584587
| `` | send |
585588
| `/new` | start a fresh persisted session (`/new prompt` sends its first prompt) |
589+
| `/sessions` | choose a workspace session and resume it |
586590
| `/compact` | compact context now (`/compact prompt` starts the next turn with `prompt`) |
587591
| `/effort` | choose `default`, `low`, `medium`, or `high`; Tab toggles saving the default |
588592
| `/model` | open the searchable provider-grouped model picker |
@@ -607,7 +611,7 @@ no credentials — the client exits with that agent's own last diagnostic rather
607611
than waiting on a handshake that will never finish.
608612

609613
`/new` clears the visible transcript but leaves the prior persisted session
610-
intact and resumable. `/model` switches the main agent and compactor in the same
614+
intact and resumable. `/sessions` opens the newest-first workspace catalog; Enter resumes the selected session through the same path as `/resume <session-id>`. `/model` switches the main agent and compactor in the same
611615
live ACP session at the next safe turn boundary; it does not rewrite transcript
612616
history or restart the child process. The picker groups models by provider and
613617
ranks fuzzy matches as you type. Press `tab` before confirming to also replace the `provider`

docs/user/tui-and-sessions.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ Start an interactive session at a project root with the installed binary:
1010
kit tui --root /path/to/project
1111
```
1212

13-
Resume the ID shown in the header:
13+
List sessions for the workspace, then resume the ID shown in the header or catalog:
1414

1515
```sh
16+
kit sessions --root /path/to/project
1617
kit tui --root /path/to/project --resume <session-id>
1718
```
1819

20+
The catalog requires an existing directory and is workspace-filtered and newest-first. It reports each durable ID and updated time. The title comes from the earliest retained useful user text so compaction does not rename a session; the preview describes the current retained history. Display metadata removes terminal controls and Unicode default-ignorable formatting characters.
21+
1922
A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` uses the same durable sessions: it prints `session_id: <id>` after its answer, and that ID can be continued by either `kit prompt --resume <session-id>` or `kit tui --resume <session-id>`.
2023

2124
## TUI keys, prompt editing, and navigation
@@ -72,12 +75,13 @@ At an idle, non-empty editor, `Ctrl+C` clears the prompt instead of unexpectedly
7275

7376
## Manage sessions and compact from the TUI
7477

75-
The TUI handles `/new`, `/resume`, `/close`, `/model`, and `/effort` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally:
78+
The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, and `/effort` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally:
7679

7780
```text
7881
/new
7982
/new Start by reviewing the tests
8083
/resume <session-id>
84+
/sessions
8185
/close
8286
/compact
8387
/compact Continue with the migration
@@ -86,7 +90,7 @@ The TUI handles `/new`, `/resume`, `/close`, `/model`, and `/effort` as exact lo
8690
/effort high
8791
```
8892

89-
These local commands are available only while the session is idle. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume <session-id>` closes the current session, resumes the requested durable session, and replays its transcript. `/close` closes the current session and exits the TUI.
93+
These local commands are available only while the session is idle. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume <session-id>` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace; Up and Down move, Enter uses the existing resume flow, and Esc closes the dialog. `/close` closes the current session and exits the TUI.
9094

9195
`/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. A new or resumed process starts from the resolved CLI/TOML default unless the selection was saved.
9296

@@ -97,24 +101,26 @@ The ACP server advertises `compact` for every new session. The TUI submits `/com
97101
Kit stores durable JSONL transcripts, locks, and session-associated fatal error logs in:
98102

99103
```text
100-
~/.kit/sessions/<session-id>.jsonl
101-
~/.kit/sessions/<session-id>.lock
104+
~/.kit/sessions/w-<workspace-hash>/<session-id>.jsonl
105+
~/.kit/sessions/w-<workspace-hash>/<session-id>.lock
102106
~/.kit/errors/<session-id>/<event-id>.json
103107
```
104108

109+
The workspace hash is the BLAKE3 digest of the canonical workspace-root path. It keeps identical session IDs in different workspaces in separate storage directories.
110+
105111
Fatal error records use their own versioned JSON schema and are not transcript content. Schema v2 adds optional structured transport diagnostics; schema v1 records remain readable. Transport diagnostics contain only bounded, allowlisted request/stream stage, retry, attempt, the provider's strictly validated `x-request-id` value, reqwest classification, and typed Hyper, HTTP/2, and I/O fields. Unknown or truncated source chains are identified without storing source text. Kit never stores raw error display/debug text, arbitrary headers, prompts, tool arguments, response bodies, credentials, URLs, or peer-controlled HTTP/2 debug text in these records. Files are written atomically with owner-only permissions on Unix, and Kit retains the newest 50 records per session. Cancellation is not a fatal error and does not create a record. When persistence succeeds, local prompt and ACP terminal errors include the log path; A2A records stay server-local.
106112

107113
`HOME is unset; cannot locate durable sessions` means Kit cannot determine this directory. Set `HOME` to the intended home directory before starting Kit.
108114

109115
Transcript records are versioned and have consecutive generations. Transcript schema v3 records the canonical workspace root so ACP discovery and resume cannot expose a session to another project; schema v1 and v2 records remain readable and gain that binding when they are next resumed. Normal items are appended and synced to disk before they are accepted into the in-memory conversation. Operations such as compaction append a replacement record; older records remain in the JSONL file, but readers treat the latest valid replacement as the canonical transcript.
110116

111-
Older sessions under `<root>/.kit/sessions` remain readable. The first resume validates and copies a legacy transcript into `~/.kit/sessions`; a live legacy lock produces `legacy session is actively locked by another Kit instance ...; stop it before resuming with this Kit version`. When both locations contain the ID, the global transcript is preferred.
117+
Older sessions stored directly under `~/.kit/sessions` or under `<root>/.kit/sessions` remain readable. On resume, Kit compares workspace-hashed, workspace-bound global, and project-local candidates and selects the history that descends from the others; equivalent histories prefer the workspace-hashed copy, while divergent histories fail instead of choosing silently. An old global transcript without workspace metadata is a fallback only for an explicit resume when no workspace-hashed or project-local candidate has that ID. The first successful resume copies the authoritative history into the workspace-hashed directory and leaves redirects in applicable legacy files. A live legacy lock produces `legacy session is actively locked by another Kit instance ...; stop it before resuming with this Kit version`.
112118

113119
### ACP session loading
114120

115-
ACP v1 clients restore a closed durable session with `session/load`. The v1 endpoint advertises only the protocol's top-level `loadSession` capability; it does not advertise `session/resume` or `session/list`. ACP v2 clients use `session/list` and `session/resume` instead. Both versions use the exact durable session ID and return the same model and reasoning configuration options as `session/new`.
121+
ACP v1 clients restore a closed durable session with `session/load` and discover sessions with the optional `session/list` capability. ACP v2 clients use `session/list` and `session/resume`. Both list variants use the same newest-first catalog, optional exact-cwd filter, and opaque `offset:<n>` pagination cursors, and return titles and RFC 3339 updated times. Both versions use the exact durable session ID and return the same model and reasoning configuration options as `session/new`.
116122

117-
Session discovery and restoration are isolated to the server's canonical workspace root. A requested workspace must match that root, and additional directories are not accepted. Legacy transcripts under a project-local `.kit/sessions` directory follow the same migration and root checks as CLI resume; they do not make a same-named session visible from another workspace.
123+
Session discovery and restoration are isolated to the server's canonical workspace root. A requested workspace must match that root, and additional directories are not accepted. Legacy transcripts under a project-local `.kit/sessions` directory follow the same migration and root checks as CLI resume; they do not make a same-named session visible from another workspace. Old global transcripts without workspace metadata are excluded from discovery in every workspace, but an explicit resume by ID remains supported and binds the transcript to that workspace. An individually malformed or concurrently incomplete transcript is omitted from catalog results without preventing valid sessions from being listed; explicit resume remains strict and reports its error.
118124

119125
An arbitrary ACP load or resume never applies the server process's configured `--force` setting. The one exception is the initial resume requested by `kit tui --resume <id> --force`: only that matching configured session may use the explicit stale-lock override. If another live Kit instance owns the session lock, restoration fails instead of taking over the session. A missing or invalid ID also fails normally. After the session closes and releases its lock, an ACP client can restore it again.
120126

src/main.rs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,12 @@ enum Command {
405405
#[command(flatten)]
406406
credentials: CredentialArgs,
407407
},
408+
/// List durable sessions for a workspace, newest first.
409+
Sessions {
410+
/// Working directory and project context (defaults to config or `.`).
411+
#[arg(long)]
412+
root: Option<PathBuf>,
413+
},
408414
/// Serve ACP on stdio with A2A, remote ACP, or both over HTTP.
409415
Serve {
410416
/// Working directory and project context (defaults to config or `.`).
@@ -534,6 +540,21 @@ fn initial_config(home: &Path) -> String {
534540
)
535541
}
536542

543+
fn format_sessions(entries: &[kit::session::CatalogEntry]) -> String {
544+
let mut output = String::from("UPDATED\tID\tTITLE\tPREVIEW\n");
545+
for entry in entries {
546+
output.push_str(&entry.updated_at_rfc3339());
547+
output.push('\t');
548+
output.push_str(&entry.id);
549+
output.push('\t');
550+
output.push_str(entry.title.as_deref().unwrap_or("-"));
551+
output.push('\t');
552+
output.push_str(entry.preview.as_deref().unwrap_or("-"));
553+
output.push('\n');
554+
}
555+
output
556+
}
557+
537558
fn write_if_missing(path: &Path, contents: &[u8]) -> io::Result<()> {
538559
match fs::OpenOptions::new()
539560
.write(true)
@@ -750,6 +771,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
750771
return Ok(());
751772
}
752773
let config = Config::load_default()?;
774+
if let Command::Sessions { root } = &cli.command {
775+
let root = config.root(root.clone());
776+
print!("{}", format_sessions(&kit::session::catalog(&root)?));
777+
return Ok(());
778+
}
753779
let openrouter_api_key =
754780
resolve_openrouter_api_key(cli.openrouter.openrouter_api_key.clone(), |name| {
755781
env::var(name).ok()
@@ -773,6 +799,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
773799
match cli.command {
774800
Command::Init => unreachable!("init returns before loading runtime config"),
775801
Command::Auth { .. } => unreachable!("auth commands return before loading runtime config"),
802+
Command::Sessions { .. } => unreachable!("sessions returns before starting a runtime"),
776803
Command::Serve {
777804
root,
778805
model,
@@ -1014,9 +1041,10 @@ mod tests {
10141041
use kit::tools::CredentialStorage;
10151042

10161043
use super::{
1017-
AuthAction, AuthProvider, Cli, Config, CredentialArgs, CredentialStoreKind, McpArgs,
1018-
OTEL_CAPTURE_MESSAGE_CONTENT_ENV, ReasoningEffortArg, init_config,
1019-
resolve_openrouter_api_key, supervise_serve_with_trigger, validate_auth_storage,
1044+
AuthAction, AuthProvider, Cli, Command, Config, CredentialArgs, CredentialStoreKind,
1045+
McpArgs, OTEL_CAPTURE_MESSAGE_CONTENT_ENV, ReasoningEffortArg, format_sessions,
1046+
init_config, resolve_openrouter_api_key, supervise_serve_with_trigger,
1047+
validate_auth_storage,
10201048
};
10211049

10221050
#[test]
@@ -1380,6 +1408,27 @@ future_option = true
13801408
assert!(Config::load(&path).is_err());
13811409
}
13821410

1411+
#[test]
1412+
fn sessions_command_accepts_a_workspace_root_and_formats_catalog_rows() {
1413+
let cli = Cli::try_parse_from(["kit", "sessions", "--root", "/tmp/project"]).unwrap();
1414+
assert!(matches!(
1415+
cli.command,
1416+
Command::Sessions { root: Some(root) }
1417+
if root.as_path() == std::path::Path::new("/tmp/project")
1418+
));
1419+
1420+
let output = format_sessions(&[kit::session::CatalogEntry {
1421+
id: "session-1".into(),
1422+
title: Some("Fix tests".into()),
1423+
preview: Some("Fix tests in the catalog".into()),
1424+
updated_at: 0,
1425+
}]);
1426+
assert_eq!(
1427+
output,
1428+
"UPDATED\tID\tTITLE\tPREVIEW\n1970-01-01T00:00:00.000Z\tsession-1\tFix tests\tFix tests in the catalog\n"
1429+
);
1430+
}
1431+
13831432
#[test]
13841433
fn otel_endpoint_is_a_global_command_line_option() {
13851434
let cli = Cli::try_parse_from([

0 commit comments

Comments
 (0)