RFD 087: Session-Scoped Active Workspace
- Status: Accepted
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-01
- Extends: RFD 020
- Tracking Issue: #793
Summary
Each terminal session can select an active workspace with jp w use, after which jp q runs against it from anywhere without --workspace, the same way RFD 020 gives each session an active conversation. This also decides which checkout a command acts on when RFD 031 maps one workspace ID to several git worktrees.
Motivation
RFD 031 makes every git worktree of a repository share one user-local store keyed by workspace ID, which fixes durability and the directory-collision crash. It leaves one question unanswered: when a workspace ID resolves to several checkouts on disk, which one does a command act on?
Today there is no good answer. --workspace=<id> is ambiguous across checkouts, and nothing targets a workspace from outside its directory at all, so running jp always means first cd-ing into the right tree. Do nothing and that stays true: worktree users keep navigating by hand, and the multi-checkout case has no defined behavior.
RFD 020 already solved the same shape of problem for conversations: each tab tracks its own active conversation, and the feature is widely relied on. Applying that model one level up gives each tab an active workspace, so jp q works from anywhere and the multi-checkout ambiguity becomes an explicit, per-session choice.
Design
Workflow
A fresh terminal has no active workspace, so jp behaves exactly as it does today: it operates on the workspace you are standing in.
To drive a workspace from anywhere, select one for the session:
$ cd ~/scratch
$ jp w use ?
? Select a workspace
> jp ~/Projects/jp.git/my-feature
jp ~/Projects/jp.git/main
dotfiles ~/.dotfiles
$ jp q "summarize the last commit" # runs in ~/Projects/jp.git/my-featureThe choice is scoped to this tab, exactly like the active conversation in RFD 020: another tab can select a different workspace, and the two never interfere. jp w show reports the current selection and jp w use cwd drops it, falling back to cwd resolution.
The rest of this section describes how that resolution works.
Two-layer session model
Session identity is reused unchanged from RFD 020 (workspace-independent:
$JP_SESSION,getsid(0)/ console HWND, per-pane terminal vars).New global layer: session to active workspace, stored in a user-global session store at
~/.local/share/jp/sessions/<source-key>.json, above any<id>. This store mirrors RFD 020's session mapping shape: a most-recent-firsthistoryof selected workspaces (each entry records the workspace<id>, its resolved checkout root, and a timestamp), plus a session-levelstickyflag. The active workspace ishistory[0]; the previous one ishistory[1](thesession/starget — see Thejp workspacecommand surface). Recording the<id>(not just the root) is what makes recovery work after the root is deleted (see Reprompt on a missing active workspace). The filename encodes the full session source, not just its value:textgetsid-<pid>.json hwnd-<handle>.json env-<KEY>-<hash(value)>.json # e.g. env-JP_SESSION-9f86d0….jsonAn automatic
getsid/Hwndsession can never alias anEnvsession that shares the same numeric value, and two different env vars ($JP_SESSION,$TMUX_PANE) holding the same value get distinct files. Hashing the opaque env value also keeps unsafe characters out of the filename. This is the same source-encoding scheme RFD 020's per-workspace session store now uses (Session::storage_key); this RFD reuses that one encoder rather than defining a second so the two stores stay consistent. The blast radius of a collision at this layer is which workspace a command runs against, so the keys are kept disjoint by construction. Two tabs that deliberately set the same$JP_SESSIONstill share a record (same source, same key, same value), which stays the supported way to link sessions.Existing per-workspace layer is unchanged: session to active conversation at
<id>/sessions/<session-key>.json.Composition for
jp qfrom anywhere: resolve session to active workspace (global), enter it (see Execution context below), resolve session to active conversation (per-workspace), run.
Startup ordering
Selecting a workspace by ID happens before a Workspace exists, which inverts today's startup: run_inner loads the workspace first, then resolves session identity. A dedicated bootstrap step in jp_cli owns the pre-workspace resolution:
- Resolve session identity (
session::resolve). - Inspect cwd for a workspace, if any.
- Read the user-global session store for this session's active workspace.
- Read the per-workspace roots registries under the user data directory.
- Choose a concrete checkout root (see Precedence).
- Only then construct
Workspace, load config, load the conversation index, and run the command.
User-data scanning and root selection stay in this jp_cli step. jp_workspace::Workspace keeps managing an already-selected workspace and gains no awareness of how the root was chosen.
The bootstrap step resolves the selection once — launch cwd, selected workspace root, child cwd, resolution source, and the session (if any) — and passes those values explicitly to its consumers (workspace construction, config loading, MCP and plugin spawns, local tools, and user-typed path parsing). They are not re-derived from the process cwd at each call site, which is what keeps the launch-cwd / root / child-cwd distinction from collapsing again.
Workspace bootstrap requirement
Not every command needs a workspace selected, so each command declares its requirement — the workspace-level analog of today's per-command conversation_load_request (jp_cli::cmd). The bootstrap step reads this declaration and only runs the resolution ladder (steps 5–6 above) when the command asks for it:
- none — no workspace is bootstrapped.
jp w lsreads the user-global registries only;jp w use cwdjust clears the session record;jp initis unchanged. - resolve — resolve and validate a target root to record a selection, without loading the conversation index.
jp w use ?andjp w use <id>need the root, not the conversation data. - load — resolve, construct
Workspace, and load the conversation index.jp q,jp w show, and most existing commands.
The bootstrap handoff therefore has a no workspace selected form. For none commands — and for resolve / load commands that legitimately resolve to no workspace, such as jp w show from outside any workspace with nothing active — the downstream consumers that assume a root (config loading, MCP / plugin child cwd, path parsing) simply do not run. This makes "absence of a selected workspace" a first-class bootstrap outcome rather than something each command has to fake.
Bootstrap storage ownership
The two new stores are owned by this jp_cli bootstrap step, not by Workspace. Today Workspace::cleanup_stale_files requires an FsStorageBackend bound to the selected workspace, only touches that workspace's lock files and conversation session mappings, and runs at the end of a command once a workspace exists. That owner cannot reach a user-global record before any workspace is selected, and it never sees roots for workspace IDs that were not selected this run, so it cannot own the new stores.
Ownership therefore splits:
- Workspace cleanup (unchanged): conversation locks and per-workspace conversation session mappings, for the selected workspace.
- Bootstrap cleanup (new): the user-global active-workspace session records and roots-registry pruning. It runs before workspace selection (read the active workspace, validate the candidate roots, prune dead ones) and after the command (upsert the current root, drop the global session record when its source is dead).
Pruning dead roots is opportunistic: the bootstrap prunes the roots it inspects while resolving a selection, rather than scanning every known workspace on every run.
Execution context: launch cwd, workspace root, and child cwd
A from-anywhere run has to keep three directories distinct, which coincide today only because JP normally runs from inside the workspace it operates on:
- launch cwd — where the user invoked
jp; the shell completed any relative path argument against this. - workspace root — the selected checkout root.
- child cwd — the working directory spawned MCP servers, plugins, and local tools inherit.
Whenever JP operates on a workspace whose root is not the launch cwd's own workspace — a session-active pick, an explicit -w <path> or -w <id>, or the fallback picker — the child cwd becomes the selected workspace root: config loading, MCP servers, plugins, and local tools all run as if launched from there. Today these use inconsistent bases (config and MCP/plugin spawns inherit the process cwd; local tools use workspace.root()), so without this invariant a from-anywhere run would mix contexts.
User-typed relative path arguments resolve against the launch cwd, not the workspace root. A user standing in ~/scratch who types jp q --attach ./foo.txt had the shell complete ./foo.txt against ~/scratch; silently re-rooting it at the workspace would surprise the user and, once shipped, become a contract nobody chose.
When JP is launched from inside a workspace, the child cwd is left unchanged, so a subdirectory's .jp.toml chain still loads as it does today.
Minimum path behavior for this RFD. Resolving against the launch cwd does not relax the existing workspace-containment rule: a user-typed relative path that resolves outside the selected workspace errors exactly as it does today. This RFD adds no new handling — mounting, snapshotting, or external file:// resources — for outside-workspace paths; that is left to the deferred path RFD. The from-anywhere flows this RFD targets (jp q "…", attachments that live inside the selected workspace) are unaffected; only attaching a path outside the selected workspace is deferred, and it fails closed.
The full per-input-class resolution model — exactly how every relative path, mount spec, and cwd-targeted config edit resolves, plus the containment policy for paths outside the workspace — is broader than this RFD and is deferred (see Non-Goals).
Roots registry (one workspace ID, many checkouts)
The single storage symlink is replaced by a roots registry that maps one workspace ID to its checkouts on disk. A shared read-modify-write file would have a lost-update race that can silently drop a checkout from the set, so the registry is instead a directory of per-root files, one per checkout, mirroring how sessions and locks already work:
~/.local/share/jp/workspace/<id>/roots/<root-key>.json<root-key>is a stable hash of the checkout's canonical path, so each checkout owns exactly one file and writes never contend.- Each run upserts only its own file, recording the canonical path and a
last_usedtimestamp. No file is read-modified-written by more than one checkout. - Liveness is derived, not stored: a root is live when JP workspace discovery from that path resolves a workspace whose loaded ID equals
<id>(in today's colocated layout, that means<root>/.jp/.id). A path that was deleted, or recreated as a different workspace, is not live, and its file is pruned by the bootstrap cleanup (see Bootstrap storage ownership). - These are plain files, so the Windows symlink-privilege requirement that the old
storagesymlink imposed does not arise.
// ~/.local/share/jp/workspace/<id>/roots/<root-key>.json
{ "path": "/Users/jean/Projects/jp.git/my-feature", "last_used": "2026-06-01T18:25:00Z" }The roots registry is workspace-scoped, living under the workspace's user-local silo directory (<slug>-<id>, located by ID suffix per RFD 031; the <id> shorthand in the paths above stands for that silo). The session store is user-global (under sessions/, mapping a session to its active workspace). These are deliberately separate, not one store doing two jobs.
Migration from the storage symlink
Today the user-local back-pointer to a checkout is a single storage symlink (<id>/storage → the checkout's .jp), and that symlink is how -w <id> resolves a root today. RFD 031 Phase 1 renames the user-local directory to <id> but does not touch this symlink, so RFD 087 owns its transition to the roots registry.
Migration is best-effort, not load-bearing. On first bootstrap for an <id>, if a legacy storage symlink exists, JP canonicalizes its target, verifies it still resolves to a workspace whose loaded ID equals <id>, and seeds roots/<root-key>.json from it; the symlink is then left in place (ignored) or removed. A dead or mismatched target is pruned. Beyond this one-time seed, roots are discovered organically — each checkout upserts its own file on every run — so a user who never re-enters a given checkout only loses -w <id> access to that checkout until they next run from it.
This migration depends on RFD 031 Phase 1 being implemented, not merely accepted (see Implementation Plan).
The jp workspace command surface
jp workspace is the canonical command; jp w is the visible short alias, mirroring jp conversation / jp c. Examples below use jp w for brevity.
jp w use <target>: select the session's active workspace, using the targeting grammar below.jp w use ?opens the picker (list known workspaces, expand each through the roots registry to its live checkouts, pick one).jp w useis interactive-only in all forms — includingcwd— because it mutates session state; scripts target withjp -winstead (see Non-interactive mode).jp w use cwd(short.): drop the session's active workspace and fall back to cwd resolution. This replaces a--clearflag — clearing is just selecting the cwd-derived workspace.jp w ls: list known workspaces and their checkouts, mirroringjp c ls.jp w show [<target>]: with no target, report the session's active workspace; with<target>(e.g.jp w show <id>), report that workspace — mirroringjp c show. The readout covers how it was resolved, whether the session is sticky to it, whether cwd is overriding it, the conversation count, and the active conversation (if any). The active conversation is per-<id>(the session mapping), shown once. The conversation count is the union by conversation ID across the user-local durable store and every live root's workspace.jp/conversations/, so it includes external (ext) conversations that live only in one checkout (see RFD 031).jp w show <id>therefore loads the conversation index (index only, not event contents) for each live root and deduplicates by ID — the one placeshowdoes a multi-root read, chosen so the count is accurate rather than cheap. When the target resolves to a single concrete root — a path, or an<id>with one live root — the readout shows that root. When an<id>has several live roots,jp w showlists every live root and marks the session-active one (if any); it does not prompt, soshowstays read-only and script-friendly.jp -w <target>: a per-command workspace override using the same targeting grammar. It selects the workspace for this invocation only — it does not change the session's active workspace. A bare<target>is treated as a path if it resolves to an existing path, otherwise parsed as a workspace ID, so a local directory whose name matches a workspace ID shadows the ID. When an ID has one live root, JP uses it; when it has several, interactive runs prompt and non-interactive runs fail with the candidate roots listed.
Workspace targeting grammar
jp w use and jp -w share a WorkspaceTarget grammar modeled on ConversationTarget (jp_cli::cmd::target), reusing the same keywords and single-letter aliases for the concepts that carry over:
| Target | Meaning |
|---|---|
<id> | a literal workspace ID |
| free text | fuzzy-match known workspaces by slug / path / ID → picker (slug is cosmetic) |
? | pick from all known workspaces |
?s, ?session | pick from this session's workspace history |
s, session | the session's previously active workspace (like cd -) |
l, latest | the live root with the newest last_used across the roots registry (global recency, distinct from s / session) |
cwd, . | the cwd-derived workspace; as a use target, clears the session selection |
- | read a workspace ID from stdin (for jp -w in non-interactive use) |
help | print keyword help and exit |
Keywords with no workspace meaning are intentionally omitted: newest / n (workspaces have no creation timeline), the pinned / p family (workspaces have no listing-pin — see Relationship to jp conversation), and the archive keywords. The multi-target keywords (+session, +pinned, +archived) are omitted because both jp w use and jp -w select exactly one workspace; they can be added later under the same grammar if a multi-workspace command appears.
Session-history targets (s, ?s) operate on concrete history entries — workspace ID plus checkout root — not on the workspace ID alone. s restores the exact previously active checkout (cd - semantics), not the previous workspace re-resolved against its roots; multiple roots of the same ID are distinct history entries.
The picker and fuzzy free-text match display each workspace by its slug — the <slug> in the user-local silo directory <slug>-<id> (see RFD 031), the workspace directory name captured when the silo was first created. The slug is cosmetic: it may be absent (a bare <id> silo, in which case the <id> is shown), is never renamed, and is not unique across workspaces. Fuzzy free-text matches over slug, path, and ID, but resolution is always by ID and concrete root — never by slug — so a shared or stale slug affects only display and search, never which workspace a command runs against.
Precedence and the cwd-vs-active conflict
Interactive ladder, in order:
- Explicit
-wwins. - Else, if the session is sticky to its active workspace (
A, below) and that workspace is still live, use it — even when cwd resolves to a different workspace. - Else, if a session-active workspace is set and cwd resolves a different workspace (or a different checkout of the same ID), prompt.
- Else cwd wins when present.
- Else use the session-active workspace when live.
- Else picker.
The conflict prompt fires on any difference (different workspace ID or a different checkout of the same ID):
How to proceed? [c/C/a/A/q]
c - use current workspace
C - use current workspace and make it session-active
a - use active workspace
A - use active workspace and keep the session sticky to it
q - quit without running commandA persists on the session record and makes the session sticky to the active workspace. It is interactive-only state, cleared with jp w use cwd (see Session store and cleanup).
Non-interactive mode ignores the session-active workspace entirely. Here "non-interactive" means JP cannot prompt the user, determined by the same promptability signal JP already uses elsewhere (plugin install, lock-timeout handling, tool-call prompts). This RFD does not pin that signal to a specific mechanism: RFD 049 is the eventual canonical definition (controlling-terminal availability rather than stdout being a TTY), and RFD 087 inherits whatever the shared signal resolves to as it evolves. Non-interactively, a workspace-consuming command (bootstrap load or resolve) runs from inside a workspace or with an explicit -w, and errors otherwise. The explicit -w accepts only concrete targets — a workspace <id>, a path, cwd / . (resolve from the invocation directory), or - (read an ID from stdin). Session-derived targets (s / session, ?s / ?session) and pickers (?, fuzzy free-text) are interactive-only: they resolve against hidden per-session state or need a prompt, so they error non-interactively. The read-only jp w ls and jp w show run non-interactively unchanged, since they report registry and resolved/stored state without prompting. jp w use is interactive-only in every form: it mutates session state, and a script returns to cwd behavior by not setting $JP_SESSION rather than by running jp w use cwd. This keeps scripts deterministic: they never depend on hidden per-session state.
No session identity
A session-active workspace is per-session state, so it needs a session identity ($JP_SESSION, getsid / Hwnd, or a per-tab terminal var; see RFD 020). Having no session identity is distinct from having no active workspace:
jp w usewithout a session identity errors, mirroringjp c use("No session identity available. Set$JP_SESSIONor run in a terminal with automatic session detection."). There is nothing to persist the selection against.jp qlaunched from outside a workspace without a session identity errors with guidance to pass-w. It does not fall back to a non-persisted one-shot picker: a choice that cannot be recorded would have to be re-made on every invocation, and RFD 020 already establishes that mappings are not persisted without a session identity. Scripts stay deterministic.
Reprompt on a missing active workspace
- If the recorded root no longer exists (worktree removed), recovery uses the
workspace_idstored alongside it: read the<id>, expandworkspace/<id>/roots/to its live checkouts, prune dead ones, and re-prompt among the remainder (one live root may be used directly). This is why the session record stores the<id>and not only the root — once the root is gone, the<id>cannot be recovered from<root>/.jp/.id. It mirrors howsession_active_conversationreturnsNoneand falls back to the picker when the active conversation is gone.
Session store and cleanup
The global session store mirrors RFD 020's session mapping: a most-recent-first history of selected workspaces (active = history[0], previous = history[1]), plus a session-level sticky flag and the session source. It is longer-lived than the per-workspace conversation mapping, so cleanup splits by session source:
// ~/.local/share/jp/sessions/getsid-12057.json
{
"history": [
{
"workspace_id": "...",
"root": "/Users/jean/Projects/jp.git/my-feature",
"selected_at": "2026-06-01T18:25:00Z"
}
],
"sticky": false,
"source": { "type": "getsid" }
}The sticky field is the persisted A state from the precedence ladder.
getsid/Hwnd: reuse RFD 020's process-liveness check. The mapping is removed when the originating process is confirmed dead.Env(including$JP_SESSION): process liveness is unknown, so cleanup is existence-based across the whole history, mirroring RFD 020'sEnvrule (which keeps a mapping while any referenced conversation still exists). A history entry is pruned only when itsworkspace_idhas no live root; the whole mapping is removed only when no history entry references a workspace ID with any live root. Keying cleanup off the workspace ID rather than the single active root is what lets the missing-root recovery flow run: when the active root is gone but its<id>still has other live checkouts, the mapping survives so recovery can re-prompt among them (see Reprompt on a missing active workspace).- A sticky
Achoice has no process bound forEnvsources, so it persists until itsworkspace_idhas no live root — not merely until the active root dies. If the active root is removed while a sibling checkout survives, recovery re-prompts among the remaining roots rather than dropping the sticky selection.
Relationship to jp conversation
jp workspace deliberately mirrors jp conversation so the two share a mental model. The session store reuses RFD 020's mapping shape (a most-recent-first history, active = history[0]); session identity, the getsid / Hwnd / Env source split, and the stale-cleanup rules are the same machinery; jp w use / ls / show mirror jp c use / ls / show; and the targeting grammar reuses ConversationTarget's keywords and single-letter aliases for the concepts that carry over (?, ?s, s / session, l / latest, -, help).
Two things genuinely diverge, by necessity:
- The cwd-vs-active conflict and the
stickyflag have no conversation analog. A conversation has no ambient, location-derived candidate competing with the session-active one; a workspace does (the tree you are standing in). The conflict prompt, the precedence ladder, and thestickypin exist only because workspaces are filesystem-rooted. Sticky here means "this session keeps using its active workspace even when cwd points elsewhere" — distinct from a Pinned Conversation, which marks a conversation as important in listings. They are different concepts and use different words on purpose. cwdis a workspace-only target.jp w use cwdclears the session selection and returns to cwd resolution; conversations have no cwd fallback, so the keyword (and its clearing behavior) is workspace-specific.
Conversely, the multi-target keywords (+session, +pinned, +archived) and the listing-pin / archive keywords from jp c are absent here because the underlying concepts do not exist for workspaces; they are not a divergence to reconcile.
Drawbacks
- A new user-global session store and its cleanup pass. It reuses RFD 020's process-liveness check for
getsid/Hwndsources but needs a distinct rule forEnvsources (see Session store and cleanup). - Cold-start double prompt: a fresh session run from nowhere prompts for a workspace, then a conversation. Acceptable for v1; optimization deferred.
- The conflict prompt adds a decision point, though only for users who have set a session-active workspace.
Alternatives
- Always cwd-wins, no session-active workspace. Rejected: never lets you run
jpfrom outside a workspace directory, the core goal. - Always session-active-wins over cwd. Rejected: silently runs against the wrong place when you
cdelsewhere. - A single global active workspace, not per-session. Rejected: breaks parallel tabs, the property RFD 020 users rely on.
- Multi-target symlink for the back-pointer. Not a filesystem primitive; the registry file models one-to-many natively.
Non-Goals
- Git awareness. Consistent with RFD 031, JP does not inspect worktree topology.
- Attachment portability across checkouts. Continuing a conversation in a different checkout can break path-relative attachments. Once RFD 065's snapshot-at-attach model lands it will capture attachment content at attach time and stop re-resolving it on later runs, which would remove most of this risk. Until then, continuing a conversation from a different checkout can re-resolve path-relative attachments against the selected checkout. Either way, the precedence ladder and conflict prompt above are a guardrail, not a guarantee; RFD 065 is not a prerequisite for this RFD.
- Re-embedding the workspace ID in conversation IDs. That regression fix is a separate, smaller change. It is complementary (visible IDs make
jp wandjp -wdiscoverable) but not required for this design to function. - A global-local workspace for use outside any project. Issue #144 asks for JP to work anywhere by falling back to a "global local" workspace when the user is outside every project workspace. This RFD does not implement that — it only lets commands launched outside a workspace target an existing workspace selected by session state, the picker, or
-w. - A unified path-resolution model. Defining, per input class, how every relative path argument, mount spec, and cwd-targeted config edit resolves across launch cwd / workspace root / child cwd is broader than this RFD and intersects the "use JP outside a workspace" work (issue #144). This RFD commits only to two rules: child processes run with the workspace root as their cwd, and user-typed relative path arguments resolve against the launch cwd. The rest belongs in a dedicated RFD.
- Cross-machine sync.
Risks and Open Questions
- A workspace ID with no live checkouts (every worktree removed) cannot be entered by
jp -w <id>orjp w use. The intended behavior is an error pointing the user at a checkout; confirm that is sufficient. - Root-key derivation must be stable across runs and collision-resistant for distinct canonical paths. A hash of the canonical path is the intended approach.
Implementation Plan
Phase 1: Roots registry and -w <id> resolution
Replace the storage symlink with the per-root registry directory and resolve -w <id> against it: one live root, use it; many, picker/error; none, error. Derive liveness via the same-ID check. Seed the registry from any legacy storage symlink on first bootstrap (see Migration from the storage symlink).
Depends on: RFD 031 Phase 1 implemented (not merely accepted). This phase only adds ID-to-root resolution and changes no session state, so it can be merged independently of the session layer.
Phase 2: Startup boundary and execution context
Move session resolution ahead of workspace construction and add the jp_cli bootstrap step that selects the root. Establish the root-as-working-directory invariant for from-anywhere runs. Add the per-command workspace bootstrap requirement (none / resolve / load), the analog of conversation_load_request.
Depends on: Phase 1.
Phase 3: User-global session store and jp w surface
Add the user-global session store (history-shaped, mirroring RFD 020) and the jp w commands (use ?, use cwd, ls, show) with the shared workspace targeting grammar, plus jp q-from-anywhere resolution and the source-split cleanup rules.
Depends on: Phase 2.
Phase 4: Precedence ladder, conflict prompt, and reprompt
Implement the interactive ladder, the cwd-vs-active prompt, the persisted A sticky state, the non-interactive rule, and reprompt-on-missing-root.
Depends on: Phase 3.
References
- RFD 020: Parallel Conversations, the session identity and history model this RFD extends.
- RFD 031: Durable Conversation Storage with Workspace Projection, the shared user-local store and migration this RFD requires.
- RFD 065: Typed Resource Model for Attachments, whose snapshot-at-attach model bounds the attachment-portability concern noted in Non-Goals.
- RFD 049: Non-Interactive Mode and Detached Prompt Policy, the eventual canonical definition of "interactive" this RFD's local rule defers to.