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
16 changes: 13 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ own mapping as an extension in its own module.
│ │ │ ├── SessionModels.swift # SessionProvider, SessionSummary, transcripts, delete plans
│ │ │ ├── SessionParsing.swift # Provider-agnostic, total parsing primitives
│ │ │ ├── SessionProviderAdapter.swift # The protocol + the registry
│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse}SessionAdapter.swift
│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse,Devin,MistralVibe}SessionAdapter.swift
│ │ │ ├── CodexTitleHydrator.swift # Titles from Codex's side index
│ │ │ ├── AntigravityConversationIndex.swift
│ │ │ ├── LiveSQLiteReader.swift # Read a store another process holds open
Expand Down Expand Up @@ -161,15 +161,25 @@ Claude Cowork because another running app owns those stores, and for Grok Bot
because the conversation itself lives on xAI's servers and this directory is
only what the client replicated. Muse Code is read-only too: the CLI's own
`session-index.db` points at every log, and a running `muse` keeps locks and
sockets inside the session directory. Removing from underneath a live SQLite
handle is how a store gets corrupted rather than emptied. Do not "fix" that.
sockets inside the session directory. Devin keeps every session as rows in one
database the CLI holds open, so a delete would be SQL against another
process's live store — that is `devin rm`'s job. Mistral Vibe caches its
listing, leases running sessions, nests sub-agent sessions inside the parent's
directory, and sweeps worktrees no listed session resumes into. Removing from
underneath a live SQLite handle is how a store gets corrupted rather than
emptied. Do not "fix" that.

## 7. Adding a harness

