RFD 113: Context Decomposition for Invocation and Workspace Scope
- Status: Accepted
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-09-08
- Extends: RFD 087
- Required by: RFD 114
- Summary: Splits the CLI context into invocation and per-checkout workspace halves, resolving turn config per checkout through one pipeline.
Summary
jp_cli::Ctx holds invocation state (terminal, printer, signal router, runtime) and workspace state (workspace, config, storage backend, MCP client) in one struct that every command borrows mutably. This RFD splits it into CliCtx and WorkspaceCtx, routes every turn's configuration through the one config pipeline for the checkout it runs in, and produces the inventory of which capabilities belong to the application rather than to a frontend.
Motivation
Ctx was shaped when one jp run meant one workspace and one terminal. Neither still holds.
The plugin host already broke "one terminal": a delegated turn renders to Printer::sink() and pins its interrupt actions to Stop, because nobody is watching the host's terminal. It is about to break "one workspace": a plugin serving a workspace switcher needs one bundle of workspace state per checkout it addresses, and exactly one printer, signal router, and runtime for the process. The unit is the checkout, not the workspace ID: two git worktrees of one repository share a workspace ID (RFD 087) but hold different files, config, and projected conversations, and a turn has to run in the right one. Holding several Ctx values would duplicate the second set; threading extra parameters through every handler would leave the two scopes entangled and the duplication implicit.
The second consumer is the application-layer extraction. Moving the non-frontend half of jp_cli into its own crate requires knowing which side of the boundary each concern sits on. Today that answer is not written down anywhere, and some of it is already diverging: query-draft handling exists twice. jp_cli::cmd::query resolves the draft path, fingerprints it, and removes it conditionally; jp_cli::cmd::plugin::dispatch resolves the same path again with its own create branch and applies a different conflict policy. Neither lives anywhere a non-terminal frontend can reach.
If nothing changes, the workspace switcher is implementable only by threading parameters around a god object, and the crate extraction begins by guessing at its own surface.
Design
This RFD is written against the tree with the in-flight plugin-host work landed: delegated turns, query drafts over the protocol, and list_configs.
Two scopes
Ctx becomes two types. Every field moves to one side, except where noted below.
| Field | Scope | Note |
|---|---|---|
term, printer | invocation | one terminal per process |
runtime | invocation | one runtime per process |
config | invocation | the bootstrap resolution, kept whole; see below |
signals | invocation | one signal router per process; reads the invocation's interrupt.* |
session | invocation | terminal identity; its per-workspace mappings are read through WorkspaceCtx |
mcp_client | invocation | one pool of server instances per process; see below |
exec | workspace | launch cwd, selected root, child cwd (RFD 087) |
workspace | workspace | |
fs_backend | workspace |
Commands take both. A multi-workspace plugin host holds one CliCtx and a map of WorkspaceCtx keyed by checkout — a workspace ID and a root — because exec holds exactly one selected root.
config_reset and task_handler do not move cleanly to one side; see Fields that straddle.
Configuration is resolved per operation
config stays on CliCtx as the whole AppConfig the bootstrap resolves, as Ctx holds it today: loaded through RFD 087's execution context — from the launch directory when it is inside the selected checkout, and from the selected checkout's root otherwise — with the environment and --cfg applied, and from the user-global layers alone when no workspace is selected. jp -w B run from inside workspace A therefore takes B's plugin admission and options, as it does now. It governs the process — plugin admission and options, the Ctrl-C escalation cooldown, output format, editor settings — and for a CLI command it is also the configuration the command runs under, conversation layer included. It is resolved once at startup and does not change when a host opens another checkout. Signal handling follows from this: it is scoped to the launched jp process and configured by the workspace that launched it, so a plugin can send and receive signals but cannot configure them.
A turn a host runs on a plugin's behalf does not run under it. It resolves its own configuration through ConfigPipeline for the checkout it runs in: that checkout's files and environment as the base, loaded from its exec.config_cwd() (the launch directory for the checkout jp was launched in, the checkout root for any other), the conversation's stored configuration as the per-conversation layer, then build. This is the path jp query takes at startup. A conversation pins its configuration, so an existing conversation resolves as it does today; the base only supplies fields the conversation never set. The --cfg arguments jp was launched with govern the invocation, not the turns a host delegates, which already do not read them.
WorkspaceCtx therefore holds the pipeline's inputs — workspace, fs_backend, exec — and no resolved configuration. A host that stays up for hours reads a checkout's files when a turn needs them, not when the checkout was first opened. The resolved configuration is passed to what reads it (turn preparation, and the tool service that leases the turn's MCP servers), so Ctx::swap_config, which installs a turn's configuration into the shared context and swaps the previous one back, is deleted.
Today the host assembles delegated-turn configuration itself: from load_base_partial and build_partial_over for a new conversation, and from the stored stream alone for an existing one, because the pipeline lives with the caller that owns startup. Putting its inputs on WorkspaceCtx gives the host the same pipeline the CLI uses, and one resolution path instead of two.
MCP servers are leased per turn
mcp_client is one pool per process. It holds running server instances keyed by server name, the configuration the instance was started with, and the directory it was spawned in, so the pool decides what is shared, not the checkout a turn happens to run in.
A turn's tool service acquires the servers its tools name, passing the configuration its turn resolved and its checkout's spawn directory:
- A running instance with the same key is leased to the turn.
- With no match, the pool starts a new instance, alongside any running under a different configuration or directory. A stdio server is a child process of its own, as it already is when two
jp queryruns in two terminals start the same server. - The service releases its leases when the turn ends. An instance nobody leases is eligible to stop; how long a long-running host keeps one idle is the host's policy.
Acquiring a lease is asynchronous and cancellable, so a later policy can make a turn wait for an instance without changing the interface.
Today a host shares one client across turns and starts servers by name: a running server is reused whatever configuration started it. A conversation pins its configuration, providers.mcp included, so two conversations whose personas configure the same server differently share whichever instance started first, and the second one's tool results come from a server its recorded configuration does not describe. Keying by configuration removes that. Counting leases also removes the host's guess at whether another turn is using a server (McpServerScope), which it makes today from its own list of running turns.
A caller that names no workspace reads what it needs from CliCtx and constructs no WorkspaceCtx. That removes the need for a bootstrap level producing a workspace-shaped result with no workspace in it — though WorkspaceRequirement (RFD 087) does conflate two questions today, since None means "no workspace and no config" by returning before the config pipeline runs. Untangling them belongs with the first caller that needs config without a workspace.
Fields that straddle
config_reset is an invocation-scoped input applied to a workspace's conversation stream. It exists for the CLI's --cfg reset keywords (RFD 038) and has no meaning for a caller that names no single workspace. It lives on CliCtx and is refused for a multi-workspace caller.
task_handler has an invocation-scoped lifecycle and a workspace-scoped destination. One JoinSet and one cancellation token per process is right — Ctrl-C stops all background work — but TaskHandler::sync takes a single &mut Workspace and hands it to every drained task, and TitleGeneratorTask::sync silently returns success when its conversation is not in the workspace it was given. Drained against the wrong workspace, a generated title is dropped with no diagnostic.
It lives on CliCtx, and the silent skip becomes a warning, which is the difference between a wrong result and a visible one. The destination problem is real but only reachable once a host addresses several workspaces: whether to hold one handler per workspace, or one handler whose tasks carry their own destination, is for the RFD that introduces the registry. The single-workspace path is unaffected either way.
What belongs to the application, and what to a frontend
The placement question for every capability is whether all three frontends — terminal, IPC, FFI — would need it.
| Capability | Application | Frontend |
|---|---|---|
| Conversation read, write, lock | yes | |
| Query drafts, revision conflict | yes | |
| Config resolution and delta recording | yes | |
| Workspace enumeration and addressing | yes | |
| Turn execution | yes | |
| Typed partial-config assignment | yes | |
| ANSI rendering, streaming markdown | terminal | |
KeyValueOrPath argv grammar and clap | terminal | |
| JSON-lines framing, correlation IDs | IPC | |
catch_unwind, C strings, handle table | FFI |
The last application row is the correction the inventory forces. PluginToHost::Query carries cfg: Vec<String> parsed by the CLI's KeyValueOrPath, so the IPC frontend consumes a terminal argument grammar, including the NONE and WORKSPACE keywords whose payload is CLI-only. A typed replacement is not a partial: one --cfg argument resolves to several entries across search roots applied in precedence order, an entry can declare loader.reset = "none" which becomes a reset point rather than a value, and RFD 070 distinguishes a named source from an anonymous assignment so that a later -C can revert it. Rather than design that surface here, the field goes away (see Phase 3) and returns when the typed form is designed.
The inventory records where each capability belongs, so the later extraction is a move rather than a redesign. Where a capability is already on the wrong side, this RFD moves it — but only the two Phase 3 names, not as a general licence.
Migration mechanic
Ctx becomes a shell holding the two halves (ctx.cli, ctx.workspace) and consumers migrate incrementally, one call path at a time. The shell is deleted when the last consumer stops using it. This keeps each step small and reviewable at the cost of both shapes existing during the migration.
The plugin dispatch path migrates first, because a multi-workspace plugin host depends on WorkspaceCtx existing and not on the rest of the CLI having moved.
Drawbacks
Churn proportional to how pervasive
Ctxis. Every command signature changes. The work is mechanical and compiler-checked, but the diff is large and touches files this RFD has no other reason to open.Two shapes in flight. During the migration, some call paths take the shell and some take the halves. A reader encountering the codebase mid-flight sees an inconsistency with no local explanation.
WorkspaceCtxis not the library seam. It holds the inputs to config resolution for the turn path, which also needs the runtime and the MCP pool onCliCtx, so it is not a type an FFI consumer can hold. It is the frontend-agnostic application bundle, and the narrower reader surface stays injp_workspace,jp_conversation, andjp_storage.The inventory is a claim, not a proof. It is checked by the extraction that follows, which is where a wrong row surfaces.
Alternatives
Leave Ctx and thread parameters. The plugin host takes the workspace bundle as separate arguments per handler. This works for one caller and leaves the two scopes entangled for everything else; the duplication a multi-workspace host must avoid stays implicit, enforced by review rather than by types.
Hold several Ctx values. Rejected: each carries a printer, a signal router, and a runtime, of which the process needs exactly one.
Two resolved configurations. An invocation bundle of already-resolved namespaces (plugins, interrupt, …) on CliCtx, and a resolved AppConfig per checkout on WorkspaceCtx. Rejected: no turn runs under a checkout's AppConfig, because a turn runs under its conversation's configuration; the bundle is a new concept that forces a namespace-by-namespace allocation; and the host's own resolution path would remain alongside it, with swap_config to bridge the two.
Extract the application crate first, and split as a consequence. The split is the mechanical part and the crate boundary is the irreversible one. Doing the reversible work first means the boundary is drawn from a written inventory rather than from whatever the compiler accepted.
Non-Goals
Crate extraction. Both halves stay in
jp_cli. The application crate comes later, informed by this RFD's inventory.The output and interaction inversion. Making the turn loop emit events that frontends render, rather than writing to a
Printer, is the largest piece of the eventual architecture and is its own RFD. This one only records that rendering is a frontend concern.Agent loop extraction. RFD 026's territory, and it follows the output inversion rather than preceding it. Note that 026 as written extracts the loop while retaining
Printerand movesToolRendererintojp_agent, which this RFD's inventory puts on the frontend side; 026 needs revision if the inversion lands first. Neither gates the other.Multi-workspace addressing. This RFD makes it implementable; the protocol surface, the host's per-checkout registry, and where background tasks deliver their results in it are separate work.
Authentication and authorization. The plugin protocol grants a plugin whatever the host can reach, and the web frontend binds an unauthenticated port. That is accepted for internal use and addressed when the frontend is solid enough to warrant it.
Risks and Open Questions
Derived state is where a silent behavior change hides.
Ctx::newcomputesescalation_cooldown, the MCP spawn directory, and the output width from its inputs. A split that recomputes any of them from a different side changes behavior with no test failing. Each derivation needs an explicit home and a test asserting the value, not just the wiring.Resolution cost per turn. Each delegated turn reads its checkout's config files and
extendschain. That is what a new conversation already costs the host, and what everyjp querycosts at startup; a cache is a measured follow-up, and would have to stay fresh against file edits.Servers with exclusive state. A server that keeps state only one instance can hold — a database file, a browser profile, a fixed port — conflicts with a second instance of itself. Two terminals already produce that conflict; the pool makes it reachable inside one host. Declaring such a server exclusive, and making turns wait for it across processes, is separate work on MCP server lifecycle.
sessionmay not be cleanly invocation-scoped. The identity is, but every read of a session's active conversation goes through a workspace's mapping store. IfCliCtxholding the identity forces workspace-shaped lookups back through it, the field belongs on both or the lookup belongs elsewhere.Whether the inventory is complete. It is drawn from the capabilities that exist today. A capability added between this RFD and the extraction lands on whichever side its author picked, unless the placement question is asked in review.
The
cfgrefusal has no natural end. The protocol has no floor below which the host refuses a plugin, so the refusal stays until one exists or the protocol is renumbered with a clean break.
Implementation Plan
Phase 1: The two types, and the plugin dispatch path
- Introduce
CliCtxandWorkspaceCtxwith the field allocation above. Ctxbecomes a shell holding both.- Resolve a delegated turn's configuration through
ConfigPipelinefor its checkout, pass it to turn preparation explicitly, and deleteCtx::swap_config. - Key MCP server instances in
jp_mcp::Clientby server, configuration, and spawn directory; lease them to a turn's tool service and route tool lookups and calls through the lease; deleteCtx::configure_active_mcp_servers, the per-turnset_servers, andMcpServerScope. - Warn instead of silently skipping when a background task's conversation is not in the workspace it was drained against.
- Migrate
run_external,run_plugin,message_loop, and every plugin request handler to take the two halves. - Assert each derived value (escalation cooldown, child cwd, output width) in a test.
Mergeable independently. Unblocks multi-workspace addressing.
Phase 2: The remaining commands
- Migrate every other command off the shell, one group at a time.
- Delete the shell.
Depends on Phase 1. Each command group is independently mergeable.
Phase 3: The inventory, and the placements it forces
- Record the application/frontend split as a living document under
docs/architecture/. - Consolidate query-draft handling into the storage layer: one path resolution instead of the two in
cmd/query.rsandcmd/plugin/dispatch.rs, reachable byjp queryand an FFI consumer alike. The two conflict policies guard different operations — the CLI refuses to delete a draft that changed since the request was composed, the plugin refuses to write over one whose revision moved — and both are kept. - Remove
cfgfromPluginToHost::Queryandlist_configsfrom the protocol surface, keeping both deserializable. A query carrying a non-emptycfg, and anylist_configsrequest, is answered with a correlated error telling the user to update the plugin, and no turn starts. A delegated turn otherwise runs under the conversation's stored configuration. The host's version check only refuses a plugin that needs a newer host, andQueryRequestignores unknown fields, so without the refusal an older plugin's configuration selection would be dropped and its turn would succeed under different settings. A removed configuration selection must never become a successful query. The typed replacement takes a new field name, so an older plugin's string list is never read as the new form.
Depends on Phase 1. Each item is independently mergeable and is a behavior change worth its own review.
Removing cfg costs the web frontend its persona and skill selection until a typed config-assignment surface replaces it; the CLI is unaffected. The draft consolidation lands in jp_storage and jp_workspace and is held to their standard, not the protocol's.
References
- RFD 087 — Session-scoped active workspace; defines
WorkspaceRequirement, the execution context, and the roots registry. - RFD 072 — Command plugin system; defines the protocol whose host this RFD's first phase reshapes.
- RFD 099 — Native macOS app; the FFI consumer whose constraints define the narrow reader surface.
- RFD 026 — Agent loop extraction; the later boundary this RFD's inventory informs.
- RFD 048 — Four-channel output model, and RFD 012 — typed streaming events; the existing groundwork for treating rendering as a frontend concern.
- RFD 038 — Config reset keywords; the origin of
config_reset. - RFD 070 — Negative config deltas, and RFD 060 — config explain; both consume the canonical form of a config assignment.