Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b110ffa
feat(desktop): select the durable store instead of searching for it
tonyfettes Aug 15, 2026
d78ef88
fix(desktop): act on the sidebar row's own durable record
tonyfettes Aug 15, 2026
c85c049
docs: require the store on every record-addressing session op
tonyfettes Aug 15, 2026
95fd076
fix(desktop): filter sidebar rows by record, not by session id
tonyfettes Aug 15, 2026
4bd19be
fix(desktop): never rebind an open conversation from an ambiguous lis…
tonyfettes Aug 15, 2026
56fd5b4
refactor(desktop): index the session listings instead of rescanning them
tonyfettes Aug 15, 2026
7610d8a
fix(desktop): free the checkout of the project the archive selected
tonyfettes Aug 15, 2026
f821b40
feat(desktop): stop creating same-id records, and scope slots to a store
tonyfettes Aug 15, 2026
9f871bd
docs(desktop): fix the vocabulary
tonyfettes Aug 15, 2026
45d03c8
refactor(desktop): carry the selected store as one value
tonyfettes Aug 15, 2026
64f0752
fix(desktop): write a session command only to its own store's engine
tonyfettes Aug 15, 2026
2e1ddd2
feat(desktop): name the store on every op that writes a record
tonyfettes Aug 15, 2026
c5ac204
fix(desktop): let a record's own store decide whether it is archived
tonyfettes Aug 15, 2026
003a943
refactor(desktop): define the codecs of every payload this change tou…
tonyfettes Aug 15, 2026
aa201b9
fix(desktop): refuse a step count that is not an integer
tonyfettes Aug 17, 2026
00903a4
fix(desktop): close the two ways a command still made a same-id twin
tonyfettes Aug 17, 2026
4db1eac
fix(desktop): read both listings before calling a placement unambiguous
tonyfettes Aug 17, 2026
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
3 changes: 3 additions & 0 deletions desktop/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Desktop Agent Notes

`CONTEXT.md` fixes the vocabulary — store, root, workspace, checkout,
placement, family. Read it before naming anything in this tree.

## Frontend And Host Compatibility

- The desktop frontend and host are versioned and shipped together. Do not
Expand Down
88 changes: 88 additions & 0 deletions desktop/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Desktop Vocabulary

Words that mean one exact thing in `desktop/`. Several of them are ordinary
English that the codebase has already spent on something specific, so a loose
second use silently merges two concepts. Prefer these; when a term below says
"never", it is because that use once existed and cost a bug.

## Two axes, never one

Every conversation answers two independent directory questions. They coincide
for a plain project conversation, which is why they get conflated.

| Conversation | Record lives in (store) | Agent works in (checkout) |
|---|---|---|
| Project `/p` | `/p/.openseek` | `/p` |
| Worktree `wt-1` of `/p` | `/p/.openseek` | `/p/.worktrees/wt-1` |

The worktree row shows the two are siblings, neither containing the other.
Nothing may derive one from the other — each follows from its own input.

## Durable identity