1. `SessionProvider` case + `displayName` (via `HarnessCatalog`) +
`defaultHarness` + an honest `supportsDeletion`.
2. An adapter. `roots(homeDirectory:)` is both the discovery scope and the
deleter's containment fence — keep it exactly as narrow as the store is.
If a session is not exactly one file — a sibling file changes on its own
(Mistral Vibe's `meta.json`), or many sessions share one store (Devin's
database, addressed as `<store>/<session id>`) — override
`changeFingerprint(fileURL:)` so the index re-reads exactly the sessions
that moved.
3. Register in `SessionProviderRegistry.standard`.
4. Tests over a synthetic temp tree: metadata, transcript, deletion plan, and
the malformed-input case.
Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`DevinSessionAdapter`** — Cognition's Devin is a listed, readable,
searchable provider. The `devin` CLI and the Devin desktop app (whose
"Devin Local" agent drives the CLI over ACP) keep every session as rows in
one WAL-mode database, `~/.local/share/devin/cli/sessions.db`. Discovery
hands out one locator per visible session, `…/sessions.db/<session id>`,
which names a row rather than a file. Metadata comes from the `sessions` row
(title, working directory, unix-second timestamps) and a bounded window at
each end of the main chain (first typed prompt, last reply, the model that
generated it). The transcript is the one chain from
`sessions.main_chain_id` back to its root — not compaction's abandoned
prefixes, retried siblings, or sub-agent trees — with system blocks,
injected user turns, and reasoning dropped. Sessions Devin marks
`hidden = 1` (its own helper agents' runs) are not listed.
- **`MistralVibeSessionAdapter`** — Mistral AI's Vibe CLI (`vibe`) is a listed,
readable, searchable provider. A session is
`~/.vibe/logs/session/session_<UTC stamp>_<first 8 of id>/`, and its file is
`messages.jsonl`; `meta.json` beside it supplies the id (which the directory
must agree with), title, working directory, start and end times, and the
model the active alias resolves to (`config.models[active_model].name`).
Without a recorded title, the first prompt a person typed is the title.
`injected` lines — compaction summaries, hook messages — stay out of the
transcript. Sub-agent sessions nested under a parent's `agents/` directory
are not listed.
- **`Harness.devin`** / **`SessionProvider.devin`** ("Devin") and
**`Harness.mistralVibe`** / **`SessionProvider.mistralVibe`** ("Mistral
Vibe"). Both are read-only, and resume with `devin --resume <id>` and
`vibe --resume <id>`. Devin's ids are word pairs, validated against the
loose `[A-Za-z0-9._-]` charset; Vibe's are UUID-shaped hex.
- **`SessionProviderAdapter.changeFingerprint(fileURL:)`** and
**`SessionChangeFingerprint`** — what the incremental index compares to
decide whether a session must be re-read. The default is the file's
nanosecond mtime and size with a `-wal` sibling folded in, exactly as
before. Mistral Vibe folds `meta.json` in, so a rename re-indexes the row;
Devin answers per session, so a turn in one conversation re-reads only that
one. A host adapter that wraps another should forward it; a wrapper that
does not still indexes Devin, at the coarser granularity of the whole
database.
- The Rust lane knows both providers: raw values `devin` and `mistralVibe`,
the same harness storage keys, resume commands, and id validation.

### Changed

- `SessionIndexService` fingerprints each session through its adapter's
`changeFingerprint` rather than a `stat` of its own.
- `LiveSQLiteReader.read(at:snapshotFallback:_:)` can give up after the direct
read instead of copying the database, for a probe that runs once per
session against a store many sessions share.
- `LiveSQLiteReader` reads a WAL database whose last writer closed cleanly (no
`-wal` left behind) in place with `immutable=1`. A read-only connection
cannot recreate the journal, so that read used to fail and fall back to a
full snapshot copy; it now neither copies nor leaves a file behind.

## [0.9.0] - 2026-09-17

Meta's Muse Code joins the session list: its sessions are listed, searched,
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ point takes the home directory it should read.
| Cursor | `~/.cursor/chats/**/store.db` | ✅ | ⚠️ when a turn recorded one | ❌ store stays open |
| Grok Bot | `~/Library/Application Support/Grok Bot/sand-client-persistence` | ✅ | ❌ the run happens server-side | ❌ a cloud cache |
| Muse Code | `~/.local/share/muse/sessions/YYYY/MM/DD/<id>/session.jsonl` | ✅ | ✅ | ❌ the CLI indexes and locks it |
| Devin | `~/.local/share/devin/cli/sessions.db` — one database, a row per session | ✅ | ✅ | ❌ rows in a live database |
| Mistral Vibe | `~/.vibe/logs/session/session_*/{meta.json,messages.jsonl}` | ✅ | ✅ | ❌ the CLI indexes and leases it |

Where a ⚠️ appears the log genuinely does not carry the value — an aborted
Cursor conversation records no model name at all, and old Gemini CLI chats
Expand All @@ -113,11 +115,23 @@ an empty one. Grok Bot's ❌ is the stronger statement: the conversation runs
on xAI's servers and the client's cache records no model, no token counts,
and no cost for anyone to read.

Five providers are listed and readable but never deletable, because another
Seven providers are listed and readable but never deletable, because another
running app — or, for Grok Bot, a server — owns the store.
`SessionProvider.supportsDeletion` says so up front, and the adapters fail
closed with `SessionDeleteError.providerIsReadOnly`.

Devin is the one store that is not a file per session: the `devin` CLI and the
Devin desktop app (whose "Devin Local" agent drives that CLI) keep every
conversation as rows in one SQLite database. Its adapter hands out a locator
per session, `…/sessions.db/<session id>`, which names a row rather than a
file, and answers `changeFingerprint` per session, so a turn in one
conversation re-reads that one. A session's messages are a forest —
compaction re-roots the system prefix, retries add siblings, sub-agents grow
trees of their own — and the transcript is the one chain from
`sessions.main_chain_id` back to its root. Sessions Devin marks `hidden` (its
own helper agents' runs) are not listed. Mistral Vibe's sub-agent sessions
nest inside their parent's directory and are not listed either.

## Usage

### List and read
Expand Down Expand Up @@ -153,8 +167,12 @@ await service.refreshIndex { done, total in print("\(done)/\(total)") }
let hits = try await service.search("sqlite migration", harnesses: [.codex, .claudeCode])
```

The index is incremental: a file is re-read only when its fingerprint —
nanosecond mtime plus size, WAL sibling folded in — has moved. Full-text
The index is incremental: a session is re-read only when its adapter's
`changeFingerprint` has moved — for a file, nanosecond mtime plus size with a
WAL sibling folded in; for Mistral Vibe, `messages.jsonl` and `meta.json`
together; for a Devin session, its own row and node counters. A host that wraps
an adapter should forward `changeFingerprint`; one that does not still indexes
Devin, only at the coarser granularity of the whole database. Full-text
search uses an FTS5 `trigram` tokenizer, the only built-in one that matches
inside words, which is what makes substring search work for CJK and for
identifiers alike.
Expand Down Expand Up @@ -288,6 +306,7 @@ snapshot.quota?.usedPercent // 43.2, from Codex's own rate_limits
| Codex, ChatGPT Work | `token_count`: `last_token_usage.input_tokens` and `info.model_context_window` | `measured` |
| Grok Build | `signals.json`: `contextTokensUsed` and `contextWindowTokens` | `measured` |
| Cursor, AntiGravity, Grok Bot, Gemini CLI, Muse Code | nothing on disk answers it | `nil` |
| Devin, Mistral Vibe | no live adapter yet; Devin records per-reply `metrics`, Vibe `stats.context_tokens` | `nil` |

Claude Code computes both its window size *and* its category breakdown
(messages, system tools, skills, MCP tools, memory files) in-process and writes
Expand Down Expand Up @@ -339,6 +358,8 @@ table goes through `ArgvSanitizer`.
| Grok Build | ✅ `GrokLiveAdapter` | `~/.grok/sessions/<percent-encoded cwd>/<id>/{events,updates}.jsonl`; liveness via `~/.grok/active_sessions.json` and the per-file writer locks |
| Gemini CLI | — | `~/.gemini/tmp/*/chats` |
| Muse Code | — | `~/.local/share/muse/sessions/**/session.jsonl` |
| Devin | — | `~/.local/share/devin/cli/sessions.db` (WAL) |
| Mistral Vibe | — | `~/.vibe/logs/session/session_*/messages.jsonl` + `meta.json` |
| AntiGravity | ✅ `AntigravityLiveAdapter` | `~/.gemini/antigravity{-cli,}/conversations/*.db`; state from the SQL columns plus a shallow `step_payload` decode; liveness via `presence/<id>.lock` |
| Cursor | ✅ `CursorLiveAdapter` | `~/.cursor/chats/**/store.db` + `~/.cursor/projects/<slug>/agent-transcripts`; liveness via `cursor-agent-worker-*.pid` and the store's WAL |
| Grok Bot | ✅ `GrokBotLiveAdapter` | `~/Library/Application Support/Grok Bot/sand-client-persistence/<base32(key)>.blob`; the roster slice supplies the name and the needs-you flag; liveness via the `Grok Bot` process and `~/.grokbot/local-exec-supervisor.json` |
Expand Down
6 changes: 3 additions & 3 deletions implementations/rust/crates/agent-session-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ let recent = reader.list(&SessionListFilter { limit: 20, ..Default::default() })
| Contract | Value |
| --- | --- |
| Index schema version | `PRAGMA user_version = 5` (any other value ⇒ refuse) |
| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse` |
| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot` |
| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse`, `devin`, `mistralVibe` |
| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot`, `Muse Code`, `Devin`, `Mistral Vibe` |
| Index timestamps | Unix epoch seconds (INTEGER) |
| FTS query | one double-quoted phrase, `""` escaping; needles < 3 chars use escaped `LIKE` |
| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse ids; `[A-Za-z0-9._-]` for grok/gemini; max 200 chars |
| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse/mistralVibe ids; `[A-Za-z0-9._-]` for grok/gemini/devin; max 200 chars |
43 changes: 42 additions & 1 deletion implementations/rust/crates/agent-session-core/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,8 @@ impl SessionIndexReader {
"Cursor" => "cursor",
"Grok Bot" => "grokBot",
"Muse Code" => "museCode",
"Devin" => "devin",
"Mistral Vibe" => "mistralVibe",
raw => raw,
}
}
Expand All @@ -773,7 +775,9 @@ impl SessionIndexReader {
WHEN 'gemini' THEN 'geminiCLI' \
WHEN 'antigravity' THEN 'antigravity' \
WHEN 'grokBot' THEN 'grokBot' \
WHEN 'muse' THEN 'museCode' END)"
WHEN 'muse' THEN 'museCode' \
WHEN 'devin' THEN 'devin' \
WHEN 'mistralVibe' THEN 'mistralVibe' END)"
}

fn dedup_nonempty_strings(
Expand Down Expand Up @@ -1008,6 +1012,43 @@ mod tests {
assert_eq!(rows[0].harness.as_deref(), Some("codex"));
}

#[test]
fn devin_and_mistral_vibe_rows_list_and_filter_by_either_name() {
let dir = tempfile::tempdir().unwrap();
let path = fixture_index(dir.path());
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \
VALUES(4, 'devin', 'quiet-harbor', NULL, 'devin row', 1700004000, 1700004000, \
'/Users/example/.local/share/devin/cli/sessions.db/quiet-harbor'); \
INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \
VALUES(5, 'mistralVibe', '5c0ffee1-7a1b-4c2d-8e3f-0123456789ab', 'mistralVibe', 'vibe row', \
1700005000, 1700005000, \
'/Users/example/.vibe/logs/session/session_20260101_000000_5c0ffee1/messages.jsonl');",
)
.unwrap();
drop(conn);

let reader = SessionIndexReader::open(&path).unwrap();
for (harness, provider, title) in [
("Devin", SessionProvider::Devin, "devin row"),
("devin", SessionProvider::Devin, "devin row"),
("Mistral Vibe", SessionProvider::MistralVibe, "vibe row"),
("mistralVibe", SessionProvider::MistralVibe, "vibe row"),
] {
let rows = reader
.list(&SessionListFilter {
harnesses: Some(vec![harness.to_string()]),
limit: 10,
..Default::default()
})
.unwrap();
assert_eq!(rows.len(), 1, "{harness}");
assert_eq!(rows[0].provider, provider);
assert_eq!(rows[0].title.as_deref(), Some(title));
}
}

#[test]
fn null_harness_uses_provider_default_before_filtering_and_offset() {
let dir = tempfile::tempdir().unwrap();
Expand Down
53 changes: 52 additions & 1 deletion implementations/rust/crates/agent-session-core/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ pub enum SessionProvider {
GrokBot,
#[serde(rename = "muse")]
Muse,
#[serde(rename = "devin")]
Devin,
#[serde(rename = "mistralVibe")]
MistralVibe,
}

impl SessionProvider {
pub const ALL: [SessionProvider; 9] = [
pub const ALL: [SessionProvider; 11] = [
SessionProvider::Claude,
SessionProvider::ClaudeCowork,
SessionProvider::Codex,
Expand All @@ -36,6 +40,8 @@ impl SessionProvider {
SessionProvider::Antigravity,
SessionProvider::GrokBot,
SessionProvider::Muse,
SessionProvider::Devin,
SessionProvider::MistralVibe,
];

/// Storage raw value — identical to Swift's `rawValue`.
Expand All @@ -50,6 +56,8 @@ impl SessionProvider {
SessionProvider::Antigravity => "antigravity",
SessionProvider::GrokBot => "grokBot",
SessionProvider::Muse => "muse",
SessionProvider::Devin => "devin",
SessionProvider::MistralVibe => "mistralVibe",
}
}

Expand All @@ -72,6 +80,8 @@ impl SessionProvider {
SessionProvider::Antigravity => "AntiGravity",
SessionProvider::GrokBot => "Grok Bot",
SessionProvider::Muse => "Muse Code",
SessionProvider::Devin => "Devin",
SessionProvider::MistralVibe => "Mistral Vibe",
}
}

Expand All @@ -88,3 +98,44 @@ impl SessionProvider {
)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn raw_values_round_trip_and_match_serde() {
for provider in SessionProvider::ALL {
assert_eq!(
SessionProvider::from_raw(provider.raw_value()),
Some(provider)
);
assert_eq!(
serde_json::to_string(&provider).unwrap(),
format!("\"{}\"", provider.raw_value())
);
}
assert_eq!(SessionProvider::Devin.raw_value(), "devin");
assert_eq!(SessionProvider::MistralVibe.raw_value(), "mistralVibe");
assert_eq!(SessionProvider::Devin.default_harness(), "Devin");
assert_eq!(
SessionProvider::MistralVibe.default_harness(),
"Mistral Vibe"
);
}

#[test]
fn read_only_providers_refuse_deletion() {
for provider in [
SessionProvider::ClaudeCowork,
SessionProvider::Cursor,
SessionProvider::Antigravity,
SessionProvider::GrokBot,
SessionProvider::Muse,
SessionProvider::Devin,
SessionProvider::MistralVibe,
] {
assert!(!provider.supports_deletion(), "{provider:?}");
}
}
}
Loading
Loading