Codex-style Worktree (dsh-codex-style-worktree)
English | 简体中文
A "work like Codex" placement choice for DSH sessions: on the new-session hero row, pick Local or New local worktree (the default) next to the workspace chip; after you send the first message the plugin creates a detached checkout and switches that session's cwd into it. No new Workspace, no naming, no branch from the plugin (Git owns branch naming at commit time).
- Version:
0.5.1(2026-10-20; package / plugin id / loader row name:dsh-codex-style-worktree; profile-patch insert row id:codex-worktree) - API namespace:
/api/dsh-codex-style-worktree, RPC method prefixdsh-codex-style-worktree/; sidecar:$DSH_HOME/dsh-codex-style-worktree/state.json; disable marker:.dsh-codex-style-worktree-off - GitHub: https://github.com/wplct/dsh-codex-style-worktree
- Requirements: DSH
0.2.0-rc.2/0.2.1-rc.2, Node.js>= 22.19.0,giton PATH - License: MIT
0.5.1: the checkout is created on the first message
- Bug fixed: opening a blank Session no longer creates a checkout. The desktop fires
agent/createdthe moment the New Session appears — long before the first prompt — so the old wiring decided the work location at Session-open time; a choice made in that blank Session therefore applied only to the next Session (switching to Local still created a checkout, switching back to New local worktree created none). - Creation now runs on the first step of the Session's first turn (
agent/pre-stepwithturn === 1) — i.e. after you send the first message — and the per-workspace placement is read at that moment, so the choice made in the blank Session applies to that Session. - Blank Sessions no longer leave stray checkouts behind; the progress card and sidebar marker appear after the first message instead of when the blank Session opens.
- Nothing else changes; there is no config, sidecar or settings migration. Upgrade with
dsh plugin --profile desktop update dsh-codex-style-worktreeand reopen DSH once.
0.5.0: rename-only release
- Package name / plugin id / loader row name / API namespace / RPC prefix / sidecar directory all move from
dsh-codex-worktreetodsh-codex-style-worktree; the display title "Codex-style Worktree" is unchanged. - The settings namespace stays
codex-worktree(the profile-patch insert row id is untouched), so saved settings survive; every editable field is unchanged. - On first boot the sidecar directory migrates automatically from
~/.dsh/dsh-codex-worktreeto~/.dsh/dsh-codex-style-worktree(rename, no data loss; if the rename fails the old directory keeps being read). - The repository opt-out marker is now
.dsh-codex-style-worktree-off; the old.dsh-codex-worktree-offis still honored. - Upgrade path: remove the old package, install the new one (
dsh plugin --profile desktop remove dsh-codex-worktree, thendsh plugin --profile desktop add github:wplct/dsh-codex-style-worktree), then fully quit and reopen DSH once.
What changed in 0.4.0 (vs 0.3.0)
| Capability | 0.3.0 | 0.4.0 |
|---|---|---|
| Cleanup guard | Cleanup deletes directly | Refused when the checkout is another active session's current cwd (worktree-in-use); cleaning your own session's checkout first moves its cwd back to the workspace; force overrides after confirmation |
| Snapshot before delete | Only autoPrune kept a ref for detached commits | Every delete path: when the checkout is detached and HEAD ≠ baseCommit, write refs/dsh-codex-style-worktree/<timestamp+session suffix> and record it in the sidecar |
| Snapshot restore | Manual git branch only |
/wt-snapshots, /wt-restore <ref>, and a "recoverable snapshots" list in the settings page |
| Archive cleanup | Checkouts always kept after a session ends | Optional cleanupOnArchive (default off): a lazy sweep removes clean, unused checkouts after archive |
| RPC | 6 endpoints | 7 (adds restore); the list response gains per-repository snapshots |
| Sidecar | v2 | v3 (adds a snapshots map; v1/v2 are migrated in memory on read, no data loss) |
What changed in 0.3.0 (vs 0.2.x) — history
| Capability | 0.2.x | 0.3.0 |
|---|---|---|
| When the worktree is created | Automatically for every new session (no choice); a "New Worktree" button could also create one on demand | You choose a placement on the hero row; the worktree is created after the first message (first-turn agent/pre-step) |
| Selector location | A dock button above the composer | The hero workspace row: a pill between 📁 workspace ▼ and standard mode ▼ |
| Remembered choice | None | Per workspace, persisted in the sidecar (placements), restart-safe |
| Workspace | The old button called workspaces.create, cluttering the sidebar |
Never creates a Workspace; only the session cwd changes |
| Progress feedback | None | Inline three-step progress card above the composer (projection-driven, minimum 1 s) |
| Sidebar | No marker | Managed sessions show a read-only worktree icon (tooltip = checkout path) |
| Settings page | Read-only config | Editable form; save writes the active profile patch and applies live (volatile, no restart) |
| State sync | Some RPC reads | Everything is event/projection-driven, zero polling |
| RPC | Included create, etc. |
status/list/clean/prune/placement/settings, no create/cancel |
1. What it does
- On the new-session hero row (the blank state without a session id) the workspace row shows a placement pill: New local worktree (default) or Local;
- the choice is written back to the host immediately and persisted per workspace path in the sidecar; later sessions in that workspace inherit it, across restarts;
- you send the first message → the Session's first turn starts with
agent/pre-step(before the model's first step) → the host decides by that workspace's placement whether to create a checkout; - with "New local worktree": a detached HEAD checkout is created at
<repo>/.agents/worktrees/wt-<timestamp>and this session's working directory is switched to it; the session stays attached to its original Workspace; - creation progress is pushed through the
codexWorktreesession projection and rendered as an inline progress card above the composer (Prepare workspace → Check out files → Set up environment); when done the card fades and the header chip plus the sidebar marker appear; - with "Local": nothing is created; the session runs in its original Workspace directory;
- the plugin never creates branches, never names anything, never merges, never pushes and never deletes your branches. Git decides the branch name at commit time; use plain
git switch -cwhen you want a branch.mode: branchmakes the plugin create a<branchPrefix><name>branch up front (not the default).
2. Interaction
2.1 New-session hero row: placement selector
Position: the official conversation.hero.options seat — the compact options area of the New Session row, right-aligned after the workspace and preset controls, rendered only once the blank new Session exists. It sits on the same row as 📁 workspace ▼ and standard mode ▼:
📁 shian360 ▼ standard mode ▼ ⑂ New local worktree ▼
┌────────────────────────────────────────────────────┐
│ Describe what you want to build, / for commands… │
└────────────────────────────────────────────────────┘
- Options (single select, current one ticked):
Local/本地— run directly in the current workspace directory;New local worktree/新建本地工作树— run in a new checkout (default);- Grey hint at the bottom of the menu:
Creating a local worktree does not add a Workspace; it only switches this Session’s working directory.
- Visibility: only on the blank new-session hero row (this seat is session-scoped, so it renders once the blank Session exists); hidden for non-Git workspaces (
isGit=falsefrom the host) and for existing sessions. - Seat choice: this is the official list seat, not the shipped WorkspacePicker's
conversation.hero.workspace(that seat iskind: "single"and owned by the workspace picker; registering there would shadow the shipped workspace switcher). The plugin registers under its own id inconversation.hero.optionsand never shadows shipped UI. - Persistence: the choice is written back immediately through the
placementRPC and stored per workspace path in the sidecar. The control updates optimistically and rolls back with a message if the write fails; if reading fails it degrades to a read-only display of the default and never blocks sending. - Relation to config: the selector reads/writes the per-workspace placement, not profile config;
defaultPlacementonly decides the value for a workspace that has no stored placement yet. First open showsplacements[path] ?? defaultPlacement.
2.2 Creation after the first message
- You send the first message;
- the Session's first turn begins and the host's
agent/pre-steplistener runs before the model's first step (a blank Session on its own creates nothing); - the host re-reads that workspace's placement:
local→ do nothing;worktree→ runPrepare workspace(create the pool directory + self-ignoring.gitignore) →Check out files(git worktree add --detach) →Set up environment(canonicalize the path,workingDirectory.set, write the sidecar record); - every phase is pushed through the session projection, and the client renders an inline progress card above the composer:
┌────────────────────────────────────────────────────┐
│ ⑂ Creating worktree │
│ ✓ Prepare workspace │
│ ◐ Check out files │
│ ○ Set up environment │
│ Cancel │
└────────────────────────────────────────────────────┘
- The card is shown for at least 1000 ms (creation is usually < 0.5 s); on completion all three steps are ticked and
Worktree createdfades out after ~400 ms. - Only "Cancel" is offered: it hides the card immediately while creation continues; the plugin does not roll back and offers no "don't use a worktree" remedy.
- Failure: the card turns into an error state with the reason, and the session continues in its original directory without blocking the conversation. Error codes:
| Error code | Meaning |
|---|---|
not-git-repository |
The workspace is not a Git repository |
no-commit |
The repository has no commit yet |
disabled |
enabled: false, or .dsh-codex-style-worktree-off exists at the repository root |
root-unreachable |
The configured root is not writable, or the session sandbox forbids that path |
limit-reached |
The repository reached maxPerRepo (clean up old checkouts first) |
| other message | git error / timeout etc., shown verbatim |
2.3 Session ownership and directory
- The session always stays with its original Workspace; the only side effect is
workingDirectory.setfor the current session. - The plugin never calls
workspaces.create/workspaces.rename/sessions.createand never adds a Workspace; acceptance: the number of workspaces inworkspace.jsonis unchanged before and after. - Default
mode: detached: the plugin creates no branch, the checkout stays on a detached HEAD; Git decides the branch name at commit/push time (usegit switch -cwhen you want a named branch).mode: branchswitches to creating<branchPrefix><name>up front.
2.4 Sidebar marker (read-only)
- Slot:
sidebar.session.row.leading(the official ambient-marker seat: a persistent leading icon area of the session row; the component readssessionId). This is a deliberate design decision: the row's action strip is a hover-only button strip and is unsuitable for a permanent marker; the seat does not belong to the shipped workspace picker and never shadows shipped UI. - Managed sessions show a 14 px worktree icon (
⑂-style SVG,currentColor, muted), with the tooltipCodex-style Worktree · <checkout path>. - Not clickable: clicks fall through to the row (keeping "open session"); no hover card and no navigation.
- Data source: the host registers the
codexWorktreesession projection; the client readssessions.list.getSnapshot().byId[sessionId].projectionValues?.codexWorktreeand renders the icon only whenphase === 'ready', hiding it fornull/missing. Zero RPC, zero polling. - Creation, cleanup and pruning changes appear immediately via projection pushes.
Known limitation: when you clean a non-live session's checkout from the settings page, the host cannot append a projection event for that session, so its sidebar marker disappears only on that session's next consistency repair (reopening the session or its first step).
2.5 Session header chip
- Shows
Codex-style Worktreeplus status (managed / not managed / error). - Popover: path, repository, base commit, mode, change count (dirty), cleanup action.
- The basic state comes from the projection first; when you open the popover it makes one
statuscall for details such as the dirty count (on demand, not polling). - No naming, and no "don't use a worktree" rollback.
2.6 Settings page
One Codex-style Worktree section with two blocks:
- Configuration (editable form)
- Save → goes through the host settings service, writes the active profile's
cordis.patch.yml, and takes effect immediately (volatile config: no restart, no re-mount); - the official DSH "Plugins" settings page also gets an auto-generated form via
configure({ auto: true }); both share one revision and conflicts are surfaced; - "Restore defaults" writes the 21 user-visible fields back to their schema defaults;
- conflict (stale revision) → "The configuration was changed elsewhere" and the form refreshes automatically;
- invalid value → an error is returned, the previous value is kept and the constraint is shown;
- settings service unavailable (headless / non-profile composition) → degrade to read-only and keep the original hint.
- Save → goes through the host settings service, writes the active profile's
- Worktree management (the existing UI): checkouts grouped by repository (path / mode / dirty / created), a per-repository "recoverable snapshots" list (ref / commit / created / restored marker plus a Restore button), refresh, per-row cleanup (in-use or dirty checkouts need confirmation/force) and prune now.
3. Install and upgrade
3.1 Install
Prerequisites: DSH 0.2.0-rc.2 / 0.2.1-rc.2, Node.js >= 22.19.0, git; finish or stop running tasks/sessions first.
dsh plugin --profile desktop add github:wplct/dsh-codex-style-worktree
Then quit and reopen DSH once (the bundle layer takes effect; this is not a page refresh). Afterwards configuration changes need no restart (volatile, live).
3.2 Upgrading from older versions
0.5.0 → 0.5.1 (current):
- bug fix only: the checkout is created on the first message instead of when a blank Session opens, so the placement choice applies to the Session it was made in;
- no migration: run
dsh plugin --profile desktop update dsh-codex-style-worktree, then quit and reopen DSH once.
0.4.x → 0.5.0 (rename-only):
- rename only, no feature changes (the 0.4.0 cleanup guard, restorable snapshots and
cleanupOnArchiveall stay); - follow the "0.5.0: rename-only release" section: remove + add + restart; settings values, sidecar records and snapshot refs are preserved (the ref prefix follows the new name as
refs/dsh-codex-style-worktree/; refs written by the old version can still be pointed at withgit update-ref).
0.3.x → 0.4.0 (history):
- no breaking UI changes: the placement selector, creation-after-send, three-step progress card, read-only sidebar marker, chip and projections all stay; existing placement/config records keep working;
- the sidecar
state.jsonis migrated in memory on read from v2 (or older v1) to v3 (addssnapshots; older versions also getplacements) with no data loss, and the next write persists v3; - the settings page gains
cleanupOnArchive(defaultfalse); leaving it alone keeps the 0.3.0 "do not delete on session end" behavior; - new cleanup guard: cleaning a checkout used by another live session returns
worktree-in-use(cleaning your own session's checkout first moves its cwd back to the workspace; override withforceafter confirming); new/wt-snapshots,/wt-restore <ref>and the settings-page "recoverable snapshots" list; - DSH still needs one restart after upgrading (the bundle layer takes effect); configuration changes continue to apply without a restart.
0.2.x → 0.3.0 (history):
- The old "New Worktree" button is gone; creation only happens after the first message. The
create/cancelendpoints and the pending/adoption logic were removed; - the sidecar
state.jsonmigrates automatically from v1 to v2 (keepsrecords, adds an emptyplacementsmap, bumps the version); old v1 records keep showing in the chip and settings page; - one-time manual cleanup (after confirming): workspaces the old button created (if the sidebar has extras), the corresponding
.agents/worktrees/wt-*checkouts and leftover pending records; - verify the profile has no leftover
auto-worktreerows in its dependencies / bundles /cordis.patch.yml(0.2.2 already cleaned those) and that the market's disabled list has no old package name; - restart DSH once after upgrading so both the new bundle and the client artifact are refreshed; the one-time cleanup done when upgrading to 0.3.x does not need to be repeated.
3.3 Direct install (optional local/offline path)
The repository also ships a direct-install script that bypasses the package manager, for local development or offline use:
node install.mjs --dry-run # print the plan only, write nothing
node install.mjs # install into the default profile (desktop)
node install.mjs --profile web
DSH_HOME=/path/to/.dsh node install.mjs --profile desktop
What the script does:
- validates the source package (
package.json,cordis.patch.yml,lib/**,client/**must all be there; a missing half aborts with an absolute path in the error); - copies
package.json,cordis.patch.yml,README*.md,lib/**andclient/**(recursively) into<profile>/node_modules/dsh-codex-style-worktree/— staged first in a temporary directory on the same volume, never overwriting the live directory in place; - runs
node --checkon every.jsfile in the staged copy and publishes only if all of them pass; a failure rolls back; - backs up
<profile>/cordis.patch.ymlto.bak-<ISO timestamp>and appends the marked patch block (the package'scordis.patch.ymlminus#comment lines; that file currently holds only the config-lessinsertrow, defaults come fromlib/config.js). If the file already contains# >>> dsh-codex-style-worktree >>>, the append is skipped; - prints target path, file count, backup paths and next steps.
The direct installer explicitly does not: modify the profile's package.json, run pnpm/npm, or restart DSH.
4. Uninstall and rollback
Uninstall with the symmetric DSH plugin command, then restart DSH:
dsh plugin --profile desktop remove dsh-codex-style-worktree
If the package was installed with the direct-install script from section 3.3, use uninstall.mjs instead:
node uninstall.mjs --dry-run # print the plan only
node uninstall.mjs # default profile
node uninstall.mjs --profile web
uninstall.mjs removes the marked block (markers and everything between them, other lines preserved, file backed up to .bak-<ISO timestamp> first) from <profile>/cordis.patch.yml, deletes <profile>/node_modules/dsh-codex-style-worktree/ recursively, and prints every action and backup path; it is idempotent and --dry-run writes nothing. It also reports (without deleting) install snapshots .dsh-codex-style-worktree.previous-* and the sidecar ledger.
| Goal | Action |
|---|---|
| Full uninstall | dsh plugin --profile desktop remove dsh-codex-style-worktree (direct install: node uninstall.mjs), then restart DSH |
| Revert the profile patch only | cp <profile>/cordis.patch.yml.bak-<timestamp> <profile>/cordis.patch.yml, delete node_modules/dsh-codex-style-worktree/ manually, restart |
| Roll back to the previous package | rename the printed .dsh-codex-style-worktree.previous-<timestamp> directory back to node_modules/dsh-codex-style-worktree, restart |
Uninstall never deletes your worktrees or branches: check /wt-status, clean up with /wt-clean, or run git worktree remove <path> / git branch -d <branch> yourself. Deleting the sidecar ledger ($DSH_HOME/dsh-codex-style-worktree/) also clears the remembered placements.
5. Configuration
5.1 Where configuration lives
- Defaults and validation:
lib/config.js(resolveConfigis the single source of truth;EDITABLE_FIELDSdefines the settings form fields and their order); - package
cordis.patch.yml: a plain insert row (id: codex-worktree,name: dsh-codex-style-worktree) with no config, so the market can hot-mount it; - runtime override: the
- id: codex-worktreeconfig row in the active profile'scordis.patch.yml(the settings page maintains it automatically); - every user-visible field is volatile: saving applies immediately, no restart or re-mount required.
5.2 User-visible fields (editable in settings, 21)
| Field | Type | Default | Control | Constraints / notes |
|---|---|---|---|---|
enabled |
boolean | true |
switch | Master switch; when off, new sessions get no worktree (existing checkouts are untouched) |
defaultPlacement |
worktree|local |
worktree |
select | Placement for a workspace with no stored record yet |
directory |
string | .agents/worktrees |
text | Pool directory: relative, no ./.. segments |
root |
string | "" |
text | Empty = inside the repository; may be absolute or repository-relative, no .. |
mode |
detached|branch |
detached |
select | detached creates no branch (default); branch creates <branchPrefix><name> up front |
namePrefix |
string | wt- |
text | Checkout name prefix; no / \, must not start with - |
branchPrefix |
string | wt/ |
text | Branch prefix for mode: branch; must not start with - or / |
baseRef |
string | HEAD |
text | Local base revision; must not start with -; resolved locally (network only via fetchBeforeCreate) |
fetchBeforeCreate |
boolean | false |
switch | Run git fetch --prune origin before creation; failures only warn and never block; skipped without origin |
autoPrune |
boolean | true |
switch | After each successful creation, prune the repository's oldest checkouts in the background (keep the newest autoPruneKeep) |
autoPruneKeep |
number | 15 |
number | Newest checkouts kept per repository; non-negative integer |
pruneGraceMs |
number | 60000 |
number | Checkouts younger than this are never auto-pruned (milliseconds); non-negative integer |
cleanupOnArchive |
boolean | false |
switch | After a session is archived, a lazy sweep deletes its clean (no uncommitted changes) and unused worktree; off by default to keep the Codex-style "do not delete on session end" behavior |
poolGitignore |
boolean | true |
switch | Write a self-ignoring .gitignore when the pool is created, keeping the main checkout's git status clean |
gitTimeoutMs |
number | 20000 |
number | Hard timeout for one git command (milliseconds); positive integer |
skipChildAgents |
boolean | true |
switch | Never create a worktree for subagents/child sessions |
skipSessionIdPatterns |
string[] | ["^session-taskboard-"] |
multiline text | One regular expression per line; matches are skipped (taskboard sessions by default) |
skipPresets |
string[] | [] |
multiline text | One regular expression per line; matching agent presets are skipped |
disableMarker |
string | .dsh-codex-style-worktree-off |
text | A file with this name at the repo root or in the session cwd disables creation; plain file name, no / \ |
maxPerRepo |
number | 32 |
number | Upper bound of plugin-managed checkouts per repository; positive integer; hitting it reports limit-reached |
noticeOnCreate |
boolean | false |
switch | Queue one extra user-context message describing the new checkout |
Hidden internals (not in the form):
| Field | Default | Notes |
|---|---|---|
gitCommand |
git |
Internal: git executable name/path |
cleanupOnDispose |
false |
Reserved: delete the checkout on session dispose (currently always kept) |
5.3 root semantics and the safety boundary
The DSH workspace-write file sandbox is bounded by the session's original workspace directory (header.cwd), not the whole disk.
root: ""(default): the pool is<repo>/<directory>, so checkouts always live inside the repository (that is, inside the session workspace) →workspace-writecan read and write them. Safest;- non-empty
root: checkouts are created under that root (absolute, or repository-relative). If it lies outside the session workspace, writes from aworkspace-writesession would be rejected by the sandbox; creation fails withroot-unreachablein the progress card and the session continues in its original directory. Onlydanger-full-accesssessions can create worktrees under an external root; - automatic pruning/deletion is subject to the same sandbox constraints: cleaning an external root requires the session to have the matching permissions; otherwise it fails loudly instead of force-deleting.
6. Worktree management
Slash commands (management only; naming belongs to Git):
| Command | Purpose |
|---|---|
/wt-status |
Show this session's checkout: path, repo, branch (detached when detached), base, dirty |
/wt-clean [--force] [--branch] |
Remove this session's checkout; refuses uncommitted work or an in-use checkout unless --force; --branch also deletes the branch (meaningful with mode: branch) |
/wt-snapshots |
List the current session's repository snapshots (all repositories when unmanaged): ref, commit, created time, restored marker |
/wt-restore <ref> |
Restore a snapshot into a new detached checkout (wt-restored-…) and print the restored path and commit |
6.1 Cleanup entry points and the in-use guard
Three entry points with identical rules:
| Entry point | Notes |
|---|---|
/wt-clean [--force] [--branch] |
Terminal-style management command for this session's checkout |
| Session-header chip popover | Cleans the current session's checkout in place; dirty or in-use checkouts show a confirmation / force affordance |
| Settings page "worktree management" row delete | Cleans any managed checkout from the per-repository list |
- In-use guard (
worktree-in-use): if the checkout directory is the current working directory of another live session, deletion is refused withcode=worktree-in-use. This mirrors Codex, which refuses to delete the "current checkout" outright. - Cleaning your own checkout is not refused: when
/wt-cleanor the current session's chip cleans that session's own checkout, the plugin first moves the session's cwd back to its original workspace (the response carriesmovedTo) and then deletes — the session keeps working. - Force override: after confirming,
force(the--forceflag, or the "force clean/delete" button in the UI) overrides the guard — the other live sessions lose their working directory (their cwd points at a deleted checkout) and uncommitted changes in the checkout are lost too. - Dirty checkouts are still refused first with
worktree-dirty;forceoverrides both the in-use and the dirty check. - If the snapshot conditions in 6.2 apply, the plugin writes the snapshot ref first and then deletes.
6.2 Snapshots and restore
- When a snapshot is taken automatically: before deleting a detached (no branch) checkout whose
HEAD ≠ baseCommit, the plugin writes the currentHEADas the snapshot refrefs/dsh-codex-style-worktree/<timestamp+session suffix>and records it in the sidecarsnapshots. This applies to every delete path (/wt-clean, chip cleanup, settings-page delete,prune, the archive lazy sweep). /wt-snapshots: lists the current session's repository snapshots (all repositories when the session is unmanaged): ref, commit, created time, restored marker./wt-restore <ref>: restores a snapshot into a new detached checkout (namedwt-restored-<timestamp>) and returns{ ref, path, commit, repoRoot }; the restored checkout is not bound to the original session and does not switch any session's cwd — it just brings the data back.- Settings page "worktree management": the
listresponse carries a top-levelsnapshotsarray (each row hasrepoRoot, newest first) and the page groups it by repository into a "recoverable snapshots" list (ref, commit, created, restored marker); clicking Restore creates a new checkout and refreshes the list. - A restored checkout is an ordinary git worktree (in the pool, visible in
git worktree list) but is not written into sidecarrecords: it has no sessionId and is not bound to a session, so it never shows up in the managed list or as a/wt-cleantarget; remove it manually withgit worktree remove <path>when done.refs/dsh-codex-style-worktree/…are plain Git refs, sogit branch <name> <ref>still works. - The same snapshot can be restored repeatedly (each restore creates a new checkout and updates that snapshot's
restoredAt/restoredPath). - Snapshot failure policy:
pruneand the archive lazy sweep skip removing that entry to avoid losing commits; an explicit usercleanonly logs a warning and proceeds. - Snapshots only capture the committed HEAD; uncommitted changes are not included and cannot be restored.
6.3 Archive cleanup (cleanupOnArchive, off by default)
- Setting
cleanupOnArchive(boolean, defaultfalse): after a session is archived, delete its clean worktree. - It is a non-polling lazy sweep that runs opportunistically when: a new session is created successfully, the worktree management page is opened (a
listrequest), aprunerequest runs, or about 1 second after the archive strip stops (workspace/session-stop, via an unref'd timer that never keeps the process alive). - Only checkouts that are both clean (dirty ones are skipped) and unused (in-use ones are skipped) are removed; the snapshot rules from 6.2 still apply before deleting.
- Why off by default: it preserves the Codex-style "do not delete on session end" behavior; enable it when you want disk reclaimed automatically (or clean manually with
/wt-clean/ prune).
6.4 Automatic pruning and manual prune
- With
autoPrune: true(the default), every successful creation prunes the repository's oldest checkouts beyondautoPruneKeepin the background; dirty checkouts and anything younger thanpruneGraceMsare left alone. - A manual prune (the settings page's Prune now, or the
pruneendpoint) runs immediately for one or all repositories and returns the removed entries plus the snapshots it created. - A pruned detached commit gets a
refs/dsh-codex-style-worktree/<name>snapshot first (see 6.2), recoverable with/wt-restoreorgit branch <name> <ref>; uncommitted changes cannot be recovered.
7. RPC and state sync
7.1 RPC endpoints
The browser calls through the authenticated /api carrier: connection.rpc.call('/api', 'dsh-codex-style-worktree/<endpoint>', payload, signal); the host registers /api/dsh-codex-style-worktree/<endpoint> and asserts message.method === 'dsh-codex-style-worktree/<endpoint>'; responses are {ok:true,value} or {ok:false,error:{code,message,details}}.
| Endpoint | Request | Response value | Error codes |
|---|---|---|---|
status |
{ sessionId } |
{ managed, path?, workspaceRoot?, repoRoot?, mode?, branch?, baseCommit?, createdAt?, dirty? } |
bad-request |
list |
{ workspacePath? } |
{ repos: [{ repoRoot, records: [...] }], snapshots: [...], config } (settings-page management list; snapshots is a top-level array, each row carrying repoRoot, newest first) |
bad-request |
clean |
{ sessionId, force?, deleteBranch? } |
{ removed, path, branchDeleted, snapshots, movedTo? } (movedTo means this session's cwd was moved back to the workspace; when the checkout is gone: { removed, path, alreadyGone: true, snapshots }) |
worktree-dirty, worktree-in-use, not-managed |
prune |
{ repoRoot? } |
{ removed, snapshots } |
bad-request |
placement |
read { workspacePath }; write { workspacePath, mode } |
{ workspacePath, mode, defaultMode, isGit } |
bad-request, not-git-repository |
settings |
{action:'get'} / {action:'set', patch, revision} / {action:'reset'} |
{ ns, revision, writable, values, defaults, fields: [...] } |
settings-unavailable, conflict, invalid-config |
restore |
{ ref } (snapshot ref, required) |
the new checkout: { ref, path, commit, repoRoot } (not bound to the original session, no cwd switch, not written to records) |
bad-request, snapshot-not-found (other failures are operation-failed) |
- There is no
createand nocancel: creation is triggered only by the first message of a Session (first-turnagent/pre-step); the progress card's "Cancel" merely hides the card on the client. - Projection data never goes through RPC;
statusis only used on demand by the chip popover and the settings page.
7.2 Session projection (zero polling)
- The host registers the projection unit
codexWorktree(stateVersion: 1); every change appends the eventcodex-worktree/state, whosedata.statecarries the complete post-change state; - state:
{ phase: 'creating'|'ready'|'error', step?: 'prepare'|'checkout'|'finalize', path?: string|null, repoRoot, workspaceRoot, placement?: 'worktree'|'local', mode, branch?: string|null, baseCommit?: string|null, error?, updatedAt }ornull; - the client subscribes to
sessions.listsnapshot entries atbyId[sessionId].projectionValues.codexWorktree(useSyncExternalStore) for the progress card, sidebar marker and chip base state; - no
setInterval/ no polling anywhere; - projection events go into the session log (~4 small events per new session) — that is DSH's native state-sync mechanism and the price of zero polling;
- cleanup: live session → the host appends
null, the marker disappears immediately; non-live session →nullis appended on the next consistency repair (see the known limitation in 2.4).
7.3 Sidecar data ($DSH_HOME/dsh-codex-style-worktree/state.json) v3
{
"schemaVersion": 3,
"records": {
"session-…": {
"sessionId": "session-…",
"repoRoot": "/abs/repo",
"workspaceRoot": "/abs/repo",
"path": "/abs/repo/.agents/worktrees/wt-…",
"poolRoot": "/abs/repo/.agents/worktrees",
"repoLabel": "repo-<hash8>",
"mode": "detached",
"branch": null,
"baseCommit": "<sha>",
"source": "startup",
"createdAt": "ISO"
}
},
"placements": {
"/abs/repo": { "mode": "worktree", "updatedAt": "ISO" }
},
"snapshots": {
"refs/dsh-codex-style-worktree/…": {
"ref": "refs/dsh-codex-style-worktree/…",
"repoRoot": "/abs/repo",
"commit": "<sha>",
"sessionId": "session-…",
"createdAt": "ISO"
}
}
}
- v1/v2 → v3 migration: missing sections are filled in memory on read (v1 gets
placements, v2 getssnapshots) with no data loss; the next write persists v3; - writes are atomic (tmp + rename); a corrupt file is renamed to a backup and the store continues from an empty state;
- stale placements are cleaned lazily: nonexistent paths are ignored, entries unused for more than 90 days are dropped;
snapshotsbacks the restore list (the top-levelsnapshotsarray inlist, and the settings page's "recoverable snapshots" block); each restore appendsrestoredAtandrestoredPathto that row, and the snapshot can still be restored again.
8. Compatibility, limitations and risks
DSH versions:
0.2.0-rc.2/0.2.1-rc.2; depends on the base bundle'ssessionProjections,settingsandconfigEditor— missing ones degrade gracefully: no projections → sidebar marker / progress card unavailable; no settings → read-only config;Node.js >= 22.19.0 (the client uses React 18+
useSyncExternalStore);Creation happens only on the first step of a Session's first turn and only for top-level Sessions whose recorded creation source is
startup; resume / clear / compaction / subagents / taskboard sessions / matching presets / disable marker /enabled: false/ existing record / placement=local never create one. A blank Session that is opened and never used creates nothing, so a placement choice made in it is read at send time and cannot lag one Session behind;No new Workspace: sessions stay in their original workspace; only the cwd changes;
No naming, no branch (detached by default);
mode: branchis opt-in; the plugin never merges, pushes or deletes user branches;Non-Git workspaces: the selector is hidden and nothing is created;
Read-only sandbox / workspace-write boundary / no commit / limit: structured errors that never block the session;
concurrent creation in one repository is serialized by
createKeyedMutex; any exception inside theagent/pre-steplistener is swallowed and warned about, so the plugin can never prevent a session from starting or block the first message;projection events enter the session log (~4 per new session), the cost of zero polling; an ephemeral channel could replace them later (out of scope for this release);
known limitation: cleaning a non-live session's checkout from the settings page leaves its sidebar marker until that session's next consistency repair (reopening the session or its first step; see 2.4);
seat choice (by design, not a limitation): the placement pill registers in the official
conversation.hero.optionslist seat, not the single-valueconversation.hero.workspaceseat owned by the shipped WorkspacePicker (that seat iskind: "single"withreplaceRisk: "shadows-shipped-ui"), so the shipped workspace switcher is never shadowed; the sidebar marker uses the official ambient-marker seatsidebar.session.row.leading, not the hover-only row action strip;the sidebar marker is only a marker: not clickable, no hover card, no navigation;
the "don't use a worktree" rollback feature is explicitly out of scope; the progress card's "Cancel" only hides the card.
The cost of force-cleaning: overriding
worktree-in-usemakes the other live session lose its current working directory (its cwd points at a deleted checkout) and throws away any uncommitted changes in it; cleaning your own session's checkout is not refused, its cwd is moved back to the original workspace.Restored checkouts are not managed by the plugin: they are not written to sidecar records and are not bound to a session, so remove them manually with
git worktree remove; snapshots only hold the pre-delete committed HEAD.Snapshots only contain committed content: the ref points at the pre-delete
HEAD, so uncommitted changes are not in it; a restored checkout is not bound to the original session either.Archive cleanup is a lazy sweep:
cleanupOnArchiveis not immediate; it only runs at the moments listed in 6.3, and skips checkouts still used by a live session.
Out of scope: cloud / Codex Web integration / multiple placements; automatic merge, PRs, environment installation; remembering placement per session (instead of per workspace); switching an existing session into a worktree mid-conversation.
9. Acceptance criteria (AC)
| # | Item | Verification |
|---|---|---|
| AC-1 | The placement selector appears between "workspace" and "standard mode" | Manual GUI + smoke-client render assertions |
| AC-2 | The choice is remembered per workspace: switching to "Local" makes new sessions run in the original directory with no .agents/worktrees output; it survives restart |
Manual + live-host RPC + sidecar assertions |
| AC-3 | Default worktree: the first new session in a new workspace creates a worktree after sending and switches cwd | Integration test (fake ctx + real git) |
| AC-4 | After sending, a three-step progress card appears above the composer (projection-driven, zero polling), for at least 1 s; the chip/marker appear when done | Manual + smoke-client state-machine assertions |
| AC-5 | "Cancel" only hides the card and creation continues; no "don't use a worktree" offered | smoke-client + code grep |
| AC-6 | The number of workspaces in workspace.json is unchanged and the session stays in its original workspace |
live-host assertions + manual |
| AC-7 | Managed sessions show a read-only icon in the sidebar (not clickable, tooltip with path); it disappears immediately after cleanup (projection push, no polling) | Manual + smoke-client |
| AC-8 | Editable settings: disabling "auto-create" stops new worktrees without a restart; values are written to the profile patch; the official Plugins page also shows an auto form | Manual + live-host RPC + patch assertions |
| AC-9 | Conflicts/invalid values in settings produce clear messages and do not corrupt the current config | Unit tests (revision / validation) |
| AC-10 | The old button, create/cancel endpoints and pending logic are gone; old records can still be displayed/cleaned |
Code grep + tests |
| AC-11 | Unit tests and host/client smoke are all green | Local scripts |
| AC-12 | A real DSH host: routes registered, placement read/write, projection pushes, settings get/set, client bundle served | Temporary profile + curl RPC |
10. Troubleshooting
| Symptom | Action |
|---|---|
| No placement selector on the new-session hero row | It only shows for a blank new session whose workspace is a Git repository; existing sessions and non-Git workspaces hide it (check isGit from the placement RPC). |
| Selected "New local worktree" but nothing was created | Confirm the workspace's placement is still worktree when you send the first message (it is remembered per workspace and may have been switched to local); look for the first-step skip reason in the session log; confirm enabled: true, the repository has a commit, maxPerRepo is not reached and there is no disable marker. |
| Chose "Local" but an earlier session still runs in a worktree | Placement only affects future sessions; existing sessions keep their cwd. Clean their checkouts from the settings page if needed. |
| The progress card shows an error | Follow the error code: not-git-repository / no-commit / disabled / root-unreachable / limit-reached; the session continues in its original directory and the conversation is not blocked. |
| The progress card is stuck on "Check out files" | git may be hanging or timing out: check that git is on PATH and the repository is healthy; raise gitTimeoutMs if needed. |
| Settings save reports "configuration was changed elsewhere" | Revision conflict: the form refreshed automatically, confirm and save again; both forms (Plugins page / this page) share one revision. |
Settings save reports settings-unavailable |
This composition has no settings service (headless / non-profile): config degrades to read-only; edit the active profile's cordis.patch.yml (- id: codex-worktree) and restart. |
| Managed sessions have no sidebar icon | The icon only shows when the projection is phase === 'ready'; after cleaning a non-live session it clears on the next consistency repair. Confirm both server and client restarted on 0.5.1. |
Cleanup fails with worktree-in-use |
The checkout directory is the cwd of another live session (Codex-compatible refusal): run /wt-clean from that session (its cwd is moved back to the workspace), or override with force / "force clean" once it no longer needs the directory; note this loses that session's working directory and uncommitted changes. |
| No restore entry point / empty snapshot list | Snapshots are only created when deleting a detached checkout whose HEAD ≠ baseCommit (see 6.2); use /wt-snapshots to check the current session's repository (all repositories when unmanaged), and open the "worktree management" block in the settings page. |
| Restored but cannot find the new checkout in the managed list | Restores produce a plain git worktree (wt-restored-…) that is not written to sidecar records, so it is not in the managed list; use the path returned by /wt-restore or git worktree list, and remove it with git worktree remove. |
cleanupOnArchive is on but the checkout survives archive |
The lazy sweep only runs at the 6.3 triggers (new session, list, prune, after the archive strip stops); dirty or in-use checkouts are skipped. |
| The chip says "not managed" although the session is in a worktree | The session is not managed by this plugin (created by an older version, manually via git worktree add, or placement=local); old records still appear in the settings page and can be cleaned. |
| The 0.2.x "New Worktree" button is gone | Removed in 0.3.0; creation now happens automatically after the first message. |
| Old workspaces/checkouts remain after upgrading | The 0.2.x button may have created workspaces and checkouts: confirm the extra sidebar workspaces, the corresponding .agents/worktrees/wt-* directories and pending records, then clean them manually (see 3.2). |
Startup error Cannot find package 'dsh-codex-style-worktree' |
Re-run dsh plugin --profile desktop add github:wplct/dsh-codex-style-worktree (direct install: node install.mjs), confirm <profile>/node_modules/dsh-codex-style-worktree/lib/index.js exists, restart DSH. |
11. Layout
dsh-codex-style-worktree/
├── install.mjs # direct install: copy into the profile + append the marked block (--dry-run / --profile)
├── uninstall.mjs # direct uninstall: drop the marked block + remove the directory (idempotent, --dry-run)
├── package.json # 0.5.1; exports "." (host) / "./client" (web); engines node >=22.19
├── cordis.patch.yml # plain insert row (id: codex-worktree, no config)
├── lib/ # host half: config defaults/validation, volatile live config, first-message creation,
│ # git wrapper, pool rules, cleanup guard (worktree-in-use), snapshot/restore, archive lazy sweep,
│ # sidecar v3 (records + placements + snapshots), session projection,
│ # RPC routes (placement/settings/status/list/clean/prune/restore), commands
├── client/client.js # browser half: 5 slots (placement selector / progress card / sidebar marker / settings page / chip)
│ └── README.md # client-half slots, projection, RPC, zero-polling notes
├── scripts/ # smoke-host.mjs / smoke-client.mjs
├── test/ # node --test unit/integration tests
├── README.zh.md # Chinese primary README
├── README.md # this English counterpart
└── LICENSE # MIT
Local development and checks:
npm test # unit / integration tests (node --test)
npm run smoke # host + client smoke
npm run verify # the above + node --check client/client.js
npm run install:dry # direct-install dry run
No comments yet. Be the first to write one.