- **session id** — a conversation's name. **Unique only within one store.**
Generators differ in collision resistance: desktop mints
`desktop-YYYYMMDD-HHMMSS-mmm-sssssssss` with a random salt
(`frontend/session.mbt`), the TUI's `tui-...` form has no salt
(`SessionId::generated`'s `salt?` defaults to empty), and the CLI takes
`--session <name>` verbatim. Never treat an id alone as an identity.
- **record** — one conversation's durable directory, `<root>/sessions/<id>/`:
transcript, title, standing goal, review base. Deleting it is what "delete a
conversation" means; project files are never touched.
- **store** (session store) — the root a record lives under: one per
registered workspace (`@workspaces.store_root(w)` = `<w>/.openseek`),
enumerated by `@session_store.known_roots()`. A store *has* a root; the two
words are not interchangeable.
- **root** — the path of a store, and the value passed as `--session-root`.
Only ever say "root" unqualified about a store. Other roots exist
(`workspace_root()`, `checkout_root`) and are checkouts, not stores — always
qualify those.
- **family** — a record plus every `-sr-N` descendant sub-run record beside it.
Records are flat sibling directories even though the sidebar draws a tree, so
a family is discovered by id structure (`is_descendant_session`), never by a
textual prefix. Archive, unarchive, and delete move a whole family or none of
it.
- **archived twin** — `<root>/archived/sessions/<id>`, the same layout one
level down, so listing archived conversations is one more `sessions list`.
`<root>/archived/deleting/` holds condemned records; a rename into it is
permanent deletion's commit point.

## Directories

- **workspace** — a *registered project directory*. The host registry, never a
request, decides which directories qualify. Every conversation belongs to
one, and its store is that project's.
- **worktree** — `<workspace>/.worktrees/<name>`, a checkout owned by exactly
one conversation. Its records still live in the **project's** store.
- **checkout** / **cwd** — the directory the agent, terminals, and file
operations work in. Derived from (workspace, id) through the worktree
registry, never searched for.
- **placement** — already means two things, both about checkouts, and must
never be stretched to cover a store: (1) the retained worktree registry row
that survives archiving so an unarchive reads as `MissingTree` and can offer
a rebuild; (2) `CheckoutPlacement` (`frontend/interop/channel.mbt`), the
client's four-case view of a conversation's checkout.

## Runtime

- **serve engine** — one `openseek serve` child process. The host runs **at
most one per session id**; slots are keyed by id alone, so any operation
naming a store must ask whether a live engine actually writes that store
before treating it as its own.
- **slot** — a session id's entry in the manager's map: its engine, its pending
claim, its follower generation.
- **follower** — the actor tailing one record's durable tail.
- **run** — one turn: a prompt through its terminal event. A conversation
outlives its runs; a run never outlives its conversation.

## Wire versus host

The protocol has no word for a store. Every op addressing a record spells one
as `workspace`: the registered project's resource path the host itself
reported. The host validates it against the registry rather than trusting the
spelling, and selects that store exactly instead of searching.

That one field answers both directory questions — which store holds the
record, and which project the run works in — because a project owns both. Say
which one you mean when the difference matters, as it does for a worktree
conversation, whose checkout is not its project's directory.
33 changes: 24 additions & 9 deletions desktop/frontend/archive.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/// the dialog appear to authorize a different conversation.
priv struct ArchivedDeleteConfirm {
channel : @interop.ChannelId
key : ArchiveRecordKey
key : RecordKey
// The parent plus every descendant visible in the same archived snapshot.
// Host notifications still cover a child created after that snapshot.
sessions : Array[String]
Expand Down Expand Up @@ -57,16 +57,19 @@ fn fetch_archived(
fn archive_session(
dispatch : @cmd.Emit[Msg],
channel : @interop.ChannelId,
session : String,
key : RecordKey,
connection_generation : Int,
sessions_request_generation : Int,
archived_request_generation : Int,
force? : Bool = false,
) -> @cmd.Cmd {
guard key.workspace_payload(channel) is Some(workspace) else {
return @cmd.none
}
@cmd.custom_cmd(scheduler => {
@js.async_run(() => {
archive_session_into(
scheduler, dispatch, channel, session, connection_generation, sessions_request_generation,
scheduler, dispatch, channel, key, workspace, connection_generation, sessions_request_generation,
archived_request_generation, force,
)
})
Expand All @@ -78,7 +81,8 @@ async fn archive_session_into(
scheduler : &@cmd.Scheduler,
dispatch : @cmd.Emit[Msg],
channel : @interop.ChannelId,
session : String,
key : RecordKey,
workspace : String,
connection_generation : Int,
sessions_request_generation : Int,
archived_request_generation : Int,
Expand All @@ -90,7 +94,15 @@ async fn archive_session_into(
// Archiving removes the conversation's checkout but retains its placement;
// the retry after the discard-confirmation dialog carries force on the
// wire.
{ session, force: if force { Some(true) } else { None } },
{
session: key.session,
workspace,
force: if force {
Some(true)
} else {
None
},
},
) catch {
error => {
scheduler.add(
Expand All @@ -105,7 +117,7 @@ async fn archive_session_into(
NeedsForce(refusal) =>
scheduler.add(
dispatch.map((msg : DeviceMsg) => FromDevice(channel, msg))(
ArchiveNeedsForce(session~, refusal~),
ArchiveNeedsForce(key~, refusal~),
),
)
Archived(reply) =>
Expand All @@ -120,16 +132,19 @@ async fn archive_session_into(
fn unarchive_session(
dispatch : @cmd.Emit[Msg],
channel : @interop.ChannelId,
session : String,
key : RecordKey,
connection_generation : Int,
sessions_request_generation : Int,
archived_request_generation : Int,
) -> @cmd.Cmd {
guard key.workspace_payload(channel) is Some(workspace) else {
return @cmd.none
}
archive_action(
dispatch,
channel,
@commands.session_unarchive,
{ session, force: None },
{ session: key.session, workspace, force: None },
connection_generation,
sessions_request_generation,
archived_request_generation,
Expand All @@ -143,7 +158,7 @@ fn unarchive_session(
fn delete_archived_session(
dispatch : @cmd.Emit[Msg],
channel : @interop.ChannelId,
key : ArchiveRecordKey,
key : RecordKey,
sessions : Array[String],
connection_generation : Int,
archived_request_generation : Int,
Expand Down
32 changes: 29 additions & 3 deletions desktop/frontend/boot.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ fn SidebarDispatcher::conversation_open(
return self.unroutable("open", "channel \{id.channel()}")
}
match id.source() {
"openseek.live" => self.emit(OpenSession(channel, id.conversation()))
"openseek.live" =>
match row_record_key(channel, id) {
Some(key) => self.emit(OpenSession(channel, key))
None => self.unroutable("open", "live row with no store")
}
"openseek.archived" => {
// The row carries the store its record lives in; without one the click
// would name a session id in no particular store.
Expand All @@ -179,6 +183,21 @@ fn SidebarDispatcher::conversation_open(
}
}

///|
/// The durable record a sidebar row stands for. Its `root` is the store its
/// group renders, so the row acts on that record even when another store
/// holds one with the same session id.
fn row_record_key(
channel : @interop.ChannelId,
id : @conversation.Id,
) -> RecordKey? {
guard id.root() is Some(root) &&
@resource.of_path(channel, root) is Some(workspace) else {
return None
}
Some({ session: id.conversation(), workspace })
}

///|
fn SidebarDispatcher::conversation_archive(
self : SidebarDispatcher,
Expand All @@ -188,7 +207,11 @@ fn SidebarDispatcher::conversation_archive(
return self.unroutable("archive", "channel \{id.channel()}")
}
match id.source() {
"openseek.live" => self.emit(ArchiveSession(channel, id.conversation()))
"openseek.live" =>
match row_record_key(channel, id) {
Some(key) => self.emit(ArchiveSession(channel, key))
None => self.unroutable("archive", "live row with no store")
}
"codex.live" => self.emit(CodexArchiveThread(id.conversation()))
source => self.unroutable("archive", "\{source}@\{id.channel()}")
}
Expand All @@ -204,7 +227,10 @@ fn SidebarDispatcher::conversation_restore(
}
match id.source() {
"openseek.archived" =>
self.emit(UnarchiveSession(channel, id.conversation()))
match row_record_key(channel, id) {
Some(key) => self.emit(UnarchiveSession(channel, key))
None => self.unroutable("restore", "archived row with no store")
}
"codex.archived" => self.emit(CodexUnarchiveThread(id.conversation()))
source => self.unroutable("restore", "\{source}@\{id.channel()}")
}
Expand Down
Loading
Loading