dsh-session-isolation
Per-session working directories for DSH — the Codex-style behavior, as an opt-in per project, switched from a page in the Web GUI, from a conversation, or from a marker file.
English | 中文
The problem
DSH gives every session the Workspace directory you picked, so two sessions in one project write into the same files. Run three agents on one folder and they overwrite each other's output: the second one "fixes" the file the first one just wrote.
What it does
While isolation is on for a directory, every new session created there works
in its own session-<id> subfolder. That subfolder — not the project — becomes
the session's workspace-write sandbox root, so parallel sessions cannot touch
each other's files, while the project itself stays readable to all of them.
project/ ← the Workspace you picked (readable to every session)
├── .dsh-isolate ← the switch (an empty file; delete it to turn isolation off)
├── src/ … ← the project's own files (read-only to sessions)
├── session-1a2b3c4d/ ← session A's sandbox root, private to it
└── session-9f8e7d6c/ ← session B's sandbox root, private to it
- Each folder is created before the session exists, and its name carries the
first eight characters of the session id (
-2,-3, … if two ever collide). - The session still belongs to the project in the Workspace sidebar, and
AGENTS.mddiscovery and project-skill lookup still work — both walk up from the session's cwd. - Sessions that already exist never move: isolation changes where the next session works, not where a running one does.
- Turning the switch off stops new private folders; sessions created while it was on keep their folders and stay accounted to the project.
Nothing in DSH is modified. The plugin mounts through the ordinary Cordis composition, wraps two seams at load time, and removes both when it unloads.
Install
No build step and no runtime dependencies beyond DSH itself. The repository
contains the Cordis entry, six lib/ modules, the browser half, the bundle
patch, and the test suite. Verified against DSH 0.1.6-alpha.2; Node.js 22+ is
required.
From GitHub
dsh plugin --profile <profile> add \
https://github.com/sweet-boby/dsh-session-isolation.git
Then restart the host once. The plugin manager installs the package, selects its
bundle, and applies cordis.patch.yml; the restart lets the
browser module table discover the new dsh.client bundle.
From the Web GUI
Open Plugins → Add plugin and paste the GitHub URL:
https://github.com/sweet-boby/dsh-session-isolation
If that DSH build's installer only accepts a local bundle path, clone the repository first and paste the checkout's absolute path instead.
From a local checkout
git clone https://github.com/sweet-boby/dsh-session-isolation.git
dsh plugin --profile <profile> add \
/absolute/path/to/dsh-session-isolation
Manual profile install
Add the dependency and select the bundle in
~/.dsh/profiles/<profile>/package.json:
{
"dependencies": {
"dsh-session-isolation": "github:sweet-boby/dsh-session-isolation"
},
"dsh": {
"profile": {
"bundles": ["…", "dsh-session-isolation"]
}
}
}
For local development, replace the dependency with
file:/absolute/path/to/dsh-session-isolation, then install in the profile:
cd ~/.dsh/profiles/<profile>
pnpm install --ignore-scripts
Restart the host after either installation path.
Requirements
- Node.js 22 or newer.
- DSH with the
sessionController,workspaceRegistry, andtoolsservices composed — every base-backed profile has them. A deployment missing one leaves the plugin pending instead of half-applied. - The Web carrier (
ctx.webServer) is optional: without it the plugin keeps the policy and the tool and simply serves no GUI page.
Turn isolation on
Isolation is a property of the directory, and its durable form is a
.dsh-isolate marker file inside it, so the setting travels with the folder and
a file manager can change it too.
- Web GUI — Settings → Built-in plugins → Workspace isolation
(
设置 → 内置插件 → 工作区隔离). One row per Workspace with its path, session count, and a single switch. The list also refreshes every five seconds, so a decision made anywhere else shows up here. - In a conversation — ask the agent to isolate the project. It calls the
session_isolationtool:status,list,enable,disable, with an optional absolutepaththat defaults to the calling session's workspace. - From host code —
await ctx.isolatedSessions.setIsolated(workspaceId, true)orctx.isolatedSessions.setIsolatedPath(path, true);list()andisIsolated(id)read the current state. - By hand — create an empty
.dsh-isolatefile in the project directory.
Verify it worked
Start a new session in that Workspace and ask it for its working directory
(pwd, or just have it write a file). The answer is a session-<id> subfolder,
and the project root appears in its file policy as a read-only location. Two such
sessions get two different folders.
The GUI page
The browser half is a hand-written client bundle (lib/client.js)
declared with dsh.client and exports["./client"], so the Web client module
table serves it like any packaged plugin bundle. It registers one
settings.plugins.tab entry through ctx.slots.inject, i.e. it waits for the
Settings section to declare that slot before registering — plugin activation
order is not stable, and registering into an undeclared slot fails the whole
browser boot.
An external plugin's browser half cannot declare a Remote namespace, so the two halves talk over two named routes on the Web carrier:
| Route | Method | Body | Answer |
|---|---|---|---|
/session-isolation/workspaces |
GET |
— | { workspaces: [{ workspaceId, title, path, isolates, sessionCount }], isolatedCount } |
/session-isolation/toggle |
POST |
{ workspaceId, enabled } |
the refreshed list, or { error } |
They hold no state of their own — every answer is projected from the marker files — and they are unauthenticated routes on the loopback-bound carrier, like the other plugin asset routes. A host without a Web carrier registers neither.
One restart is needed for the page to appear. The client module table scans
dsh.clientdeclarations at activation and caches its verdict per package, so a bundle that appeared after the host started is picked up on the next boot. Host-side edits reach a running host through configuration reload; a new client bundle does not. Until then, every other switch (tool, marker file, host service) already works.
How it works
Two seams, wrapped at load time and unwrapped on unload. Both delegate to the
original for anything they do not own, and both fail loud — … this DSH build moved it — rather than silently isolating nothing.
ctx.sessionController.create— the Remote entry point the GUI calls. DSH resolves a new session's cwd asworkspace.path ?? request.cwd ?? default, and the controller rejects a request carrying bothworkspaceIdandcwd, so the plugin points the named Workspace entity at a freshly allocated private directory for the duration of that one call. The durable record is untouched, the redirect is removed in afinally, and a failed creation deletes the folder it allocated. A mutation slot absorbs the entity's own writes during the redirect, so a session accounted in that call is never rolled back when the redirect is removed.- The Workspace registry's session-path index — the host object every
WorkspaceEntityreads (the registry builds one and shares it). A session directory inside an isolating Workspace resolves to that Workspace's root, so the stock attach validation, the durable membership prune, the one-time history bootstrap, and the "one owning Workspace per session" invariant all keep working with no source change.
The isolation state itself is never persisted by the plugin: it is recomputed from the marker files, so a restart, an external edit, or another tool all converge on the same answer. Scans are serialized, and a scan that lands while a per-call redirect is in flight merges instead of retracting a decision it cannot currently see.
Limits, and honest caveats
- The project root is not writable by the session. That is the isolation. If a session must edit the project's own files, keep isolation off for that project, or move the shared material into the session folder.
- Not a git-worktree substitute. The session folder lives inside the
repository, so
gitworks against the parent repository but a new branch is still shared. Branch-per-agent isolation needs a worktree, which this plugin does not create. For a normal coding repository, leave isolation off: the agent could not edit the repository's own files. - Reads are unchanged. Isolation constrains writes, exactly like DSH's
workspace-writemode; a session can still read the project. A user can also approvedanger-full-accessfor a turn, which bypasses the boundary — that is DSH's approval policy, not this plugin's. - Removing the plugin leaves isolated sessions ungrouped. The registry's membership rule is canonical-cwd equality, so without the plugin the private folders no longer match their project. Re-enable the plugin to restore the grouping; session logs and folders are never touched either way.
- Nesting follows the nearest marked ancestor. Registering a Workspace over an isolating project's own path wins for that path; a session directory inside it belongs to the nearest marked ancestor.
- Local
file:installs are copies. pnpm copies afile:dependency into the profile rather than linking it, so after editing the plugin re-runpnpm install(or deletenode_modules/dsh-session-isolationand reinstall) before the next host start.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The settings page has no Workspace isolation tab | The host started before the client bundle existed | Restart the host once (see the restart note) |
/session-isolation/workspaces answers 404 |
The plugin is not loaded, or the host has no Web carrier | Check the plugin is a selected bundle and installed; check the host boot for a failed session-isolation fiber |
| New sessions still share the project directory | The marker is not in the Workspace's own directory, or the session predates it | Confirm .dsh-isolate sits in the Workspace path shown in the page; only sessions created after it are isolated |
| Sessions from an isolating project appear ungrouped | The plugin is not active for that host | Re-enable the plugin; the folders and logs are intact |
| The agent cannot write a file in the project root | Working as designed | Turn isolation off for that project, or have the session write inside its folder |
Uninstall
- Turn isolation off for every project (each one removes its own
.dsh-isolate), or leave the markers — they are inert without the plugin. - Remove
dsh-session-isolationfrom the profile'sdsh.profile.bundlesanddependencies, then runpnpm install --ignore-scriptsin the profile. - Restart the host.
Session folders and logs are never touched by an uninstall.
Development
node --test "tests/*.test.js"
| Test file | Covers |
|---|---|
tests/isolation.test.js |
the marker policy, directory allocation, both seam wrappers, the state cache, the service API, and the HTTP routes against fakes |
tests/integration.test.js |
the real dsh-storage → dsh-storage-domain → dsh-workspace stack plus a real dsh-tools runtime: session creation, concurrent sessions, membership, a restart over the same medium, and toggling |
tests/tool.test.js |
the session_isolation tool's exported JSON Schema |
tests/client.test.js |
the browser half's module-table handoff and its slot declaration wait |
tests/integration.test.js imports the built lib/ trees from a DSH checkout by
absolute path (the CHECKOUT constant at the top); adjust it when the checkout
moves.
Layout
index.js Cordis plugin: seam patches, service, tool, optional Web routes
lib/isolate.js the marker policy and session-directory allocation
lib/state.js the isolation cache: marker scans, redirect guard, serialization
lib/patch.js the two seam wrappers + the ctx.isolatedSessions service
lib/tool.js the session_isolation model tool
lib/web.js the two HTTP routes behind the GUI page
lib/client.js the browser half (Settings → Built-in plugins page)
cordis.patch.yml the bundle patch that inserts the plugin row
No comments yet. Be the first to write one.