RFD 020: Parallel Conversations
- Status: Implemented
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2025-07-19
- Extended by: RFD 069
Summary
This RFD replaces the workspace-wide "active conversation" with per-session conversation tracking, adds conversation locks to prevent concurrent mutations, and introduces --id and --fork flags on jp query for explicit conversation targeting. Together, these changes allow users to run multiple conversations in parallel across terminal sessions.
Motivation
JP tracks a single "active conversation" per workspace. This is a global singleton stored in ConversationsMetadata.active_conversation_id. Every jp query without --new operates on this conversation, regardless of which terminal the command runs in.
This creates three problems:
Terminal interference. If you have two terminal tabs open in the same workspace and run jp query in both, they operate on the same conversation. The second query appends to the first's history. Tool calls from one session can interleave with events from another. The conversation becomes incoherent and potentially corrupted.
No parallelism. You cannot work on two independent queries at the same time within one workspace. Starting a new conversation in one terminal (jp query --new) changes the active conversation for all terminals. The other terminal's next jp query silently switches to the new conversation.
Fragile state. The active_conversation_id is a single point of contention. If two processes write it simultaneously (e.g., both running jp query --new), the last writer wins and the other session loses track of its conversation.
These problems block any future work on background execution or multi-agent workflows. Before conversations can run in parallel, each session needs its own conversation identity and conversations need protection against concurrent writes.
Design
Session Identity
A session is a terminal context - a tab, window, tmux pane, or scripting environment. JP identifies sessions using three layers, checked in order:
$JP_SESSIONenvironment variable. If set, this value is the session identity. It takes priority over everything else. The value is opaque to JP — any non-empty string works.Platform-specific automatic detection. On Unix,
getsid(0)returns the session leader PID. On Windows,GetConsoleWindow()returns a per-tab window handle (HWND).Terminal-specific environment variables. JP checks a list of known per-tab or per-pane env vars set by popular terminal emulators and multiplexers.
If none of these produce a session identity, JP operates without a session. In interactive terminals, commands that need a conversation show an interactive picker (see jp query (no flags)). In non-interactive environments, they fail with an error directing the user to --id, --new, or $JP_SESSION. Session mappings cannot be persisted without a session identity.
$JP_SESSION
The environment variable is the explicit override for cases where automatic detection is unreliable or unavailable:
- CI/scripts: Non-interactive environments without a terminal can set
JP_SESSIONto any stable identifier. - SSH: SSH allocates a new PTY per connection. If you want session persistence across SSH reconnects, set
JP_SESSIONto a stable value. - Unusual terminal setups: Any environment where the automatic detection produces wrong or missing results.
Platform-Specific Automatic Detection
If $JP_SESSION is not set, JP uses a platform-specific mechanism:
Unix: Session leader PID. getsid(0) returns the PID of the session leader — typically the login shell that the terminal spawned. Each terminal tab, window, and tmux pane gets its own session leader, so the PID is unique per tab. It is inherited by subshells and child processes, so bash -c "jp query ..." sees the same session identity as the parent shell.
The session leader PID is also stable across tmux detach/reattach: the shell process inside the pane stays alive, so its PID (and therefore the session leader PID) does not change.
The main limitation is PID recycling. If a terminal is closed and a new process happens to get the same PID as the old session leader, the new terminal could see the old session mapping. This is handled by stale session detection: JP checks whether the session leader process is still alive. If it is not, the mapping is stale and is discarded.
Windows: Console window handle. GetConsoleWindow() returns the HWND of the console host (conhost.exe or openconsole.exe) backing the current tab. Each tab in Windows Terminal, CMD, or PowerShell gets its own console host process with a unique HWND. The handle remains stable across commands within the same tab because the shell process keeps the console host alive.
Stale detection on Windows uses the same approach, checking whether the console host process is still alive.
Terminal Environment Variables
If platform-specific detection fails (e.g., no controlling terminal), JP checks terminal-specific environment variables as a final fallback:
| Variable | Terminal | Granularity | Platform |
|---|---|---|---|
$TMUX_PANE | tmux | Per-pane | Cross-platform |
$WEZTERM_PANE | WezTerm | Per-pane | Cross-platform |
$TERM_SESSION_ID | macOS Terminal.app | Per-tab | macOS |
$ITERM_SESSION_ID | iTerm2 | Per-session | macOS |
Only variables with per-tab or per-pane granularity are used. Per-window variables like $WT_SESSION (Windows Terminal), $KITTY_WINDOW_ID (Kitty), and $ALACRITTY_WINDOW_ID (Alacritty) are deliberately excluded because multiple tabs in the same window share the value, which would cause sessions to collide.
Since these variables are opaque strings, session mappings sourced from them cannot be stale-detected via process liveness. They are only cleaned up when none of the conversations they reference exist on disk (see Stale File Cleanup).
This list is extensible. New terminals can be added without an RFD.
Session-to-Conversation Mapping
JP stores a mapping from session identity to conversation ID in the user-local data directory:
~/.local/share/jp/workspace/<workspace-id>/sessions/
├── <session-key>.jsonWhere <session-key> is the $JP_SESSION value or the session leader PID (e.g., 12057).
The mapping file contains:
{
"history": [
{
"id": "jp-c17528832001",
"activated_at": "2025-07-19T14:30:00Z"
},
{
"id": "jp-c17528832002",
"activated_at": "2025-07-19T14:00:00Z"
},
{
"id": "jp-c17528832003",
"activated_at": "2025-07-19T13:00:00Z"
}
],
"source": {
"type": "env",
"key": "JP_SESSION"
}
}The history array tracks conversations activated in this session, ordered most recent first. Each entry records the conversation ID and activation timestamp. The list is deduplicated — reactivating a conversation moves it to the front rather than creating a duplicate entry. The active conversation is always history[0]. This history enables --id=previous (the session's previously active conversation) and improves the interactive picker by showing session-relevant conversations first.
The source field records how the session identity was produced. This determines whether and how stale detection can be performed:
| Source | source value | Stale detection |
|---|---|---|
getsid | "getsid" | Delete only when session leader PID is |
| confirmed dead. A live process keeps its | ||
| session unconditionally. | ||
| Windows HWND | "hwnd" | Delete only when the console window |
| handle is no longer valid. | ||
$JP_SESSION | { "type": "env", "key": "JP_SESSION" } | Liveness unknown — removed only when |
| no referenced conversations exist on | ||
| disk. | ||
$TMUX_PANE | { "type": "env", "key": "TMUX_PANE" } | Same as $JP_SESSION. |
For Getsid and Hwnd sources, process liveness is authoritative: if the session leader (or console window) is alive, the session mapping is valid regardless of whether its conversations are currently visible on disk (another process may be mid-persist, or the conversation may have been created after the current process loaded its index). The mapping is only deleted when the process is confirmed dead.
Mappings sourced from environment variables are opaque strings with no way to verify liveness, so they fall back to a conversation-existence heuristic: the mapping is removed by the Stale File Cleanup task when none of the conversations in its history exist on disk.
When jp query successfully operates on a conversation, that conversation is pushed to the front of the session's history. When jp query --new creates a new conversation, it becomes history[0]. The session's active conversation is always history[0].
Conversation Locks
Conversations are protected by exclusive file locks during write operations. When jp query starts, it acquires an advisory lock on the conversation via fd-lock (flock on Unix, LockFileEx on Windows). The lock is held for the entire query execution (including tool calls and multi-cycle turns) and released when the command exits.
Lock files live in the user-local data directory:
~/.local/share/jp/workspace/<workspace-id>/locks/<conversation-id>.lockThe lock file contains the PID and session identity for diagnostic purposes:
{
"pid": 12345,
"session": "12057",
"acquired_at": "2025-07-19T14:30:00.000Z"
}This content is informational only — the actual locking is done by the OS via flock (Unix) or LockFileEx (Windows). If the process exits, crashes, or is killed (including SIGKILL), the OS releases the lock automatically.
The lock file itself is eagerly deleted when the lock guard is dropped (normal exit, Ctrl+C, panics). If the process is killed with SIGKILL, the lock file orphans on disk but the OS releases the underlying lock automatically. Orphaned files are harmless and are cleaned up by a background task (see Stale File Cleanup).
What the lock protects
| Operation | Lock required? | Rationale |
|---|---|---|
jp query (any variant) | Yes (exclusive) | Writes events to conversation |
jp conversation rm | Yes (exclusive) | Deletes conversation data |
jp conversation edit | Yes (exclusive) | Modifies conversation metadata |
jp conversation show | No | Read-only |
jp conversation ls | No | Read-only |
jp conversation fork | No | Reads source, writes to a new |
| conversation | ||
jp conversation grep | No | Read-only |
jp conversation print | No | Read-only |
TIP
RFD 052 adds workspace sanitization, which moves corrupt conversation directories to .trash/. Sanitization should acquire an exclusive lock on each conversation before trashing it, to avoid moving a directory that another session is actively writing to.
Lock acquisition behavior
When a lock cannot be acquired immediately, JP blocks and waits for it to be released. In interactive terminals, JP presents a selection prompt:
Conversation jp-c17528832001 is locked (held by pid 12345, session 12057).
> Continue waiting
Start a new conversation
Fork this conversation
CancelSelecting "Continue waiting" resumes the wait. The other options provide immediate alternatives without the user having to remember CLI flags. "Cancel" exits the command.
In non-interactive environments (no TTY on stdin), JP prints an informational message and waits silently:
Waiting for lock on conversation jp-c17528832001 (held by pid 12345, session 12057)...Since jp query holds the lock for the entire session (including multi-cycle tool call loops that can run for minutes), JP enforces a default timeout of 30 seconds. If the lock is not released within this window, the command fails:
Error: Timed out waiting for lock on conversation jp-c17528832001
(held by pid 12345, session 12057).
Suggestions:
--id open conversation picker.
--id=<id> target a specific conversation.
--id=last continue the most recently active conversation.
--new start a new conversation.
--fork branch from this one.Ctrl+C during the wait cancels immediately.
The $JP_LOCK_DURATION environment variable overrides the default timeout. It accepts humantime-compatible duration strings (e.g., 10s, 2m, 1h). Setting it to 0 disables waiting entirely — lock acquisition fails immediately if the lock is held. This is useful for scripts and CI pipelines that should not block.
The implementation polls with non-blocking try_lock() at ~500ms intervals rather than issuing a blocking lock call. This makes timeout enforcement and signal handling straightforward on both platforms.
Type-level enforcement
A ConversationLock type represents proof that the process holds the lock:
/// Process-level lock. Acquired once at the start of `jp query`,
/// released when the query ends. Holds the flock.
pub struct ConversationLock {
_file: File, // holds the flock; released on drop
conversation_id: ConversationId,
}Workspace::lock_conversation(id) -> Result<ConversationLock> acquires the flock and returns the lock. The constructor is private — the only way to obtain a ConversationLock is through this method. The lock is acquired once at the start of jp query and lives in a long-lived scope (e.g., Ctx).
Mutable access to a conversation's event stream or metadata requires a reference to the lock:
impl Workspace {
pub fn get_events_mut(
&mut self,
id: &ConversationId,
_lock: &ConversationLock,
) -> Option<&mut ConversationStream> { /* ... */ }
pub fn get_conversation_mut(
&mut self,
id: &ConversationId,
_lock: &ConversationLock,
) -> Option<Mut<'_, ConversationId, Conversation>> { /* ... */ }
}All existing mutation methods stay on ConversationStream and Conversation. The change is that get_events_mut, try_get_events_mut, get_conversation_mut, and try_get_conversation_mut take a &ConversationLock parameter. Call sites add one argument:
// before
workspace.try_get_events_mut(&cid)?.add_config_delta(delta);
workspace.try_get_conversation_mut(&cid)?.title = Some(title);
// after
workspace.try_get_events_mut(&cid, &lock)?.add_config_delta(delta);
workspace.try_get_conversation_mut(&cid, &lock)?.title = Some(title);This enforces the lock-before-mutate invariant at the API boundary. You cannot call get_events_mut or get_conversation_mut without proof that the process holds the lock. ConversationLock is held for the entire jp query run, so any mutable reference obtained through it is guaranteed to be protected by the lock for its entire lifetime.
The lock file is deleted in the Drop implementation of ConversationLock.
Non-persisting queries skip the lock
jp --no-persist query skips lock acquisition entirely. Since no events are written back to disk, there is no write-write conflict. The query reads conversation state as a snapshot at the time the file is read. If the lock holder writes events.json concurrently, the reader could see a partially written file and fail to parse it. This is an acceptable trade-off: the user can retry, and the window is small since writes happen at discrete turn boundaries.
This means jp --no-persist query --id=<id> works on locked conversations, which is useful for running a non-persistent query without waiting for the lock.
CLI Changes
jp query --id[=<target>]
The --id flag accepts a conversation ID, a reserved keyword, or no value:
| Form | Behavior |
|---|---|
--id=<conversation-id> | Operate on a specific conversation by ID |
--id=last-activated | Most recently activated conversation (any session) |
--id=last-created | Most recently created conversation |
--id=previous | Session's previously active conversation |
--id (no value) | Show an interactive conversation picker |
last is shorthand for last-activated. previous can be shortened to prev.
All forms wait if the target conversation is locked (see Lock acquisition behavior) and set the resolved conversation as the session's default.
jp query --id=jp-c17528832001 "Follow-up question" # explicit ID
jp query --id=last "Continue where I left off" # most recent
jp query --id=previous "Back to the other thing" # session's previous
jp query --id "Which conversation?" # interactive pickerlast uses the last_activated_at timestamp across all conversations. last-created uses the conversation's creation timestamp — useful when you ran --new in another tab and want to pick it up here. previous reads the session's activation history to find the conversation that was active before the current one, similar to cd - in a shell. Fails if the session has no previous conversation.
The keyword list is extensible — new keywords can be added without structural changes, since conversation IDs use the jp-c<timestamp> format which cannot collide with short alphabetic keywords.
The interactive picker (--id with no value) displays recent conversations with their title, last message preview, and timestamp. Conversations previously activated in the current session appear first, followed by remaining conversations sorted by last activation time. This follows the same pattern as the bare --cfg interactive browser described in RFD 061.
In clap, --id uses num_args = 0..=1 with `default_missing_value = "". The empty string triggers the interactive picker; recognized keywords trigger their respective resolution; any other value is treated as a conversation ID.
jp query --fork[=N]
Fork the session's active conversation and start a new turn on the fork. The forked conversation becomes the session's default.
jp query --fork "Try a different approach"
jp query --fork=3 "Redo the last 3 turns differently"N is optional. If specified, the fork keeps the last N turns of the source conversation. If omitted, the fork keeps the entire history.
This is shorthand for jp conversation fork --activate followed by jp query. The fork reads the source conversation (no lock needed) and creates a new one.
--fork can combine with --id to fork a specific conversation:
jp query --fork --id=last "Branch from the most recent conversation"
jp query --fork --id=jp-c17528832001 "Branch from this one"
jp query --fork --id -- "Pick a conversation to fork"Without --id, --fork operates on the session's active conversation. Fails if the session has no active conversation.
TIP
RFD 039 introduces conversation trees, where --fork=0 becomes the standard mechanism for creating blank child conversations nested under their parent.
jp query (no flags)
Continue the session's active conversation. This is the common case for ongoing work within a single terminal.
jp query "Next question"Resolution order:
- Session mapping exists: Use the session's default conversation.
- No session mapping + interactive terminal: Show the interactive conversation picker (same as
--idwith no value). The selected conversation becomes the session's default. - No session mapping + non-interactive: Fail with an error directing the user to
--id=<id>,--id=last,--new, or$JP_SESSION. - No conversations exist: Fail with guidance to use
--new.
In the common single-session workflow, step 1 applies after the first query. Step 2 only triggers when a session has never operated on a conversation — for example, opening a new terminal tab or a split pane. The picker lets the user explicitly choose which conversation to continue, avoiding silent misrouting when multiple conversations are active.
Fails if:
- No conversations exist in the workspace
- Conversation is locked by another session and the lock wait times out
- Non-interactive and no session mapping exists
jp query --new
Unchanged. Creates a new conversation and starts a turn. The new conversation becomes the session's default. Always succeeds (no lock contention — new conversations have no other users).
jp conversation use <id>
Sets the session's default conversation without starting a query. The conversation does not need to be unlocked (this is a session mapping change, not a write to the conversation).
jp conversation use jp-c17528832001This replaces the current behavior where use changes the workspace-wide active conversation. It now only affects the current session.
Removal of active_conversation_id
The active_conversation_id field in ConversationsMetadata is removed. There is no workspace-wide "active" conversation. Each session independently tracks its own default conversation via the session mapping.
The field is removed from the ConversationsMetadata struct. Serde ignores unknown fields during deserialization, so old workspace files that contain the field will continue to load. JP simply stops reading or writing it.
Terminal Title Updates
When jp query finishes a turn, JP writes the conversation's ID + title (or ID if untitled) to the terminal title via the OSC 2 escape sequence:
\x1b]2;jp-c1234: Refactoring the config layer\x07This makes the active conversation visible in the terminal's tab or title bar, which helps users identify which conversation is running in which terminal session. In split-pane workflows, the title is visible in each pane's header (in tmux) or the tab bar (in terminals that update per-pane).
The title is updated at the end of each assistant turn, not continuously during streaming. If the conversation has no title yet (e.g., the first turn before title generation runs), JP uses a truncated form of the conversation ID.
This is a progressive enhancement — terminals that don't support OSC 2 ignore the sequence. The jp_term::osc module already provides escape sequence utilities.
Drawbacks
No sticky session without session identity. Users without a controlling terminal and without $JP_SESSION cannot persist a session mapping. In interactive terminals, they see the conversation picker on every bare jp query invocation. In non-interactive environments, they get an error and must use --id=<id>, --id=last, or --new explicitly.
Lock contention UX. When a conversation is locked, JP waits up to 30 seconds for the lock to be released (with an interactive prompt in terminals). For brief contention (another query finishing its final write) this resolves transparently. For long-running sessions, the wait times out and the user must choose an alternative. This is friction that didn't exist before.
Session mapping is a new storage location. User-local state in ~/.local/share/jp/workspace/<workspace-id>/sessions/ adds new files alongside the existing local conversation storage. This is another place where state lives that users need to be aware of for debugging.
Interactive picker adds a step for fresh sessions. Users who open a new terminal and run bare jp query see a conversation picker instead of automatically continuing their last conversation. This is one extra interaction compared to an implicit fallback, but it prevents silent misrouting when multiple conversations are active across sessions.
Alternatives
Keep workspace-wide active conversation as fallback
When no session mapping exists and no --last is passed, fall back to the workspace-wide active_conversation_id. This preserves backward compatibility.
Rejected because it ties the fallback to mutable global state. The implicit --last fallback achieves similar convenience without a global singleton: it reads the most recently modified conversation from conversation metadata (read-only) rather than a shared active_conversation_id field that multiple processes contend over.
PPID-based session identity
Use the parent process ID instead of the session leader PID.
Rejected because PPID is unreliable. All tmux panes share the tmux-server PPID. VS Code terminal tabs may share a PPID. Subshells have different PPIDs than their parent shell. The session leader PID (getsid) is stable across all of these cases because it looks through the process hierarchy to the original session leader.
No conversation locks
Rely on user discipline to avoid concurrent writes. Trust that users won't run jp query on the same conversation from two terminals.
Rejected because it's a data integrity issue, not a UX preference. Two processes appending to events.json concurrently produces corrupt data. Locks are necessary for correctness.
Lock per workspace instead of per conversation
A single lock for the entire workspace. Only one jp query can run at a time.
Rejected because it defeats the purpose of parallel conversations. Users want independent queries running simultaneously on different conversations. Per- conversation locks allow this.
Implicit --last fallback for fresh sessions
When no session mapping exists, silently resolve to the most recently modified conversation. This avoids the picker step and preserves "just works" behavior for users who only ever use one terminal.
Rejected because it silently picks the wrong conversation when multiple sessions are active. For example: a user working in a split pane sees conversation A on the left, but an agentic session in another tab recently touched conversation B. The right pane's bare jp query silently continues conversation B. The user doesn't notice until the response is wrong. The interactive picker makes this choice explicit at the cost of one additional interaction.
Separate --last flag
Add a dedicated --last flag instead of overloading --id=last.
Rejected in favor of consolidating conversation targeting into a single --id flag with three modes (explicit ID, last keyword, interactive picker). This reduces the flag surface area and follows the same pattern as --cfg in RFD 061, where a bare flag triggers interactive mode and a valued flag provides explicit input.
Non-Goals
Background execution. Running conversations as detached background processes, attaching to running conversations from other terminals, and deferred prompt answering are future work that builds on the foundations established here (conversation targeting and locking).
Cross-machine sessions. Session mappings are local to the machine.
$JP_SESSIONcan be set to the same value on different machines, but the session-to-conversation mapping is not synchronized.Automatic session detection beyond
getsid. Shell integration, terminal emulator APIs, or other mechanisms for automatic session detection are future work. The initial implementation uses$JP_SESSION, terminal env vars, andgetsid.Conversation access control. Locks prevent concurrent writes. They do not implement permissions or multi-user access control. Any local user with filesystem access can read conversations.
Risks and Open Questions
Conversation discovery for --id=last
--id=last finds the most recently modified conversation. Today, conversations are sorted by last_activated_at. With per-session tracking, this timestamp is updated whenever a session operates on a conversation. This means --id=last returns the conversation most recently used by any session, not the most recently created conversation. This seems correct — "last" means "last worked on" — but should be documented.
Implementation Plan
Phase 1: Session Identity
Add session identity resolution to jp_cli. Check $JP_SESSION, then platform-specific detection (getsid on Unix, GetConsoleWindow on Windows), then terminal env vars ($TMUX_PANE, $WEZTERM_PANE, $TERM_SESSION_ID, $ITERM_SESSION_ID). Store the result in Ctx for use by commands.
Phase 2: Handle-Based Workspace API
ConversationHandle and ConversationGuard types provide proof of conversation existence at the type level. acquire_conversation, events/metadata/ events_mut/metadata_mut form the handle-based API. All production code migrated. Old get_events/get_conversation methods remain for internal use.
Phase 3a: Remove Global Active Conversation
ConversationsMetadata and conversations/metadata.json removed. The workspace-wide singleton is replaced by per-session mappings (Phase 3b).
Phase 3b: Session-to-Conversation Mapping and ConversationNeed
Session mapping storage in ~/.local/share/jp/workspace/<id>/sessions/. jp query and jp conversation use write the mapping. The startup pipeline reads it to resolve the session's target conversation.
ConversationNeed enum (None / Session) and startup dispatch implemented. Commands declare their need; None-need commands skip conversation loading entirely.
Phase 3c: Conversation Locks
ConversationFileLock in jp_storage::lock wraps OS-level advisory locks (flock on Unix, LockFileEx on Windows) via libc/windows-sys directly (not fd-lock, to avoid the RwLockWriteGuard lifetime issue and a windows-sys version conflict).
ConversationGuard is a real struct: ConversationHandle + Option< ConversationFileLock>. The lock is Option to support guard_conversation (unlocked, for --no-persist and commands not yet migrated). jp query acquires the lock with polling/timeout (acquire_conversation_lock in query.rs). $JP_LOCK_DURATION overrides the default 30s timeout.
Phase 4a: CLI Flags
Add --id and --fork to jp query. The --id flag supports three modes:
--id=<id>— explicit conversation--id=last— most recently modified conversation--id(no value) — triggers interactive picker
--fork[=N] composes with --id for forking specific conversations.
This phase also adds ConversationNeed::New and ConversationNeed::Explicit(id) variants and the corresponding startup dispatch paths.
Phase 4b: Interactive Pickers
All interactive selection prompts that require design and UX work:
- Conversation picker (
--idwith no value, or barejp querywith no session mapping in an interactive terminal). Shows recent conversations with title, last message preview, timestamp. Session-relevant conversations first. - Lock contention prompt (conversation locked by another session). Options: continue waiting, start new conversation, fork, cancel.
Non-interactive fallback
Implemented as part of Phase 4b. In non-interactive environments (stdin is not a TTY):
- Bare
--id(picker mode) fails withNoConversationTargeterror and guidance directing to--id=<id>,--id=last,--new, or$JP_SESSION. Sessionpath with no session mapping fails with the same error.
In interactive terminals, both cases currently fall through to Session (which picks the most recent conversation). The interactive picker will replace this fallback.
Conversation picker
MVP using inquire::Select. Two call sites:
conversation_need()Picker path (--idwith no value in interactive terminal):pick_conversation()inquery.rsshows the picker and returnsExplicit(id). Conversations sorted bylast_activated_at(most recent first), session's active conversation pinned to top.run_innerSession path (no session mapping in interactive terminal):pick_session_conversation()inlib.rsshows the same picker. ReturnsOption<ConversationId>—Noneon cancel falls through to the most-recent-conversation default. Only shown when >1 conversation exists.
Both pickers display {id} {title} per line (or just {id} if untitled).
Lock contention prompt
When acquire_conversation_lock times out in an interactive terminal, it shows an inquire::Select prompt with four options:
- Continue waiting — re-enters the polling loop with a fresh timeout. Recurses back to the prompt if the second timeout also expires.
- Start a new conversation — returns
LockOutcome::NewConversation;Query::runcreates a new conversation and acquires the lock on it. - Fork this conversation — returns
LockOutcome::ForkConversation;Query::runforks the locked conversation (read-only, no lock needed) and acquires the lock on the fork. - Cancel — returns
Error::LockTimeout, terminating the command.
In non-interactive environments (no TTY), acquire_conversation_lock still fails with Error::LockTimeout immediately on timeout.
Phase 5: Terminal Title Updates
Write the conversation title to the terminal title via OSC 2 at two points:
- At startup when the target conversation is resolved — the user immediately sees which conversation they're in.
- Inside the title generation task when a title is first generated — the update appears as soon as the title is ready, not at the end of the turn.
This is a progressive enhancement — terminals that don't support OSC 2 ignore the sequence.
Implementation notes
jp_term::osc::set_title()writes the OSC 2 sequence to stderr.Query::run()callsset_terminal_title(id, title)after acquiring the conversation metadata, gated onctx.term.is_tty.TitleGeneratorTask::sync()callsjp_term::osc::set_title()after writing the title to the workspace.jp_termadded as a dependency ofjp_task.
Phase 6: Stale File Cleanup
Runs synchronously at the end of every jp invocation (alongside ephemeral conversation cleanup) via Workspace::cleanup_stale_files():
- Lock files:
Storage::list_orphaned_lock_files()attempts a non-blockingflockon each.lockfile; if it succeeds, the file is orphaned and deleted. Useslock::is_orphaned_lock()internally. - Session mappings:
Storage::list_session_files()lists all session mapping files. Each mapping is evaluated based on itsSessionSource:- Getsid/Hwnd (process liveness checkable): the session is deleted only when the originating process is confirmed dead. A live process keeps its session unconditionally — conversation existence is not checked, because another process may be mid-persist or the conversation may have been created after the current process loaded its index.
- Env (liveness unknown): falls back to checking conversation existence. Conversation IDs are re-scanned from disk (not the in-memory index) to account for conversations created by other processes. Mappings where no conversation in the history exists on disk are deleted.
Implementation Details
Conversation Targeting
Problem
The current startup pipeline assumes every command operates on a conversation:
sanitize → load_conversation_index → load_config (incl. conversation config) → command.run()load_conversation_index both scans conversation IDs and sets the active conversation. load_partial_config reads the active conversation's events to merge per-conversation config overrides. This coupling creates problems:
- Commands that don't need conversations (e.g.,
jp config show) are forced through conversation resolution anyway. - Conversation resolution requires user interaction (picker) in some cases, but the startup pipeline runs before any command logic.
- Config loading depends on knowing the active conversation, but resolving the active conversation may need config (e.g., picker backend).
Resolution
Each command declares its conversation targeting needs via a trait method. The startup pipeline uses this declaration to determine whether and how to load a conversation before config is built.
/// Declared by each command to indicate its conversation needs.
enum ConversationNeed {
/// Command does not operate on a conversation.
/// Config is built without the conversation config layer.
None,
/// Command creates a new conversation.
/// Config is built without the conversation config layer (new
/// conversations have no stored config).
New,
/// Command targets a specific conversation by ID.
/// The conversation is loaded and its config is included.
Explicit(ConversationId),
/// Command continues the session's active conversation.
/// Resolved via session mapping, falling back to an interactive
/// picker if no mapping exists or error for non-interactive sessions.
Session,
}The IntoPartialAppConfig trait (or a new companion trait) gains a method:
fn conversation_need(
&self,
conversation_ids: &[ConversationId],
session: &Option<Session>,
) -> Result<ConversationNeed>;This is called early in the startup pipeline, after filesystem scanning but before config loading.
Startup Flow
The startup pipeline becomes:
sanitize()
ids = workspace.conversation_ids() // scan only
need = command.conversation_need(ids, session)
match need:
None →
config = build(layers 1 + 2 + 4 + 5) // no conversation layer
New →
config = build(layers 1 + 2 + 4 + 5) // no conversation layer
create new conversation
Explicit(id) →
workspace.load_conversation(id) // eager load target
config = build(layers 1 + 2 + 3 + 4 + 5) // full config
Session →
target = read_session_mapping() // file read
if target exists and valid:
workspace.load_conversation(target)
config = build(layers 1 + 2 + 3 + 4 + 5)
else if interactive:
pre_config = build(layers 1 + 2 + 4 + 5) // for picker UI
target = show_picker(ids, pre_config)
workspace.load_conversation(target)
config = build(layers 1 + 2 + 3 + 4 + 5) // rebuild with conversation
else:
error with guidance
Ctx::new(workspace, config, ...)
command.run(ctx)The AppConfig in Ctx is always complete and read-only. The double-build only occurs in the Session path with a picker fallback — a first-invocation-per-terminal event that also requires /dev/tty.
Workspace API and Conversation Handles
Workspace becomes a pure storage manager with no opinion on which conversation is "active." Conversation targeting is expressed through handle types that provide proof of existence and access rights.
Type Hierarchy
ConversationGuard (owns both, for write commands like `jp query`)
├── ConversationHandle (proof of existence, for read access)
└── ConversationLock (proof of exclusive file lock, Phase 3c)ConversationHandle
A move-only (non-Copy, non-Clone) type that proves a conversation exists in the workspace index. Obtained exclusively through Workspace methods. Only one handle should exist per conversation ID at a time — acquiring a second handle to the same ID is a logic error (debug-asserted).
/// Proof that a conversation exists and is loaded.
///
/// Move-only: consuming this handle (e.g., via `remove_conversation`)
/// invalidates all access. The borrow checker prevents use-after-move.
struct ConversationHandle {
id: ConversationId,
// Private constructor — only obtainable through Workspace methods.
}ConversationGuard
Combines a ConversationHandle with a ConversationLock (Phase 3c). Required for mutable access to conversation state. Derefs to ConversationHandle so read methods work transparently.
/// Proof of existence AND exclusive write access.
struct ConversationGuard {
handle: ConversationHandle,
lock: ConversationLock, // holds the flock; added in Phase 3c
}
impl Deref for ConversationGuard {
type Target = ConversationHandle;
fn deref(&self) -> &ConversationHandle { &self.handle }
}Workspace API
impl Workspace {
/// Scan conversation IDs from disk. No loading.
fn conversation_ids(&self) -> Vec<ConversationId>;
/// Acquire a handle to a conversation, proving it exists in the index.
///
/// Does not load metadata or events from disk — those are loaded
/// lazily when accessed via `events()`, `metadata()`, etc.
/// Returns an error if the ID doesn't exist. Only one handle should
/// exist per ID at a time (debug-asserted).
fn acquire_conversation(
&self,
id: &ConversationId,
) -> Result<ConversationHandle>;
/// Create a new conversation and return a handle to it.
fn create_conversation(
&mut self,
conversation: Conversation,
config: Arc<AppConfig>,
) -> ConversationHandle;
// Read access (handle required, infallible)
fn events(&self, h: &ConversationHandle) -> &ConversationStream;
fn metadata(&self, h: &ConversationHandle) -> &Conversation;
// Write access (guard required, infallible)
fn events_mut(&mut self, g: &ConversationGuard) -> &mut ConversationStream;
fn metadata_mut(&mut self, g: &ConversationGuard) -> &mut Conversation;
// Lifecycle operations
/// Remove a conversation. Consumes the guard, releasing the handle
/// and lock. The borrow checker prevents use-after-remove.
fn remove_conversation(&mut self, g: ConversationGuard) -> Conversation;
/// Persist a specific conversation to disk.
fn persist_conversation(&mut self, h: &ConversationHandle) -> Result<()>;
/// Persist all modified conversations.
fn persist(&mut self) -> Result<()>;
}Methods that take &ConversationHandle are infallible — the handle is proof the conversation exists. The implementation uses an internal expect that can only fire if the workspace implementation has a bug (the handle invariant is violated).
events_mut and metadata_mut require &ConversationGuard (which derefs to &ConversationHandle), enforcing that write access requires both existence proof and the file lock. Read-only methods accept &ConversationHandle directly, so they work with either a bare handle or a guard.
remove_conversation consumes the ConversationGuard by value. After the call, the handle is moved and the borrow checker prevents any further use. This provides compile-time protection against use-after-remove.
NOTE
Before Phase 3c adds ConversationLock, ConversationGuard can be a type alias for ConversationHandle, or the guard can hold a placeholder lock field. This allows the API shape to stabilize before locking is implemented.
Command Implementations
| Command | conversation_need | Notes |
|---|---|---|
jp query (no flags) | Session | Reads session mapping, picker fallback |
jp query --new | New | Always creates fresh |
jp query --id=<id> | Explicit(id) | Direct targeting |
jp config show | None | No conversation needed |
jp conversation rm <id> | Explicit(id) | Targets specific conversation |
jp conversation ls | None | Lists all, no active needed |
jp conversation use <id> | None | Writes session mapping only |
jp conversation fork | Session or Explicit | Reads source conversation |
jp attachment ls | None | No conversation needed |
References
flock(2)— POSIX advisory file locking used for conversation locks.LockFileEx— Windows equivalent, used viawindows-sys.getsid(2)— POSIX function for obtaining the session leader PID.
Platform Portability
| Concern | Unix | Windows | Rust crate |
|---|---|---|---|
| File locking | flock | LockFileEx | libc / windows-sys |
| Session identity | getsid(0) | GetConsoleWindow() HWND | libc / windows-sys |
| Session env vars | $TMUX_PANE, etc. | $WEZTERM_PANE, etc. | std::env |
The ConversationFileLock abstraction in jp_storage::lock hides the platform-specific locking mechanism. We use libc and windows-sys directly rather than fd-lock to avoid RwLockWriteGuard lifetime issues and a windows-sys version conflict. Session identity uses the three-layer resolution described above, which works on both platforms.