diff --git a/README.md b/README.md
index fe7c0ba..a893940 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC
-> **1.42.0** shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
+> **1.43.0** runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).

diff --git a/docs/quality.md b/docs/quality.md
index a4a1ac8..d81ebfa 100644
--- a/docs/quality.md
+++ b/docs/quality.md
@@ -32,7 +32,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res
The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`.
-`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+
arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent.
+`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. It also starts a separate workspace from the header, checks its branch and that the project stays clean, sees a change counted, and cleans it up through the warning. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent.
Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation.
diff --git a/docs/releases/1.43.0.md b/docs/releases/1.43.0.md
new file mode 100644
index 0000000..7e96ca8
--- /dev/null
+++ b/docs/releases/1.43.0.md
@@ -0,0 +1,27 @@
+# DevDeck 1.43.0
+
+## Parallel sessions in separate workspaces
+
+Running two agents on one project used to mean two agents editing the same files. A session can now start in a **separate workspace**: its own folder and git branch, so it never touches what the project's other sessions are doing.
+
+- **Start one:** use the branch button in a session's header. DevDeck creates a git worktree from the project's current commit and opens a new session in it.
+ - The worktree lives in `.claude/worktrees/`, the same place Claude Code's own `--worktree` uses.
+ - Its branch is `devdeck/`.
+- **See what it has done:** its header shows how many files it has changed and how many commits it has made.
+- **Keep the project's status clean:** the workspace folders are listed in the repository's local `.git/info/exclude`, so they never show up as changes in the project.
+- **Clean up:** the bin button in a workspace session's header closes the session and removes the folder and its branch.
+ - It first says what would be lost, and mentions only what is actually there: uncommitted changes, or commits the starting branch does not have.
+ - If there is unmerged work, it suggests asking a session on the starting branch to merge it first.
+ - Nothing is deleted without that confirmation.
+- **On another PC:** this works for sessions on another PC as well. Creating or removing a workspace there needs the same permission as starting a session. A PC still on an older DevDeck says it needs updating.
+
+The What's new window lists this, with Show me, and the button carries a NEW dot until you first use it.
+
+## Under the hood
+
+- **Folder permissions:** a project registered as a single folder admits its own workspaces, `/.claude/worktrees/`, exactly one level down. Nothing else inside the repository is admitted.
+- **What can be removed:** only a folder DevDeck created as a workspace, on a branch it named.
+- **Windows folder leftovers:** git can drop a workspace from its list while Windows still holds the folder for a moment. DevDeck then finishes removing that one folder itself.
+- **Tests:**
+ - New tests run against real temporary git repositories: creating, counting changes, refusing to drop unmerged work, and cleaning up after a half-removed workspace.
+ - `qa:cockpit-tools` creates a workspace from the header, sees its change counted, and cleans it up through the warning.
diff --git a/package-lock.json b/package-lock.json
index d218962..dd8b7a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devdeck",
- "version": "1.42.0",
+ "version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devdeck",
- "version": "1.42.0",
+ "version": "1.43.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
diff --git a/package.json b/package.json
index 3c21175..b083107 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devdeck",
- "version": "1.42.0",
+ "version": "1.43.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
diff --git a/qa/cockpit-tools.mjs b/qa/cockpit-tools.mjs
index 845ea6f..f1d96ad 100644
--- a/qa/cockpit-tools.mjs
+++ b/qa/cockpit-tools.mjs
@@ -5,7 +5,7 @@
// Run with npm run qa:cockpit-tools.
import { _electron as electron } from 'playwright';
import assert from 'node:assert/strict';
-import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
+import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
@@ -24,6 +24,10 @@ const profile = join(sandbox, 'profile');
const home = join(profile, 'home');
for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true });
execFileSync('git', ['init', '-q', '-b', 'qa-tools'], { cwd: repo });
+// A first commit, so a separate workspace (a git worktree) can be started from it.
+writeFileSync(join(repo, 'README.md'), '# tools\n');
+execFileSync('git', ['-c', 'user.email=qa@example.invalid', '-c', 'user.name=QA', 'add', '.'], { cwd: repo });
+execFileSync('git', ['-c', 'user.email=qa@example.invalid', '-c', 'user.name=QA', 'commit', '-q', '-m', 'first'], { cwd: repo });
const sid = '7a1c2b3d-2222-4333-8444-955566667777';
const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-'));
@@ -229,6 +233,36 @@ try {
rmSync(path, { force: true });
});
+ await check('a separate workspace: its own branch and folder, its changes in the header, and a clean-up that warns', async () => {
+ const before = await win.locator('#shell-session-groups .shell-session').count();
+ await win.locator('#ck-header .ck-wt-new').click();
+ await win.waitForFunction((n) => document.querySelectorAll('#shell-session-groups .shell-session').length === n + 1, before, { timeout: 20000 });
+ await win.waitForSelector('#ck-header .ck-wt-pill');
+ const wtDir = join(repo, '.claude', 'worktrees');
+ const [name] = readdirSync(wtDir);
+ const wt = join(wtDir, name);
+ assert.equal(execFileSync('git', ['-C', wt, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim(), 'devdeck/' + name);
+ // The project itself does not see the workspace as a change.
+ assert.equal(execFileSync('git', ['-C', repo, 'status', '--porcelain'], { encoding: 'utf8' }).trim(), '');
+ // Work done in the workspace shows up in its header.
+ writeFileSync(join(wt, 'feature.txt'), 'new work\n');
+ await win.locator('#shell-session-groups .shell-session').first().click();
+ await win.locator('#shell-session-groups .shell-session', { hasText: name }).click();
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-wt-pill')?.textContent?.includes('파일 1 '), null, { timeout: 15000 });
+ await win.screenshot({ path: join(shots, '4-workspace.png') });
+ // Cleaning up says what would be lost before anything is.
+ await win.locator('#ck-header .ck-wt-clean').click();
+ await win.waitForSelector('.ck-confirm-msg');
+ const confirmText = await win.locator('.ck-confirm-msg').textContent();
+ assert.match(confirmText, /커밋하지 않은 변경 1개/);
+ assert.doesNotMatch(confirmText, /커밋 0개/, 'nothing that is not there is mentioned');
+ await win.screenshot({ path: join(shots, '4-workspace-cleanup.png') });
+ await win.locator('.ck-confirm-ok').click();
+ for (const until = Date.now() + 15000; existsSync(wt) && Date.now() < until;) await new Promise((r) => setTimeout(r, 250));
+ assert.equal(existsSync(wt), false, 'the workspace folder is gone');
+ assert.equal(execFileSync('git', ['-C', repo, 'branch', '--list', 'devdeck/' + name], { encoding: 'utf8' }).trim(), '', 'and its branch');
+ });
+
await check('a document sent from another machine lands in the temp directory under its own name', async () => {
const landed = await win.evaluate(() => window.devdeck.cockpit.receiveFile('build.log', btoa('step 1 ok\nstep 2 failed\n')));
assert.match(landed, /devdeck-file-[0-9a-f-]{36}-build\.log$/);
diff --git a/qa/perf-gate.mjs b/qa/perf-gate.mjs
index 086506d..58ce555 100644
--- a/qa/perf-gate.mjs
+++ b/qa/perf-gate.mjs
@@ -194,3 +194,6 @@ try {
await closeElectron(app).catch(() => {});
}
if (failures.length) { console.log(`\nFAIL: ${failures.join(', ')}`); process.exitCode = 1; } else console.log('\nPASS: performance gate');
+// Exit explicitly: on a Windows runner a handle left by the closed app once kept node alive after
+// the verdict was printed, until the job hit its 20-minute limit and the v1.42.0 release was cancelled.
+process.exit(process.exitCode ?? 0);
diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts
index 0c584fe..44dbdca 100644
--- a/src/main/api/deckApi.surface.test.ts
+++ b/src/main/api/deckApi.surface.test.ts
@@ -57,7 +57,7 @@ beforeAll(() => {
getTrayAlert: () => 'attention',
getShutdownIdleMinutes: () => 10,
getSessionSummary: () => true,
- getAiSessionSummary: () => false, getProgramStatus: () => false,
+ getAiSessionSummary: () => false, getProgramStatus: () => false, getWorktrees: () => [],
},
sendError: vi.fn(),
defaultLanguage: 'en',
@@ -345,3 +345,13 @@ it('allows revision-checked task saves only to write-authorized devices', () =>
expect(() => api['project:setTodos'].handler(ALLOWED_ROOT, [])).toThrow('TASKS_CLIENT_OUTDATED');
return expect(Promise.resolve().then(() => api['project:saveTodos'].handler(join(process.cwd(), 'outside'), [], 0))).rejects.toThrow('allowed folders');
});
+
+it('lets a paired device create or remove a workspace only with spawn, and look at one as an observer', async () => {
+ expect(mayCallRemotely(api['cockpit:createWorktree'], ['observe'])).toBe(false);
+ expect(mayCallRemotely(api['cockpit:createWorktree'], ['spawn'])).toBe(true);
+ expect(mayCallRemotely(api['cockpit:removeWorktree'], ['control'])).toBe(false);
+ expect(mayCallRemotely(api['cockpit:worktreeInfo'], ['observe'])).toBe(true);
+ await expect(api['cockpit:createWorktree'].handler(join(process.cwd(), 'outside'))).resolves.toEqual({ error: 'Path outside allowed folders' });
+ // A folder DevDeck did not make is not one it will remove.
+ await expect(api['cockpit:removeWorktree'].handler(join(process.cwd(), 'outside'), true)).resolves.toEqual({ removed: false, reason: 'unknown' });
+});
diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts
index 61745c4..265e3a1 100644
--- a/src/main/api/deckApi.ts
+++ b/src/main/api/deckApi.ts
@@ -1,7 +1,7 @@
import { createSessionLabels } from '../sessionLabels';
import { dialog, shell, app, clipboard, nativeImage, type BrowserWindow } from 'electron';
import { homedir, release, tmpdir } from 'node:os';
-import path, { join } from 'node:path';
+import path, { join, resolve } from 'node:path';
import { readFile, realpath, stat } from 'node:fs/promises';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
@@ -10,7 +10,8 @@ import type { PtyHost } from '../ptyHost';
import { PtyBatcher } from '../ptyBatch';
import { applyOpenAtLogin, effectiveOpenAtLogin } from '../autostart';
import { scanFolders, isRepo } from '../scanner';
-import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits } from '../gitInfo';
+import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, createGitRunner, type GitRunner } from '../gitInfo';
+import { createWorktree, removeWorktree, worktreeChanges, type Worktree } from '../worktrees';
import { getProvider, availableAgents, resolveOpenSession, resolveProjectOpenCommand } from '../agents';
import { toAgentId, type AgentId, type CloseResult, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types';
import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, AGENT_OPEN_EXT } from '../../shared/pathGuard';
@@ -1056,6 +1057,47 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
// RESTORED session (re-created with no branch) and in-terminal branch switches both show the real
// branch instead of a stale snapshot or "-". Uses the 2-call branch+dirty reader (not the deck's
// 5-call getGitInfo) since the cockpit refreshes this per session.
+ // ---- parallel-session workspaces (git worktrees) ----
+ // Each is a folder inside the project, so the same allowlist admits it; creating or removing one
+ // changes the repository, which is what "spawn" already grants a paired device.
+ // Made on first use: nothing about workspaces should run while the API is being put together.
+ let worktreeRunner: GitRunner | null = null;
+ const worktreeGit: GitRunner = (args) => (worktreeRunner ??= createGitRunner())(args);
+ const worktreeFor = (path: string): Worktree | undefined => cfg.store.getWorktrees().find((w) => w.path.toLowerCase() === resolve(String(path)).toLowerCase());
+ invoke('cockpit:createWorktree', allow('spawn'), async (repoPath: string) => {
+ const repo = String(repoPath);
+ if (!isAllowedPath(effFolders(), repo)) return { error: 'Path outside allowed folders' };
+ try {
+ const w = await createWorktree(repo, worktreeGit);
+ cfg.store.addWorktree(w);
+ cfg.diagnostics?.write('info', 'worktree', `created ${w.path} branch=${w.branch} from=${w.baseBranch ?? w.baseCommit.slice(0, 8)}`);
+ return { worktree: w };
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : String(err) };
+ }
+ });
+ invoke('cockpit:worktreeInfo', allow('observe'), async (path: string) => {
+ const w = worktreeFor(path);
+ if (!w || !isAllowedPath(effFolders(), w.path)) return null;
+ return { worktree: w, changes: await worktreeChanges(w, worktreeGit) };
+ });
+ invoke('cockpit:removeWorktree', allow('spawn'), async (path: string, force?: unknown) => {
+ const w = worktreeFor(path);
+ if (!w || !isAllowedPath(effFolders(), w.path)) return { removed: false, reason: 'unknown' };
+ // A session still running in it would lose its folder under it.
+ if (cfg.ptyHost.list().some((s) => resolve(s.projectPath).toLowerCase() === resolve(w.path).toLowerCase())) return { removed: false, reason: 'running' };
+ try {
+ const out = await removeWorktree(w, worktreeGit, force === true);
+ if (out.removed) {
+ cfg.store.removeWorktreeRecord(w.path);
+ cfg.diagnostics?.write('info', 'worktree', `removed ${w.path}${force === true ? ' (forced)' : ''}`);
+ }
+ return out;
+ } catch (err) {
+ return { removed: false, reason: 'error', error: err instanceof Error ? err.message : String(err) };
+ }
+ });
+
invoke('cockpit:gitInfo', allow('observe'), (projectPath: string) => {
// Same allowlist as every other path-taking handler — a compromised renderer must not be able
// to point git at arbitrary filesystem locations.
diff --git a/src/main/store.ts b/src/main/store.ts
index 9c8e588..b496aa3 100644
--- a/src/main/store.ts
+++ b/src/main/store.ts
@@ -7,13 +7,14 @@ import { sanitizePersistedList, type PersistedSession } from '../shared/cockpitP
import { sanitizeTodos, type Todo, type TodoSaveResult } from '../shared/tasks';
import { sanitizeWindowBounds, type WindowBounds } from '../shared/windowBounds';
import { sanitizeQuickReplies } from '../shared/quickReplies';
+import type { Worktree } from './worktrees';
import { LOCAL_MACHINE_ID, isValidMachineId, sanitizeMachineName } from '../shared/link/machine';
import { sanitizeKnownHosts, sanitizePairedDevices, type KnownHost, type PairedDevice } from './link/devices';
import { LINK_DEFAULT_PORT } from './link/protocol';
interface StateFile {
projects: Record;
- settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all'; pendingAutoRestore?: PersistedSession[]; contextWindow?: number; shutdownIdleMinutes?: number; cockpitSidebarCollapsed?: boolean; sessionSummary?: boolean; aiSessionSummary?: boolean; quickReplies?: string[]; programStatus?: boolean; whatsNewSeen?: string; windowBounds?: WindowBounds; machineId?: string; machineName?: string; linkHostMode?: boolean; linkPort?: number; linkDevices?: PairedDevice[]; linkHosts?: KnownHost[] };
+ settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all'; pendingAutoRestore?: PersistedSession[]; contextWindow?: number; shutdownIdleMinutes?: number; cockpitSidebarCollapsed?: boolean; sessionSummary?: boolean; aiSessionSummary?: boolean; quickReplies?: string[]; programStatus?: boolean; whatsNewSeen?: string; worktrees?: Worktree[]; windowBounds?: WindowBounds; machineId?: string; machineName?: string; linkHostMode?: boolean; linkPort?: number; linkDevices?: PairedDevice[]; linkHosts?: KnownHost[] };
}
/** Error codes Windows returns while another process (AV scan, indexer, OneDrive) briefly holds the file. */
@@ -295,6 +296,22 @@ export class Store {
setWhatsNewSeen(v: string): void { if (!/^\d+\.\d+\.\d+$/.test(v)) return; this.state.settings = { ...(this.state.settings ?? {}), whatsNewSeen: v }; this.save(); }
/** Whether this install has been used before (a window was ever saved) — an upgrade, not a first run. */
hasUsedBefore(): boolean { return this.state.settings?.windowBounds !== undefined; }
+ /** Parallel-session workspaces DevDeck created, with what each started from. */
+ getWorktrees(): Worktree[] {
+ const list = this.state.settings?.worktrees;
+ if (!Array.isArray(list)) return [];
+ return list.filter((w): w is Worktree => !!w && typeof w.path === 'string' && typeof w.repo === 'string'
+ && typeof w.branch === 'string' && typeof w.baseCommit === 'string' && (w.baseBranch === null || typeof w.baseBranch === 'string'));
+ }
+ addWorktree(w: Worktree): void {
+ const rest = this.getWorktrees().filter((x) => x.path !== w.path);
+ this.state.settings = { ...(this.state.settings ?? {}), worktrees: [...rest, w] };
+ this.save();
+ }
+ removeWorktreeRecord(path: string): void {
+ this.state.settings = { ...(this.state.settings ?? {}), worktrees: this.getWorktrees().filter((x) => x.path !== path) };
+ this.save();
+ }
getTrayAlert(): 'off' | 'attention' | 'all' { const t = this.state.settings?.trayAlert; return t === 'off' || t === 'all' ? t : 'attention'; }
setTrayAlert(t: 'off' | 'attention' | 'all'): void { this.state.settings = { ...(this.state.settings ?? {}), trayAlert: t === 'off' || t === 'all' ? t : 'attention' }; this.save(); }
diff --git a/src/main/worktrees.test.ts b/src/main/worktrees.test.ts
new file mode 100644
index 0000000..a362e42
--- /dev/null
+++ b/src/main/worktrees.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { createGitRunner } from './gitInfo';
+import { createWorktree, isWorktreeOf, removeWorktree, repoOfWorktree, worktreeChanges, worktreeName } from './worktrees';
+
+const run = createGitRunner();
+const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim();
+
+let repo: string;
+beforeEach(() => {
+ repo = mkdtempSync(join(tmpdir(), 'devdeck-wt-'));
+ git(repo, 'init', '-q', '-b', 'main');
+ git(repo, 'config', 'user.email', 'qa@example.invalid');
+ git(repo, 'config', 'user.name', 'QA');
+ writeFileSync(join(repo, 'a.txt'), 'one\n');
+ git(repo, 'add', '.');
+ git(repo, 'commit', '-q', '-m', 'first');
+});
+afterEach(() => { rmSync(repo, { recursive: true, force: true, maxRetries: 5 }); });
+
+describe('parallel-session workspaces', () => {
+ it('starts one on its own branch where Claude Code keeps worktrees, and keeps it out of the project\'s status', async () => {
+ const w = await createWorktree(repo, run, new Date(2026, 9, 21, 6, 32));
+ expect(w.path).toMatch(/[\\/]\.claude[\\/]worktrees[\\/]1021-0632-[0-9a-f]{3}$/);
+ expect(w.branch).toMatch(/^devdeck\/1021-0632-/);
+ expect(w.baseBranch).toBe('main');
+ expect(existsSync(join(w.path, 'a.txt'))).toBe(true);
+ expect(git(w.path, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(w.branch);
+ // The project itself still looks clean.
+ expect(git(repo, 'status', '--porcelain')).toBe('');
+ expect(readFileSync(join(repo, '.git', 'info', 'exclude'), 'utf8')).toContain('/.claude/worktrees/');
+ // A second one does not add the exclude line twice.
+ await createWorktree(repo, run);
+ expect(readFileSync(join(repo, '.git', 'info', 'exclude'), 'utf8').match(/\/\.claude\/worktrees\//g)).toHaveLength(1);
+ });
+
+ it('counts what it has done: changed files, commits, unsaved work', async () => {
+ const w = await createWorktree(repo, run);
+ writeFileSync(join(w.path, 'a.txt'), 'one\ntwo\n');
+ writeFileSync(join(w.path, 'new.txt'), 'x\n');
+ expect(await worktreeChanges(w, run)).toMatchObject({ files: 2, insertions: 1, commits: 0, dirty: 2 });
+ git(w.path, 'add', '.');
+ git(w.path, 'commit', '-q', '-m', 'work');
+ expect(await worktreeChanges(w, run)).toMatchObject({ files: 2, commits: 1, dirty: 0 });
+ });
+
+ it('refuses to remove one holding work that exists nowhere else, unless told to', async () => {
+ const w = await createWorktree(repo, run);
+ writeFileSync(join(w.path, 'b.txt'), 'b\n');
+ expect(await removeWorktree(w, run, false)).toEqual({ removed: false, reason: 'unsaved' });
+ git(w.path, 'add', '.');
+ git(w.path, 'commit', '-q', '-m', 'work');
+ expect(await removeWorktree(w, run, false)).toEqual({ removed: false, reason: 'unmerged' });
+ expect(existsSync(w.path)).toBe(true);
+ expect(await removeWorktree(w, run, true)).toEqual({ removed: true });
+ expect(existsSync(w.path)).toBe(false);
+ expect(git(repo, 'branch', '--list', w.branch)).toBe('');
+ });
+
+ it('removes a clean one, and its branch', async () => {
+ const w = await createWorktree(repo, run);
+ expect(await removeWorktree(w, run, false)).toEqual({ removed: true });
+ expect(git(repo, 'branch', '--list', w.branch)).toBe('');
+ });
+
+ it('never removes a folder that is not one of its workspaces', async () => {
+ await expect(removeWorktree({ path: repo, repo, branch: 'devdeck/x', baseCommit: 'HEAD', baseBranch: 'main' }, run, true)).rejects.toThrow('not a DevDeck workspace');
+ expect(existsSync(join(repo, 'a.txt'))).toBe(true);
+ });
+
+ it('refuses a folder that is not the top of a repository, or one with no commits', async () => {
+ await expect(createWorktree(join(repo, '.git'), run)).rejects.toThrow();
+ const empty = mkdtempSync(join(tmpdir(), 'devdeck-wt-empty-'));
+ git(empty, 'init', '-q');
+ await expect(createWorktree(empty, run)).rejects.toThrow();
+ rmSync(empty, { recursive: true, force: true });
+ });
+});
+
+describe('workspace paths', () => {
+ it('knows its own folders and nothing else', () => {
+ const r = join('C:', 'g', 'proj');
+ expect(isWorktreeOf(r, join(r, '.claude', 'worktrees', '1021-0632-abc'))).toBe(true);
+ expect(isWorktreeOf(r, join(r, '.claude', 'worktrees'))).toBe(false);
+ expect(isWorktreeOf(r, join(r, '.claude', 'worktrees', 'a', 'b'))).toBe(false);
+ expect(isWorktreeOf(r, join(r, 'src'))).toBe(false);
+ expect(repoOfWorktree(join(r, '.claude', 'worktrees', 'x'))).toBe(resolve(r));
+ expect(repoOfWorktree(join(r, 'src'))).toBeNull();
+ });
+
+ it('names them by time with a random tail', () => {
+ expect(worktreeName(new Date(2026, 0, 2, 3, 4), 'abc')).toBe('0102-0304-abc');
+ });
+});
+
+it('finishes the job when git has dropped a workspace but left its folder behind (Windows)', async () => {
+ const w = await createWorktree(repo, run);
+ // What a locked folder leaves: git no longer lists it, the folder is still there.
+ git(repo, 'worktree', 'remove', '--force', w.path);
+ mkdirSync(w.path, { recursive: true });
+ writeFileSync(join(w.path, 'leftover.txt'), 'x');
+ expect(await removeWorktree(w, run, true)).toEqual({ removed: true });
+ expect(existsSync(w.path)).toBe(false);
+});
+
+it('knows a repository reached through another spelling of its path (8.3 name, symlink, junction)', async () => {
+ // CI's temp folder is C:\Users\RUNNER~1\..., macOS's /var is /private/var: git answers with the
+ // long, real path, and a plain comparison refused the repository's own top folder.
+ const link = join(tmpdir(), `devdeck-wt-link-${Date.now()}`);
+ symlinkSync(repo, link, process.platform === 'win32' ? 'junction' : 'dir');
+ try {
+ const w = await createWorktree(link, run);
+ expect(existsSync(join(w.path, 'a.txt'))).toBe(true);
+ expect(await removeWorktree(w, run, false)).toEqual({ removed: true });
+ } finally { rmSync(link, { recursive: false, force: true }); }
+});
diff --git a/src/main/worktrees.ts b/src/main/worktrees.ts
new file mode 100644
index 0000000..7ce7430
--- /dev/null
+++ b/src/main/worktrees.ts
@@ -0,0 +1,148 @@
+// src/main/worktrees.ts
+// Separate workspaces for parallel sessions: a git worktree per session, so several agents can work
+// on one project at once without touching each other's files.
+//
+// They live where Claude Code's own `--worktree` puts them — `/.claude/worktrees/` — on a
+// branch of their own. Inside the repository folder, so the scan allowlist that admits the project
+// admits its workspaces too; listed in `.git/info/exclude`, so they never show up as changes in the
+// project they came from.
+import { appendFile, mkdir, readFile, realpath, rm } from 'node:fs/promises';
+import { isAbsolute, join, relative, resolve, sep } from 'node:path';
+import { randomBytes } from 'node:crypto';
+import type { GitRunner } from './gitInfo';
+
+export const WORKTREE_DIR = join('.claude', 'worktrees');
+export const WORKTREE_BRANCH_PREFIX = 'devdeck/';
+const EXCLUDE_LINE = '/.claude/worktrees/';
+
+export interface Worktree {
+ /** The workspace folder. */
+ path: string;
+ /** The repository it belongs to. */
+ repo: string;
+ branch: string;
+ /** The branch and commit it was started from. */
+ baseBranch: string | null;
+ baseCommit: string;
+}
+
+export interface WorktreeChanges {
+ /** Files changed against the commit it started from, committed or not. */
+ files: number;
+ insertions: number;
+ deletions: number;
+ /** Commits on its branch that its base branch does not have. */
+ commits: number;
+ /** Uncommitted changes, untracked files included. */
+ dirty: number;
+}
+
+/** A short, sortable, collision-proof name: `0121-0632-a3f`. */
+export function worktreeName(now: Date, rand = randomBytes(2).toString('hex').slice(0, 3)): string {
+ const p = (n: number): string => String(n).padStart(2, '0');
+ return `${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}-${rand}`;
+}
+
+/** Whether `path` is a workspace folder directly under `repo`'s worktree directory — the only kind removed. */
+export function isWorktreeOf(repo: string, path: string): boolean {
+ const rel = relative(join(resolve(repo), WORKTREE_DIR), resolve(path));
+ return !!rel && !rel.startsWith('..') && !isAbsolute(rel) && !rel.includes(sep) && !rel.includes('/');
+}
+
+/** The repository a workspace folder belongs to, read off its path; null for any other folder. */
+export function repoOfWorktree(path: string): string | null {
+ const parts = resolve(path).split(/[\\/]/);
+ const at = parts.length - 3;
+ if (at < 1 || parts[at] !== '.claude' || parts[at + 1] !== 'worktrees' || !parts[at + 2]) return null;
+ return parts.slice(0, at).join(sep);
+}
+
+/** Start a workspace on a new branch from the repository's current commit. */
+export async function createWorktree(repo: string, run: GitRunner, now = new Date()): Promise {
+ const top = (await run(['-C', repo, 'rev-parse', '--show-toplevel'])).trim();
+ if (!top || !(await sameDir(top, repo))) {
+ throw new Error('not the top folder of a git repository');
+ }
+ const baseCommit = (await run(['-C', repo, 'rev-parse', '--verify', 'HEAD'])).trim();
+ if (!/^[0-9a-f]{40,64}$/.test(baseCommit)) throw new Error('the repository has no commits yet');
+ const head = (await run(['-C', repo, 'rev-parse', '--abbrev-ref', 'HEAD'])).trim();
+ const name = worktreeName(now);
+ const branch = WORKTREE_BRANCH_PREFIX + name;
+ const path = join(resolve(repo), WORKTREE_DIR, name);
+ await excludeWorktrees(repo, run);
+ await mkdir(join(resolve(repo), WORKTREE_DIR), { recursive: true });
+ await run(['-C', repo, 'worktree', 'add', '-b', branch, path, baseCommit]);
+ return { path, repo: resolve(repo), branch, baseBranch: head && head !== 'HEAD' ? head : null, baseCommit };
+}
+
+/** Keep the workspaces out of the project's own status: one line in the repository-local exclude file. */
+async function excludeWorktrees(repo: string, run: GitRunner): Promise {
+ const common = (await run(['-C', repo, 'rev-parse', '--git-common-dir'])).trim();
+ const file = join(resolve(repo, common), 'info', 'exclude');
+ let text = '';
+ try { text = await readFile(file, 'utf8'); } catch { /* no exclude file yet */ }
+ if (text.split(/\r?\n/).some((l) => l.trim() === EXCLUDE_LINE)) return;
+ await mkdir(join(resolve(repo, common), 'info'), { recursive: true });
+ await appendFile(file, `${text && !text.endsWith('\n') ? '\n' : ''}# DevDeck parallel-session workspaces\n${EXCLUDE_LINE}\n`);
+}
+
+/** What a workspace has done since it started. */
+export async function worktreeChanges(w: Pick, run: GitRunner): Promise {
+ const [stat, status, ahead] = await Promise.all([
+ run(['-C', w.path, 'diff', '--shortstat', w.baseCommit]).catch(() => ''),
+ run(['-C', w.path, 'status', '--porcelain']).catch(() => ''),
+ run(['-C', w.path, 'rev-list', '--count', `${w.baseBranch ?? w.baseCommit}..HEAD`]).catch(() => '0'),
+ ]);
+ const num = (re: RegExp): number => Number(re.exec(stat)?.[1] ?? 0);
+ const untracked = status.split(/\r?\n/).filter((l) => l.startsWith('??')).length;
+ return {
+ files: num(/(\d+) files? changed/) + untracked,
+ insertions: num(/(\d+) insertions?/),
+ deletions: num(/(\d+) deletions?/),
+ commits: Number(ahead.trim()) || 0,
+ dirty: status.split(/\r?\n/).filter((l) => l.trim()).length,
+ };
+}
+
+/**
+ * Remove a workspace and its branch. Refused while it holds work that exists nowhere else —
+ * uncommitted changes, or commits its base branch does not have — unless `force`.
+ */
+export async function removeWorktree(w: Pick, run: GitRunner, force: boolean): Promise<{ removed: boolean; reason?: 'unsaved' | 'unmerged' }> {
+ if (!isWorktreeOf(w.repo, w.path) || !w.branch.startsWith(WORKTREE_BRANCH_PREFIX)) throw new Error('not a DevDeck workspace');
+ if (!force) {
+ const c = await worktreeChanges(w, run);
+ if (c.dirty > 0) return { removed: false, reason: 'unsaved' };
+ if (c.commits > 0) return { removed: false, reason: 'unmerged' };
+ }
+ try {
+ await run(['-C', w.repo, 'worktree', 'remove', ...(force ? ['--force'] : []), w.path]);
+ } catch (err) {
+ // On Windows git can drop the worktree from its list and still fail to delete the folder, held
+ // a moment longer by a process that has just exited; every later attempt then says "not a
+ // working tree". Once git no longer knows it, what is left is only the folder.
+ if (await isRegistered(w.repo, w.path, run)) throw err;
+ }
+ // The folder was checked above to be one of ours; remove what git left behind.
+ await rm(w.path, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
+ await run(['-C', w.repo, 'worktree', 'prune']).catch(() => '');
+ await run(['-C', w.repo, 'branch', force ? '-D' : '-d', w.branch]).catch(() => '');
+ return { removed: true };
+}
+
+async function isRegistered(repo: string, path: string, run: GitRunner): Promise {
+ const list = await run(['-C', repo, 'worktree', 'list', '--porcelain']).catch(() => '');
+ for (const l of list.split(/\r?\n/)) if (l.startsWith('worktree ') && await sameDir(l.slice(9), path)) return true;
+ return false;
+}
+
+/**
+ * Whether two paths name the same folder. git reports long, real paths; the ones handed in can be
+ * Windows 8.3 short names (`C:\Users\RUNNER~1`) or go through a symlink (`/var` is `/private/var` on
+ * macOS) — compared as written, a repository's own top folder failed to match itself.
+ */
+async function sameDir(a: string, b: string): Promise {
+ const real = async (p: string): Promise => { try { return await realpath(resolve(p)); } catch { return resolve(p); } };
+ const [ra, rb] = await Promise.all([real(a), real(b)]);
+ return process.platform === 'win32' || process.platform === 'darwin' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
+}
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index 035d44d..44c4496 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -112,6 +112,9 @@ contextBridge.exposeInMainWorld('devdeck', {
openFile: (projectPath: string, filePath: string) => ipcRenderer.invoke('cockpit:openFile', projectPath, filePath),
receiveImage: (base64: string) => ipcRenderer.invoke('cockpit:receiveImage', base64),
receiveFile: (name: string, base64: string) => ipcRenderer.invoke('cockpit:receiveFile', name, base64),
+ createWorktree: (repoPath: string) => ipcRenderer.invoke('cockpit:createWorktree', repoPath),
+ worktreeInfo: (path: string) => ipcRenderer.invoke('cockpit:worktreeInfo', path),
+ removeWorktree: (path: string, force: boolean) => ipcRenderer.invoke('cockpit:removeWorktree', path, force),
sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('cockpit:sessionTurns', projectPath, sessionId, agentId),
sessionImages: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('cockpit:sessionImages', projectPath, sessionId, agentId),
sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('cockpit:sessionImage', projectPath, sessionId, imageId, size),
@@ -193,6 +196,9 @@ contextBridge.exposeInMainWorld('devdeck', {
gitInfo: (projectPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:gitInfo', [projectPath]),
receiveImage: (base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveImage', [base64]),
receiveFile: (name: string, base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveFile', [name, base64]),
+ createWorktree: (repoPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:createWorktree', [repoPath]),
+ worktreeInfo: (path: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:worktreeInfo', [path]),
+ removeWorktree: (path: string, force: boolean) => ipcRenderer.invoke('link:call', machineId, 'cockpit:removeWorktree', [path, force]),
sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionTurns', [projectPath, sessionId, agentId]),
sessionImages: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionImages', [projectPath, sessionId, agentId]),
sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionImage', [projectPath, sessionId, imageId, size]),
diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts
index bf1639a..46cd182 100644
--- a/src/renderer/cockpitView.ts
+++ b/src/renderer/cockpitView.ts
@@ -21,7 +21,7 @@ import { toast, reportSaveError } from './loadError';
import { setActiveUsageProvider } from './usageBar';
import { reportShutdownActivity } from './shutdown';
import { createIcon, type IconName } from './icons';
-import { deckFor, machineName, machineState, onMachineConnected, onMachinesChanged, LOCAL_MACHINE_ID } from './machineDeck';
+import { deckFor, machineName, machineState, onMachineConnected, onMachinesChanged, LOCAL_MACHINE_ID, HOST_TOO_OLD } from './machineDeck';
import { parseRemoteId } from '../shared/link/machine';
import { ptyCompatFor } from './ptyCompat';
import { followPtySize, type TerminalDims } from '../shared/terminalSize';
@@ -85,6 +85,8 @@ interface Live {
updateFailed: boolean;
/** The person asked for that restart while a turn was running; it happens when the turn ends. */
updateWhenIdle: boolean;
+ /** For a session in a parallel workspace (a git worktree): what it has done so far, or null. */
+ worktree: { files: number; commits: number; dirty: number; branch: string } | null;
/** When this terminal last changed size: the repaint that follows is not the agent doing anything. */
resizedAt: number;
}
@@ -1169,7 +1171,7 @@ async function buildTile(p: OpenReq): Promise {
// history to paint, so it writes through from the first byte.
replayPending: adopting ? [] : null,
ptyCap: null,
- updateReady: false, updateFailed: false, updateWhenIdle: false, resizedAt: 0,
+ updateReady: false, updateFailed: false, updateWhenIdle: false, resizedAt: 0, worktree: null,
});
// Repaint what is already on that terminal. Without this, attaching to work in progress shows a
// blank rectangle until the agent next speaks — which, while it is thinking, can be minutes, and
@@ -1634,6 +1636,70 @@ async function refreshMeta(id: string): Promise {
// hands re-selected everything they had typed (and broke an IME composition mid-syllable).
if (!editingId) { renderList(); renderHeader(); }
}
+
+/** A session's folder is one of DevDeck's parallel workspaces: `/.claude/worktrees/`. */
+const WORKTREE_PATH_RE = /^(.*)[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)$/;
+
+/** Refresh what a workspace session has done, for its header. */
+async function refreshWorktree(id: string): Promise {
+ const l = live.get(id); if (!l || !WORKTREE_PATH_RE.test(l.session.projectPath)) return;
+ const info = await deckFor(l.machineId).cockpit.worktreeInfo(l.session.projectPath).catch(() => null);
+ const next = info ? { files: info.changes.files, commits: info.changes.commits, dirty: info.changes.dirty, branch: info.worktree.branch } : null;
+ if (JSON.stringify(next) === JSON.stringify(l.worktree)) return;
+ l.worktree = next;
+ if (l.session.id === selectedId && !editingId) renderHeader();
+}
+
+/** "New session in a separate workspace": a git worktree on its own branch, and a session in it. */
+async function addSessionInWorktree(): Promise {
+ const l = selectedId ? live.get(selectedId) : null; if (!l) return;
+ const s = l.session;
+ // From a workspace session, the new one starts from the same project, not inside the workspace.
+ const repo = WORKTREE_PATH_RE.exec(s.projectPath)?.[1] ?? s.projectPath;
+ toast(tr('cockpit.worktree_creating'));
+ const res: { worktree?: import('../main/worktrees').Worktree; error?: string } = await deckFor(l.machineId).cockpit.createWorktree(repo).catch((e: unknown) => ({ error: String(e) }));
+ if (!res.worktree) {
+ toast(res.error === HOST_TOO_OLD ? tr('cockpit.worktree_host_too_old', { machine: machineName(l.machineId) }) : tr('cockpit.worktree_failed', { reason: res.error ?? '' }));
+ return;
+ }
+ const w = res.worktree;
+ const name = `${basename(repo)} ⎇ ${w.branch.replace(/^devdeck\//, '')}`;
+ await createSession({ path: w.path, name, staleLevel: s.staleLevel, branch: w.branch, dirty: 0, mode: 'new', agentId: s.agentId, machineId: l.machineId });
+ toast(tr('cockpit.worktree_created', { branch: w.branch }));
+}
+
+/**
+ * Clean up a workspace: close its session, then remove the folder and its branch. What would be lost
+ * is said in the one question asked; work that exists nowhere else goes only on an explicit yes.
+ */
+async function cleanUpWorktree(id: string): Promise {
+ const l = live.get(id); if (!l) return;
+ const deck = deckFor(l.machineId);
+ const path = l.session.projectPath;
+ const info = await deck.cockpit.worktreeInfo(path).catch(() => null);
+ if (!info) { toast(tr('cockpit.worktree_unknown')); return; }
+ const c = info.changes;
+ const losing = c.dirty > 0 || c.commits > 0;
+ const base = info.worktree.baseBranch ?? info.worktree.baseCommit.slice(0, 8);
+ // Only what is actually there: "0 commits" is not something that will be lost.
+ const what = [
+ c.dirty > 0 ? tr('cockpit.worktree_part_dirty', { n: String(c.dirty) }) : '',
+ c.commits > 0 ? tr('cockpit.worktree_part_commits', { n: String(c.commits), base }) : '',
+ ].filter(Boolean).join(', ');
+ const question = losing
+ ? tr('cockpit.worktree_cleanup_losing', { what, branch: info.worktree.branch, base })
+ : tr('cockpit.worktree_cleanup_clean', { branch: info.worktree.branch });
+ if (!(await confirmDialog(question, tr(losing ? 'cockpit.worktree_delete_anyway' : 'cockpit.worktree_cleanup')))) return;
+ closeSession(id);
+ // The session's processes take a moment to end, and Windows holds the folder until they have.
+ let out: { removed: boolean; reason?: string; error?: string } = { removed: false };
+ for (let attempt = 0; attempt < 12; attempt++) {
+ out = await deck.cockpit.removeWorktree(path, losing);
+ if (out.removed || (out.reason !== 'running' && out.reason !== 'error')) break;
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ toast(out.removed ? tr('cockpit.worktree_removed', { branch: info.worktree.branch }) : tr('cockpit.worktree_remove_failed', { reason: out.error ?? out.reason ?? '' }));
+}
/** Re-resolve WHICH on-disk conversation a live tile is actually writing to. /clear starts a
* brand-new session id in the same terminal, so the open-time id goes permanently stale — persisting
* it made a restart/update restore the PAST conversation. Evidence-gated (pickDriftedSessionId in
@@ -1709,7 +1775,7 @@ async function refreshGit(id: string): Promise {
// re-spawning git every tick is pure waste (matters most with many concurrent sessions).
// The provider check runs BEFORE the id check so a re-attributed tile resolves its new id against the
// right store on the same tick (one process listing in main is shared by every tile).
-function refreshAllMeta(): void { if (editingId) return; for (const [id, l] of live) { if (l.session.status === 'exited') continue; void refreshProvider(id).then(() => refreshSessionId(id)); void refreshMeta(id); void refreshGit(id); } }
+function refreshAllMeta(): void { if (editingId) return; for (const [id, l] of live) { if (l.session.status === 'exited') continue; void refreshProvider(id).then(() => refreshSessionId(id)); void refreshMeta(id); void refreshGit(id); void refreshWorktree(id); } }
/**
* Hide every terminal in the pane, by DOM rather than by walking `live`.
@@ -1738,6 +1804,7 @@ function select(id: string): void {
renderList(); renderHeader();
void refreshMeta(id);
void refreshGit(id);
+ void refreshWorktree(id);
void refreshImages(); // renderHeader pointed the strip at this session; a re-select re-reads it too
markSelectedTerminal();
void refreshTimeline();
@@ -2324,6 +2391,24 @@ function renderHeader(): void {
if (l.meta && l.meta.activeMs > 0) { const tp = document.createElement('span'); tp.className = 'ck-pill'; tp.append(createIcon('clock'), formatDuration(l.meta.activeMs)); pills.push(tp); }
const sp = document.createElement('span'); sp.className = 'sp';
const newSession = actBtn('plus', tr('cockpit.new_session'), () => void addSessionToCurrentProject());
+ // Parallel work on the same project, each in its own git worktree — offered for a git project only.
+ const workspace: HTMLElement[] = [];
+ if (s.branch) {
+ const b = actBtn('branch', tr('cockpit.worktree_new'), () => void addSessionInWorktree());
+ b.classList.add('ck-wt-new');
+ newDot(b, 'worktree-sessions');
+ workspace.push(b);
+ }
+ if (WORKTREE_PATH_RE.test(s.projectPath)) {
+ const wt = l.worktree;
+ const pill = document.createElement('span'); pill.className = 'ck-pill ck-wt-pill';
+ pill.append(createIcon('branch'), wt ? tr('cockpit.worktree_pill', { files: String(wt.files), commits: String(wt.commits) }) : tr('cockpit.worktree_pill_plain'));
+ pill.title = [wt?.branch ?? '', tr('cockpit.worktree_pill_title')].filter(Boolean).join('\n');
+ pills.push(pill);
+ const clean = actBtn('trash', tr('cockpit.worktree_cleanup_button'), () => void cleanUpWorktree(s.id));
+ clean.classList.add('ck-wt-clean');
+ workspace.push(clean);
+ }
const pin = actBtn('pin', tr(l.pinned ? 'cockpit.unpin' : 'cockpit.pin'), () => togglePin(s.id));
const rename = actBtn('edit', tr('cockpit.rename'), () => beginRename(s.id));
const folder = actBtn('folder', tr('cockpit.open_folder'), () => {
@@ -2391,7 +2476,7 @@ function renderHeader(): void {
b.addEventListener('click', () => void updateAllClaude());
update.push(b);
}
- headerEl.append(...pills, sp, ...update, reply, ...timeline, ...images, attach, newSession, pin, rename, folder, restart, close);
+ headerEl.append(...pills, sp, ...update, reply, ...timeline, ...images, attach, newSession, ...workspace, pin, rename, folder, restart, close);
}
/** "+ New session": spawn another, fresh conversation in the SAME project as the selected session. */
diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css
index 1044b37..232b07f 100644
--- a/src/renderer/features/cockpit/cockpit.css
+++ b/src/renderer/features/cockpit/cockpit.css
@@ -669,3 +669,19 @@
@media (prefers-reduced-motion: reduce) {
#view-cockpit .ck-status-pill.st-working .ck-status-dot { animation: none; }
}
+
+/* ---- parallel workspaces (git worktrees) ---- */
+#view-cockpit .ck-wt-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-family: var(--dd-font-sans);
+ font-size: 12px;
+ color: #c4a7ff;
+ border-color: color-mix(in srgb, #c4a7ff 45%, transparent);
+}
+
+#view-cockpit .ck-wt-pill svg {
+ width: 13px;
+ height: 13px;
+}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 7778e53..6efdda2 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -66,6 +66,9 @@ declare global {
gitInfo(projectPath: string): Promise<{ branch: string | null; dirty: number } | null>;
receiveImage(base64: string): Promise;
receiveFile(name: string, base64: string): Promise;
+ createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>;
+ worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>;
+ removeWorktree(path: string, force: boolean): Promise<{ removed: boolean; reason?: string; error?: string }>;
sessionTurns(projectPath: string, sessionId: string, agentId?: string): Promise;
sessionImages(projectPath: string, sessionId: string, agentId?: string): Promise;
sessionImage(projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full'): Promise;
@@ -162,6 +165,9 @@ declare global {
openFile(projectPath: string, filePath: string): Promise;
receiveImage(base64: string): Promise;
receiveFile(name: string, base64: string): Promise;
+ createWorktree(repoPath: string): Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>;
+ worktreeInfo(path: string): Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>;
+ removeWorktree(path: string, force: boolean): Promise<{ removed: boolean; reason?: string; error?: string }>;
sessionTurns(projectPath: string, sessionId: string, agentId?: string): Promise;
sessionImages(projectPath: string, sessionId: string, agentId?: string): Promise;
sessionImage(projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full'): Promise;
diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts
index 31f1c94..096a1ff 100644
--- a/src/renderer/icons.ts
+++ b/src/renderer/icons.ts
@@ -2,7 +2,7 @@ export type IconName =
| 'search' | 'projects' | 'sessions' | 'tasks' | 'usage'
| 'settings' | 'refresh' | 'more' | 'play' | 'panel-left'
| 'folder' | 'plus' | 'restart' | 'close' | 'pin' | 'edit' | 'brain' | 'clock'
- | 'trash' | 'chevron-left' | 'chevron-right' | 'chevron-down' | 'minimize' | 'maximize' | 'restore' | 'moon' | 'power' | 'globe' | 'machine' | 'image' | 'paperclip' | 'download' | 'copy' | 'terminal' | 'reply' | 'timeline' | 'pen' | 'send' | 'undo' | 'square' | 'arrow';
+ | 'trash' | 'chevron-left' | 'chevron-right' | 'chevron-down' | 'minimize' | 'maximize' | 'restore' | 'moon' | 'power' | 'globe' | 'machine' | 'image' | 'paperclip' | 'download' | 'copy' | 'terminal' | 'reply' | 'timeline' | 'pen' | 'send' | 'undo' | 'square' | 'arrow' | 'branch';
const paths: Record = {
search: '',
@@ -50,6 +50,7 @@ const paths: Record = {
undo: '',
square: '',
arrow: '',
+ branch: '',
};
export function iconMarkup(name: IconName): string {
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json
index 54b2718..65aed2c 100644
--- a/src/renderer/locales/en.json
+++ b/src/renderer/locales/en.json
@@ -612,5 +612,26 @@
"whatsnew.f.image-strip.where": "The image button in a session's header",
"whatsnew.f.update-all.title": "Update all Claude sessions at once",
"whatsnew.f.update-all.body": "When Claude Code has an update, one button stops every session, updates it and brings the same conversations back.",
- "whatsnew.f.update-all.where": "Appears in the session header when an update is waiting"
+ "whatsnew.f.update-all.where": "Appears in the session header when an update is waiting",
+ "cockpit.worktree_new": "New session in a separate workspace (its own git branch and folder)",
+ "cockpit.worktree_creating": "Creating a separate workspace…",
+ "cockpit.worktree_created": "Separate workspace ready on branch {branch}",
+ "cockpit.worktree_failed": "Could not create a separate workspace — {reason}",
+ "cockpit.worktree_host_too_old": "DevDeck on {machine} is too old for separate workspaces — update it to 1.43.0.",
+ "cockpit.worktree_pill": "{files} files · {commits} commits",
+ "cockpit.worktree_pill_plain": "Workspace",
+ "cockpit.worktree_pill_title": "A separate workspace: its own folder and git branch, so it never touches the project's other sessions.",
+ "cockpit.worktree_cleanup_button": "Clean up this workspace",
+ "cockpit.worktree_cleanup": "Clean up",
+ "cockpit.worktree_delete_anyway": "Delete it anyway",
+ "cockpit.worktree_cleanup_clean": "Close this session and delete its workspace folder and branch {branch}? It holds no changes of its own.",
+ "cockpit.worktree_cleanup_losing": "This workspace holds {what}. Cleaning up deletes them with the folder and branch {branch}. To keep them, cancel and ask a session on {base} to merge {branch} first.",
+ "cockpit.worktree_removed": "Workspace {branch} cleaned up",
+ "cockpit.worktree_remove_failed": "The workspace was not removed — {reason}",
+ "cockpit.worktree_unknown": "This workspace was not created by this DevDeck, so it is left alone.",
+ "whatsnew.f.worktree-sessions.title": "Parallel sessions in separate workspaces",
+ "whatsnew.f.worktree-sessions.body": "Start another session on the same project in its own folder and git branch, so two agents never edit the same files. The header shows what it has changed; cleaning up warns before anything unmerged is lost.",
+ "whatsnew.f.worktree-sessions.where": "The branch button in a session's header",
+ "cockpit.worktree_part_dirty": "{n} uncommitted changes",
+ "cockpit.worktree_part_commits": "{n} commits that {base} does not have"
}
diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json
index b1cbdae..84bc3ee 100644
--- a/src/renderer/locales/ja.json
+++ b/src/renderer/locales/ja.json
@@ -612,5 +612,26 @@
"whatsnew.f.image-strip.where": "セッションヘッダーの画像ボタン",
"whatsnew.f.update-all.title": "Claude セッションをまとめて更新",
"whatsnew.f.update-all.body": "Claude Code の更新があると、ボタン 1 つで全セッションを止めて更新し、同じ会話を開き直します。",
- "whatsnew.f.update-all.where": "更新待ちのときにセッションヘッダーに表示されます"
+ "whatsnew.f.update-all.where": "更新待ちのときにセッションヘッダーに表示されます",
+ "cockpit.worktree_new": "別の作業スペースで新しいセッション (専用の git ブランチとフォルダー)",
+ "cockpit.worktree_creating": "別の作業スペースを作成中…",
+ "cockpit.worktree_created": "別の作業スペースの準備完了 — ブランチ {branch}",
+ "cockpit.worktree_failed": "別の作業スペースを作成できませんでした — {reason}",
+ "cockpit.worktree_host_too_old": "{machine} の DevDeck は別の作業スペースに対応していません — 1.43.0 に更新してください。",
+ "cockpit.worktree_pill": "ファイル {files} · コミット {commits}",
+ "cockpit.worktree_pill_plain": "作業スペース",
+ "cockpit.worktree_pill_title": "別の作業スペース: 専用のフォルダーと git ブランチを使うので、同じプロジェクトの他のセッションとファイルが重なりません。",
+ "cockpit.worktree_cleanup_button": "この作業スペースを片付ける",
+ "cockpit.worktree_cleanup": "片付ける",
+ "cockpit.worktree_delete_anyway": "それでも削除",
+ "cockpit.worktree_cleanup_clean": "このセッションを閉じて、作業スペースのフォルダーとブランチ {branch} を削除しますか? 独自の変更はありません。",
+ "cockpit.worktree_cleanup_losing": "この作業スペースには {what} があります。片付けるとフォルダー・ブランチ {branch} と一緒に削除されます。残すにはキャンセルし、先に {base} 側のセッションに「{branch} をマージして」と頼んでください。",
+ "cockpit.worktree_removed": "作業スペース {branch} を片付けました",
+ "cockpit.worktree_remove_failed": "作業スペースを削除できませんでした — {reason}",
+ "cockpit.worktree_unknown": "この DevDeck が作った作業スペースではないため、触れません。",
+ "whatsnew.f.worktree-sessions.title": "別の作業スペースで並行セッション",
+ "whatsnew.f.worktree-sessions.body": "同じプロジェクトにもう 1 つセッションを開きますが、専用のフォルダーと git ブランチを使うので 2 つのエージェントが同じファイルを触りません。ヘッダーに変更ファイル数とコミット数が表示され、片付ける前に未マージの作業があれば警告します。",
+ "whatsnew.f.worktree-sessions.where": "セッションヘッダーのブランチボタン",
+ "cockpit.worktree_part_dirty": "未コミットの変更 {n} 件",
+ "cockpit.worktree_part_commits": "{base} にないコミット {n} 件"
}
diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json
index 96ebe2c..47f719f 100644
--- a/src/renderer/locales/ko.json
+++ b/src/renderer/locales/ko.json
@@ -612,5 +612,26 @@
"whatsnew.f.image-strip.where": "세션 상단의 이미지 버튼",
"whatsnew.f.update-all.title": "Claude 세션 한 번에 업데이트",
"whatsnew.f.update-all.body": "Claude Code 업데이트가 있으면 버튼 하나로 모든 세션을 멈추고 업데이트한 뒤 같은 대화로 다시 엽니다.",
- "whatsnew.f.update-all.where": "업데이트가 대기 중일 때 세션 상단에 나타납니다"
+ "whatsnew.f.update-all.where": "업데이트가 대기 중일 때 세션 상단에 나타납니다",
+ "cockpit.worktree_new": "별도 작업 공간에서 새 세션 (자체 git 브랜치와 폴더)",
+ "cockpit.worktree_creating": "별도 작업 공간을 만드는 중…",
+ "cockpit.worktree_created": "별도 작업 공간 준비 완료 — 브랜치 {branch}",
+ "cockpit.worktree_failed": "별도 작업 공간을 만들지 못했습니다 — {reason}",
+ "cockpit.worktree_host_too_old": "{machine}의 DevDeck이 별도 작업 공간을 지원하지 않는 버전입니다 — 1.43.0으로 업데이트하세요.",
+ "cockpit.worktree_pill": "파일 {files} · 커밋 {commits}",
+ "cockpit.worktree_pill_plain": "작업 공간",
+ "cockpit.worktree_pill_title": "별도 작업 공간: 자체 폴더와 git 브랜치를 써서 같은 프로젝트의 다른 세션과 파일이 겹치지 않습니다.",
+ "cockpit.worktree_cleanup_button": "이 작업 공간 정리",
+ "cockpit.worktree_cleanup": "정리",
+ "cockpit.worktree_delete_anyway": "그래도 삭제",
+ "cockpit.worktree_cleanup_clean": "이 세션을 닫고 작업 공간 폴더와 브랜치 {branch}를 삭제할까요? 자체 변경 사항은 없습니다.",
+ "cockpit.worktree_cleanup_losing": "이 작업 공간에는 {what}가 있습니다. 정리하면 폴더·브랜치 {branch}와 함께 삭제됩니다. 남기려면 취소하고, 먼저 {base} 쪽 세션에 \"{branch} 병합해줘\"라고 요청하세요.",
+ "cockpit.worktree_removed": "작업 공간 {branch}를 정리했습니다",
+ "cockpit.worktree_remove_failed": "작업 공간을 삭제하지 못했습니다 — {reason}",
+ "cockpit.worktree_unknown": "이 DevDeck이 만든 작업 공간이 아니라서 건드리지 않습니다.",
+ "whatsnew.f.worktree-sessions.title": "별도 작업 공간에서 병렬 세션",
+ "whatsnew.f.worktree-sessions.body": "같은 프로젝트에 세션을 하나 더 띄우되, 자체 폴더와 git 브랜치를 써서 두 에이전트가 같은 파일을 건드리지 않습니다. 상단에 바뀐 파일·커밋 수가 보이고, 정리할 때 병합 안 된 작업이 있으면 먼저 경고합니다.",
+ "whatsnew.f.worktree-sessions.where": "세션 상단의 브랜치 버튼",
+ "cockpit.worktree_part_dirty": "커밋하지 않은 변경 {n}개",
+ "cockpit.worktree_part_commits": "{base}에 없는 커밋 {n}개"
}
diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json
index a0baca2..ee7b299 100644
--- a/src/renderer/locales/zh.json
+++ b/src/renderer/locales/zh.json
@@ -612,5 +612,26 @@
"whatsnew.f.image-strip.where": "会话顶部的图片按钮",
"whatsnew.f.update-all.title": "一次更新所有 Claude 会话",
"whatsnew.f.update-all.body": "Claude Code 有更新时,一个按钮即可停止所有会话、完成更新并重新打开相同的对话。",
- "whatsnew.f.update-all.where": "有更新等待时出现在会话顶部"
+ "whatsnew.f.update-all.where": "有更新等待时出现在会话顶部",
+ "cockpit.worktree_new": "在独立工作区中新建会话 (独立的 git 分支和文件夹)",
+ "cockpit.worktree_creating": "正在创建独立工作区…",
+ "cockpit.worktree_created": "独立工作区已就绪 — 分支 {branch}",
+ "cockpit.worktree_failed": "无法创建独立工作区 — {reason}",
+ "cockpit.worktree_host_too_old": "{machine} 上的 DevDeck 版本过旧,不支持独立工作区 — 请更新到 1.43.0。",
+ "cockpit.worktree_pill": "{files} 个文件 · {commits} 个提交",
+ "cockpit.worktree_pill_plain": "工作区",
+ "cockpit.worktree_pill_title": "独立工作区:使用自己的文件夹和 git 分支,不会与同一项目的其他会话改到相同文件。",
+ "cockpit.worktree_cleanup_button": "清理此工作区",
+ "cockpit.worktree_cleanup": "清理",
+ "cockpit.worktree_delete_anyway": "仍然删除",
+ "cockpit.worktree_cleanup_clean": "关闭此会话并删除工作区文件夹和分支 {branch}?其中没有自己的改动。",
+ "cockpit.worktree_cleanup_losing": "此工作区有 {what}。清理会将它们连同文件夹和分支 {branch} 一起删除。若要保留,请取消,并先让 {base} 上的会话合并 {branch}。",
+ "cockpit.worktree_removed": "已清理工作区 {branch}",
+ "cockpit.worktree_remove_failed": "未能删除工作区 — {reason}",
+ "cockpit.worktree_unknown": "此工作区不是这个 DevDeck 创建的,因此不做处理。",
+ "whatsnew.f.worktree-sessions.title": "在独立工作区中并行会话",
+ "whatsnew.f.worktree-sessions.body": "在同一项目上再开一个会话,但使用独立的文件夹和 git 分支,两个智能体不会改到相同文件。顶部显示改动的文件数和提交数;清理前若有未合并的工作会先提醒。",
+ "whatsnew.f.worktree-sessions.where": "会话顶部的分支按钮",
+ "cockpit.worktree_part_dirty": "{n} 处未提交的改动",
+ "cockpit.worktree_part_commits": "{n} 个 {base} 没有的提交"
}
diff --git a/src/renderer/machineDeck.ts b/src/renderer/machineDeck.ts
index eee4512..f872fef 100644
--- a/src/renderer/machineDeck.ts
+++ b/src/renderer/machineDeck.ts
@@ -45,6 +45,9 @@ export interface DeckFacade {
gitInfo: (projectPath: string) => Promise<{ branch: string | null; dirty: number } | null>;
receiveImage: (base64: string) => Promise;
receiveFile: (name: string, base64: string) => Promise;
+ createWorktree: (repoPath: string) => Promise<{ worktree?: import('../main/worktrees').Worktree; error?: string }>;
+ worktreeInfo: (path: string) => Promise<{ worktree: import('../main/worktrees').Worktree; changes: import('../main/worktrees').WorktreeChanges } | null>;
+ removeWorktree: (path: string, force: boolean) => Promise<{ removed: boolean; reason?: string; error?: string }>;
sessionTurns: (projectPath: string, sessionId: string, agentId?: string) => Promise;
sessionImages: (projectPath: string, sessionId: string, agentId?: string) => Promise;
sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => Promise;
@@ -109,6 +112,10 @@ export function deckFor(machineId: string): DeckFacade {
receiveImage: (base64) => remote.cockpit.receiveImage(base64),
// An older host has no receiveFile: its 'no such method' is passed on, so the sender can say why.
receiveFile: (name, base64) => remote.cockpit.receiveFile(name, base64),
+ // An older host has no workspaces: creating one says so, reading one finds none.
+ createWorktree: (repoPath) => remote.cockpit.createWorktree(repoPath).catch((error: unknown) => ({ error: /no such method/.test(String(error)) ? HOST_TOO_OLD : String(error) })),
+ worktreeInfo: (path) => remote.cockpit.worktreeInfo(path).catch(() => null),
+ removeWorktree: (path, force) => remote.cockpit.removeWorktree(path, force).catch((error: unknown) => ({ removed: false, reason: 'error', error: String(error) })),
// A host older than the timeline is said so (the panel tells the person to update it); any other
// failure is an empty list for now, read again on the next turn.
sessionTurns: (projectPath, sessionId, agentId) => remote.cockpit.sessionTurns(projectPath, sessionId, agentId).catch((error: unknown) => {
diff --git a/src/renderer/whatsNew.test.ts b/src/renderer/whatsNew.test.ts
index 599fd0b..579fcbb 100644
--- a/src/renderer/whatsNew.test.ts
+++ b/src/renderer/whatsNew.test.ts
@@ -13,9 +13,9 @@ describe('when the What\'s new window opens', () => {
});
it('lists only what is newer than what was last shown, newest first', () => {
- expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.42.0']);
+ expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.43.0', '1.42.0']);
const all = featuresAfter(null).map((f) => f.version);
- expect(all[0]).toBe('1.42.0');
+ expect(all[0]).toBe('1.43.0');
expect([...all].sort((a, b) => compareVersions(b, a))).toEqual(all);
});
diff --git a/src/renderer/whatsNew.ts b/src/renderer/whatsNew.ts
index 131a290..d0ebf16 100644
--- a/src/renderer/whatsNew.ts
+++ b/src/renderer/whatsNew.ts
@@ -24,6 +24,7 @@ export interface Feature {
/** Newest first. A feature's NEW dot is shown on its `target` until the person uses it. */
export const FEATURES: readonly Feature[] = [
+ { id: 'worktree-sessions', version: '1.43.0', icon: 'branch', target: { view: 'cockpit', selector: '#ck-header .ck-wt-new' } },
{ id: 'program-status', version: '1.42.0', icon: 'terminal', target: { view: 'settings', selector: '#set-program-status' } },
{ id: 'quick-replies', version: '1.41.0', icon: 'reply', target: { view: 'cockpit', selector: '#ck-header .ck-qr-btn' } },
{ id: 'timeline', version: '1.41.0', icon: 'timeline', target: { view: 'cockpit', selector: '#ck-header .ck-tl-toggle' } },
diff --git a/src/shared/pathGuard.test.ts b/src/shared/pathGuard.test.ts
index 79fea7a..bbe3ae6 100644
--- a/src/shared/pathGuard.test.ts
+++ b/src/shared/pathGuard.test.ts
@@ -142,3 +142,15 @@ describe('AGENT_OPEN_EXT (click-to-open allowlist)', () => {
}
});
});
+
+describe('a repo\'s parallel-session workspaces', () => {
+ const repo = resolve('/w/proj');
+ const folders = [{ path: repo, kind: 'repo' as const }];
+ it('are admitted with the repo, one level under .claude/worktrees and nowhere else in it', () => {
+ expect(isAllowedPath(folders, join(repo, '.claude', 'worktrees', '1021-0632-abc'))).toBe(true);
+ expect(isAllowedPath(folders, join(repo, '.claude', 'worktrees'))).toBe(false);
+ expect(isAllowedPath(folders, join(repo, '.claude', 'worktrees', 'a', 'b'))).toBe(false);
+ expect(isAllowedPath(folders, join(repo, '.claude', 'worktrees', '..', '..', '..', 'other'))).toBe(false);
+ expect(isAllowedPath(folders, join(repo, 'src'))).toBe(false);
+ });
+});
diff --git a/src/shared/pathGuard.ts b/src/shared/pathGuard.ts
index 984157d..b97c306 100644
--- a/src/shared/pathGuard.ts
+++ b/src/shared/pathGuard.ts
@@ -40,10 +40,23 @@ function isWithin(base: string, incoming: string): boolean {
*/
export function isAllowedPath(folders: Folder[], incoming: string): boolean {
return folders.some((f) => (f.kind === 'repo'
- ? canon(incoming) === canon(f.path)
+ ? canon(incoming) === canon(f.path) || isWorktreeFolderOf(f.path, incoming)
: isWithin(f.path, incoming)));
}
+/**
+ * A repo's parallel-session workspace — `/.claude/worktrees/`, exactly one level down —
+ * is the same project on another branch, so it is admitted wherever the repo is. Nothing else inside
+ * the repo is.
+ */
+function isWorktreeFolderOf(repo: string, incoming: string): boolean {
+ const prefix = canon(repo) + sep + ['.claude', 'worktrees'].join(sep) + sep;
+ const r = canon(incoming);
+ if (!r.startsWith(prefix)) return false;
+ const name = r.slice(prefix.length);
+ return name.length > 0 && !name.includes(sep) && name !== '..' && name !== '.';
+}
+
/**
* FILE-access guard: a file may be acted on (e.g. click-to-open an image the agent printed) when it
* lives anywhere under a configured folder — including inside a registered individual repo, which