Goal
Trying Paddock, or using it day to day, must not put a user's Claude Code state at risk — and that should be a guarantee they can read off a config file, not a property they have to infer.
Today it isn't. Three separate incidents in one week (#682 data loss, #683 invisible login, #689 delete destroying real transcripts) all trace to the same root: Paddock has one lever — which Claude home it points at — and every distinct concern is welded to it. Fixing any one concern moves all of them.
This issue proposes splitting that into independent levers with one shared vocabulary.
The finding this rests on
Everything defaults to following CLAUDE_CONFIG_DIR, but almost every concern has its own escape hatch. Verified against the SDK bundle and herdctl source rather than inferred:
| Concern |
Default binding |
Independent lever |
Verified? |
Transcripts (projects/<enc>/*.jsonl) |
CLAUDE_CONFIG_DIR |
per-project symlink |
✅ in production use today |
| Keychain login |
whether CLAUDE_CONFIG_DIR is set |
CLAUDE_SECURESTORAGE_CONFIG_DIR |
✅ read from SDK source |
| MCP servers |
<config-dir>/.claude.json |
mcpServers SDK option |
✅ type + merge site read |
| Memory dir |
<claudeHome>/projects/<enc>/memory |
falls out of path shape |
✅ reproduced on this box |
| CLAUDE.md / agents / commands |
inside the home |
symlink bridge |
✅ shipped today |
Hooks (settings.json) |
inside the home |
symlink bridge |
✅ shipped today |
The SDK's own home resolver, for the record — this also settles that none of it is CLI-runtime-only:
function CR(){ return process.env.CLAUDE_CONFIG_DIR }
var Yt = re(() => (CR() ?? join(homedir(), ".claude")).normalize("NFC"), CR)
And the credential service name, which is what made the macOS login invisible:
function yz(e=""){
let t = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,
r = t !== undefined ? !t : !process.env.CLAUDE_CONFIG_DIR,
n = t !== undefined ? t.normalize("NFC") : Yt(),
o = r ? "" : `-${sha256(n).digest("hex").substring(0,8)}`;
return `Claude Code${OAUTH_FILE_SUFFIX}${e}${o}` // _z = "-credentials", suffix "" in prod
}
r is the whole story: CLAUDE_SECURESTORAGE_CONFIG_DIR overrides CLAUDE_CONFIG_DIR for secure storage only. Set it to "" and you get the plain-name entry — the user's real login — while Paddock keeps its own home. That decouples credentials from transcripts, which is the coupling #683 was.
It also confirms the service name is exactly Claude Code-credentials, which is what #686's darwin probe guesses. That was shipped flagged as unverified; it is now verified.
Proposal
A claude: block in paddock.config.yaml. Each key answers one question — whose X does this instance use? — with one vocabulary, so the guarantee is readable at a glance:
claude:
transcripts: own | host # default own
credentials: own | host # default host
mcpServers: own | host # default own
instructions: own | host # default own — CLAUDE.md, agents/, commands/
hooks: own | host # default own — settings.json hooks
own = Paddock's, isolated. host = this machine's Claude Code. own everywhere means nothing outside the data dir is read or written, which is the guarantee to be able to state plainly.
Values rather than booleans: mcpServers will plausibly want host:user / host:plugins scoping, and a boolean you later have to split is a migration.
Naming
transcripts is the noun the code already uses (transcripts.ts), it is the artifact actually at risk, and host makes the stake legible in a way "sync" does not — Paddock will write to your real files. Explicitly not mode: it is one axis of five, and naming it mode is how they got welded together in the first place.
Defaults, and the one exception
Everything defaults own. credentials defaults host: reading a Keychain entry puts no file at risk, and defaulting it own recreates #683 — a first run that boots fine and fails every turn. Isolation is about writes.
Per-lever implementation
transcripts: own — today's owned-home behaviour. <ownHome>/projects/<enc> → <project>/.chats/. Import copies; delete removes Paddock's copy.
transcripts: host — same owned home, symlink pointed the other way: <ownHome>/projects/<enc> → ~/.claude/projects/<enc>. One set of files, both directions. Verified working: an external append (what claude --resume does) showed up in Paddock as 4 messages instead of 2, with no restart and no re-import.
Note this is strictly better than 0.61.1, which achieves sharing by repointing the entire config dir and drags transcripts, memory and .claude.json along as collateral. Here it is one symlink per project, and memory keeps working because the literal path handed to the agent still has no .claude component.
Blocked on #689 — with shared files, UI delete removes real history. Inherent to the lever, not to the implementation.
credentials: host — set CLAUDE_SECURESTORAGE_CONFIG_DIR="". Keep #686's darwin probe for the diagnostic.
mcpServers: host — read the host ~/.claude.json, take top-level mcpServers plus projects[<cwd>].mcpServers, pass via herdctl's mcpServers?: Record<string, SDKMcpServerConfig> (merged at sdk-runtime.js:270; Paddock sets none today). Read-only and surgical.
Explicitly not by symlinking .claude.json: that file is mutable state Claude Code writes to (per-project trust, allowed tools, migration flags), so bridging it means Paddock writes into the user's real config. Against the goal.
injectedMcpServers is the wrong hook — that is for in-process JS tools like send_file, not external stdio/http servers.
instructions / hooks — split today's unconditional BRIDGED_ENTRIES. settings.json is a mixed bag (hooks and permissions, model, statusline), so this is not a symlink at hooks: own — Paddock has to write its own settings.json carrying the safe keys and dropping hooks. More work than a symlink, and the only honest way to offer "isolated".
Plugins: the SDK does not inherit them
Tested on a real work laptop: an installed Slack plugin's MCP server did not appear in Paddock. I had predicted it would, because both halves are bridged (plugins in BRIDGED_ENTRIES, enabledPlugins in settings.json). Wrong, and the reason matters:
/**
* Load plugins for this session. Plugins provide custom commands, agents, skills, and hooks…
* Currently only local plugins are supported via the 'local' type.
*/
plugins?: SdkPluginConfig[];
Plugins are per-session and must be passed explicitly. The interactive CLI auto-discovers installed plugins; the SDK does not, and Paddock runs the SDK. Bridging the files and the enablement flag gets you everything except the thing that loads them.
So mcpServers: host needs a plugin arm: enumerate the host's plugin dirs and pass plugins: [{type:'local', path}]. Marketplace-installed plugins do land on disk, so "local only" is probably not a blocker — prediction, not verified, and worth testing before anyone relies on it.
The gap this leaves: users declaring their own MCP servers
Paddock has nowhere to declare an MCP server. The existing opt-in/opt-out surface is for Paddock's own injected tools. A user who wants Notion in their Paddock — and who has no way to contact us — currently cannot get it. Options, not mutually exclusive:
- An
mcpServers: block in paddock.config.yaml, flowing through the same path as mcpServers: host. The real feature.
- Per-project
.mcp.json — Claude Code's own standard, checked into the repo, zero Paddock code. Caveat: approval lives in .claude.json (enabledMcpjsonServers), so headless approval needs checking.
CLAUDE_CONFIG_DIR=<paddock home> claude mcp add … — works today with no feature at all. Worth documenting regardless.
Authentication for those is an open question. I could not determine where MCP OAuth tokens persist. What is known is the shape of the answer: the SDK exposes mcpAuthenticate(serverName, redirectUri) and mcpClearAuth(serverName), so an authenticated server can be authorised from inside Paddock as a browser redirect flow. Worst case is "click to connect Notion once", not "impossible". Whether credentials: host also carries MCP tokens is the first thing to test.
Open questions
- Where does memory land under
transcripts: host? The literal path stays clean so writes work, but the files resolve into ~/.claude/projects/<enc>/memory. Consistent with sharing transcripts; also means Paddock writes agent memory into the user's home. Decision needed.
- Does Claude Code lock the JSONL? Alternating terminal/Paddock use is verified working. Simultaneous use of one session is unverified — and per @edspencer not an objective, so possibly just document it.
- Do MCP OAuth tokens follow
credentials: host? Above.
- Are marketplace plugins loadable as
type: 'local'? Above.
- Migration.
--here on 0.61.1 is transcripts: host + credentials: host. Existing instances need a mapping that does not silently change behaviour.
Relationship to other issues
Non-goals
- Simultaneous terminal + Paddock use of the same session.
- Making
host the default for anything that writes.
- Bridging
.claude.json wholesale.
Goal
Trying Paddock, or using it day to day, must not put a user's Claude Code state at risk — and that should be a guarantee they can read off a config file, not a property they have to infer.
Today it isn't. Three separate incidents in one week (#682 data loss, #683 invisible login, #689 delete destroying real transcripts) all trace to the same root: Paddock has one lever — which Claude home it points at — and every distinct concern is welded to it. Fixing any one concern moves all of them.
This issue proposes splitting that into independent levers with one shared vocabulary.
The finding this rests on
Everything defaults to following
CLAUDE_CONFIG_DIR, but almost every concern has its own escape hatch. Verified against the SDK bundle and herdctl source rather than inferred:projects/<enc>/*.jsonl)CLAUDE_CONFIG_DIRCLAUDE_CONFIG_DIRis setCLAUDE_SECURESTORAGE_CONFIG_DIR<config-dir>/.claude.jsonmcpServersSDK option<claudeHome>/projects/<enc>/memorysettings.json)The SDK's own home resolver, for the record — this also settles that none of it is CLI-runtime-only:
And the credential service name, which is what made the macOS login invisible:
ris the whole story:CLAUDE_SECURESTORAGE_CONFIG_DIRoverridesCLAUDE_CONFIG_DIRfor secure storage only. Set it to""and you get the plain-name entry — the user's real login — while Paddock keeps its own home. That decouples credentials from transcripts, which is the coupling #683 was.It also confirms the service name is exactly
Claude Code-credentials, which is what #686's darwin probe guesses. That was shipped flagged as unverified; it is now verified.Proposal
A
claude:block inpaddock.config.yaml. Each key answers one question — whose X does this instance use? — with one vocabulary, so the guarantee is readable at a glance:own= Paddock's, isolated.host= this machine's Claude Code.owneverywhere means nothing outside the data dir is read or written, which is the guarantee to be able to state plainly.Values rather than booleans:
mcpServerswill plausibly wanthost:user/host:pluginsscoping, and a boolean you later have to split is a migration.Naming
transcriptsis the noun the code already uses (transcripts.ts), it is the artifact actually at risk, andhostmakes the stake legible in a way "sync" does not — Paddock will write to your real files. Explicitly notmode: it is one axis of five, and naming itmodeis how they got welded together in the first place.Defaults, and the one exception
Everything defaults
own.credentialsdefaultshost: reading a Keychain entry puts no file at risk, and defaulting itownrecreates #683 — a first run that boots fine and fails every turn. Isolation is about writes.Per-lever implementation
transcripts: own— today's owned-home behaviour.<ownHome>/projects/<enc>→<project>/.chats/. Import copies; delete removes Paddock's copy.transcripts: host— same owned home, symlink pointed the other way:<ownHome>/projects/<enc>→~/.claude/projects/<enc>. One set of files, both directions. Verified working: an external append (whatclaude --resumedoes) showed up in Paddock as 4 messages instead of 2, with no restart and no re-import.Note this is strictly better than 0.61.1, which achieves sharing by repointing the entire config dir and drags transcripts, memory and
.claude.jsonalong as collateral. Here it is one symlink per project, and memory keeps working because the literal path handed to the agent still has no.claudecomponent.Blocked on #689 — with shared files, UI delete removes real history. Inherent to the lever, not to the implementation.
credentials: host— setCLAUDE_SECURESTORAGE_CONFIG_DIR="". Keep #686's darwin probe for the diagnostic.mcpServers: host— read the host~/.claude.json, take top-levelmcpServersplusprojects[<cwd>].mcpServers, pass via herdctl'smcpServers?: Record<string, SDKMcpServerConfig>(merged atsdk-runtime.js:270; Paddock sets none today). Read-only and surgical.Explicitly not by symlinking
.claude.json: that file is mutable state Claude Code writes to (per-project trust, allowed tools, migration flags), so bridging it means Paddock writes into the user's real config. Against the goal.injectedMcpServersis the wrong hook — that is for in-process JS tools likesend_file, not external stdio/http servers.instructions/hooks— split today's unconditionalBRIDGED_ENTRIES.settings.jsonis a mixed bag (hooks and permissions, model, statusline), so this is not a symlink athooks: own— Paddock has to write its ownsettings.jsoncarrying the safe keys and droppinghooks. More work than a symlink, and the only honest way to offer "isolated".Plugins: the SDK does not inherit them
Tested on a real work laptop: an installed Slack plugin's MCP server did not appear in Paddock. I had predicted it would, because both halves are bridged (
pluginsinBRIDGED_ENTRIES,enabledPluginsinsettings.json). Wrong, and the reason matters:Plugins are per-session and must be passed explicitly. The interactive CLI auto-discovers installed plugins; the SDK does not, and Paddock runs the SDK. Bridging the files and the enablement flag gets you everything except the thing that loads them.
So
mcpServers: hostneeds a plugin arm: enumerate the host's plugin dirs and passplugins: [{type:'local', path}]. Marketplace-installed plugins do land on disk, so "local only" is probably not a blocker — prediction, not verified, and worth testing before anyone relies on it.The gap this leaves: users declaring their own MCP servers
Paddock has nowhere to declare an MCP server. The existing opt-in/opt-out surface is for Paddock's own injected tools. A user who wants Notion in their Paddock — and who has no way to contact us — currently cannot get it. Options, not mutually exclusive:
mcpServers:block inpaddock.config.yaml, flowing through the same path asmcpServers: host. The real feature..mcp.json— Claude Code's own standard, checked into the repo, zero Paddock code. Caveat: approval lives in.claude.json(enabledMcpjsonServers), so headless approval needs checking.CLAUDE_CONFIG_DIR=<paddock home> claude mcp add …— works today with no feature at all. Worth documenting regardless.Authentication for those is an open question. I could not determine where MCP OAuth tokens persist. What is known is the shape of the answer: the SDK exposes
mcpAuthenticate(serverName, redirectUri)andmcpClearAuth(serverName), so an authenticated server can be authorised from inside Paddock as a browser redirect flow. Worst case is "click to connect Notion once", not "impossible". Whethercredentials: hostalso carries MCP tokens is the first thing to test.Open questions
transcripts: host? The literal path stays clean so writes work, but the files resolve into~/.claude/projects/<enc>/memory. Consistent with sharing transcripts; also means Paddock writes agent memory into the user's home. Decision needed.credentials: host? Above.type: 'local'? Above.--hereon 0.61.1 istranscripts: host+credentials: host. Existing instances need a mapping that does not silently change behaviour.Relationship to other issues
transcripts: hostto be shippable. Independent bug in 0.61.1 today.transcripts: host+credentials: host, which this proposes splitting.Non-goals
hostthe default for anything that writes..claude.jsonwholesale.