Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC

</div>

> **1.45.1** stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.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).
> **1.45.2** stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.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).

<div align="center">
<img src="docs/screenshots/projects.png" width="820" alt="DevDeck project overview with repository and session navigation" />
Expand Down
13 changes: 13 additions & 0 deletions docs/releases/1.45.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# DevDeck 1.45.2

## An update no longer doubles the sessions running on a paired PC

After **Update all** (or any update relaunch), every session on the other PC came back twice: the one that was already running there, and a new, empty one under the same name. Each update doubled them — measured 7 → 14 → 29 sessions running on one machine across two updates, every extra one a whole Claude process.

- **Cause:** two things reopened the same saved tile at once. When this PC relaunched after the update, the other PC — which had kept running — announced its sessions, and DevDeck adopted each one as a tile. At the same time the update relaunch walked its list of saved tiles and restored them one by one, including the ones on the other PC. By the time a remote tile's turn came, its conversation was already a tile here (the adopted one), and the restore read that as "this conversation is open in another tile, so start a fresh one" — a new conversation on the other PC, carrying the same name and label.
- **Fix:** an update relaunch restores only this PC's own tiles. A tile on another PC is left as a saved entry and comes back by adoption the moment that PC announces it, keeping its label and pin. If that PC is updating too, it restores its own tiles on its launch and announces them — same result, no duplicates.
- **Also:** any restore — a click on a previous session, Restore all, the launch restore — now stops when the conversation is already a live tile here, instead of starting a second one. The log says so: `already open here as <id>; nothing started`.

## Cleaning up what earlier versions left behind

The duplicates created before this fix are still running on the other PC. They are the empty sessions with no history. In the cockpit sidebar, Ctrl/Shift-click them and close them together. Each closed one frees a Claude process on that PC.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devdeck",
"version": "1.45.1",
"version": "1.45.2",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
Expand Down
59 changes: 40 additions & 19 deletions src/renderer/cockpitView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { formatDuration } from '../shared/usage';
import { decideKeyAction, selectionCellLength } from '../shared/terminalKeys';
import { unwrapCopiedUrl } from '../shared/urlCopy';
import { findUrlLinks, findFilePathLinks, type BufferRow } from '../shared/linkWrap';
import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId, legacyCockpitNavigationId, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, removeAutoRestoreMatches, sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist';
import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId, legacyCockpitNavigationId, persistedSessionKey, partitionLaunchRestore, takeLaunchRestoreCandidates, takeUpdateRestoreCandidates, removeAutoRestoreMatches, sanitizePersistedList, resolveRestoreTarget, adoptRestorableMatch, type PersistedSession } from '../shared/cockpitPersist';
import { OPEN_FOLDER_MISSING, toAgentId, type AgentId, type OpenMode, type StaleLevel } from '../shared/types';
import { createProviderLogo, providerName } from './providerLogo';
import { tr, currentLang } from './i18n-runtime';
Expand Down Expand Up @@ -332,20 +332,14 @@ export function mountCockpit(): void {
* latest conversation (via restoreSession). They're removed from the "Previous" list first so they
* aren't shown as restorable AND opened. Sequential to avoid a simultaneous PTY burst. */
async function autoRestoreAfterUpdate(pending: PersistedSession[]): Promise<void> {
// Entries on a machine that is not reachable right now stay saved — they come back by adoption the
// moment that machine connects and is found running them, or by a click. Asking now would only
// produce one "offline" toast per entry; one per machine is enough.
const offlineByMachine = new Map<string, number>();
const now = pending.filter((entry) => {
const machineId = entry.machineId ?? LOCAL_MACHINE_ID;
if (machineId === LOCAL_MACHINE_ID || machineState(machineId) === 'connected') return true;
offlineByMachine.set(machineId, (offlineByMachine.get(machineId) ?? 0) + 1);
return false;
});
for (const [machineId, n] of offlineByMachine) toast(tr('cockpit.restore_machine_offline_n', { n: String(n), machine: machineName(machineId) }));
restorable = removeAutoRestoreMatches(restorable, now);
// Only this machine's tiles (takeUpdateRestoreCandidates says why). A remote tile stays a saved
// entry and comes back by adoption when its machine announces it — restoring it from here too raced
// that adoption and started a second conversation on that machine, once per entry, per update.
const { own, remote } = takeUpdateRestoreCandidates(pending);
if (remote.length) window.devdeck.logDiagnostic(`update restore: ${own.length} tile(s) of this pc restored here; ${remote.length} remote tile(s) left to adoption`, 'info', 'restore');
restorable = removeAutoRestoreMatches(restorable, own);
renderList();
await restoreInTurn(now);
await restoreInTurn(own);
}

const START_QUIET_MS = 1500;
Expand Down Expand Up @@ -2857,6 +2851,19 @@ async function deliverPendingCloses(machineId: string): Promise<void> {
// tile, and now that an unopenable conversation comes back fresh, as an empty one.
const restoring = new Set<string>();

/**
* The live tile already holding a saved entry's conversation on its machine, if any. Typically an
* adoption that landed while the entry waited its turn in a restore loop: the machine announced the
* terminal, syncMachineSessions built a tile for it. The restore is then already DONE — opening anything
* now would be a second conversation under the same name on that machine (which is how two consecutive
* updates took the paired machine from 7 to 29 running sessions).
*/
function liveTileHolding(entry: PersistedSession): Live | undefined {
if (!entry.sessionId) return undefined;
return [...live.values()].find((l) => l.openedSessionId === entry.sessionId && l.session.status !== 'exited'
&& sameScope({ machineId: l.machineId, projectPath: l.session.projectPath, agentId: l.session.agentId }, entry));
}

/** Bring a previous session back to life via its resume command — under the SAME provider it was
* opened with, whatever the globally selected agent is now (a Claude conversation must never be
* handed to `codex`). Unrecognized legacy ids fall back to the active agent, as they always did. */
Expand All @@ -2871,14 +2878,21 @@ async function restoreSession(entry: PersistedSession): Promise<void> {
// a FRESH one under the same name. Never a substitute: this tile carries the user's own label and
// pin, and handing it someone else's conversation is what made a renamed tile come back showing
// unrelated work (and silently ate the entry that really owned that conversation).
const liveIds = new Set(liveSessionsForPersist().filter(l => sameScope(l, entry)).map(l => l.sessionId).filter((x): x is string => !!x));
// Conversations the other not-yet-restored entries are waiting for — an id-less entry must not
// take one of those out from under them.
const reserved = new Set(restorable.filter(r => sameScope(r, entry)).map((r) => r.sessionId).filter((x): x is string => !!x));
// A saved entry names a project by PATH, and the same path exists on both machines — so the
// conversation list has to come from the machine the tile actually ran on. Reading it locally
// would resolve the tile against unrelated work that happens to live at the same path.
const machineId = entry.machineId ?? LOCAL_MACHINE_ID;
const where = machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId);
// Already a live tile here (adopted from its machine's announcement while this entry waited its
// turn): the restore is done. The entry is consumed — its pin carries over — and nothing starts.
// Checked again below after each await, because that adoption can land at any of them.
const merged = (held: Live, when: string): void => {
if (entry.pinned && !held.pinned) held.pinned = true;
persist(); renderList();
window.devdeck.logDiagnostic(`restore ${entry.label || entry.name} on ${where}: saved=${entry.sessionId} -> already open here as ${held.session.id} (${when}); nothing started`, 'info', 'restore');
};
const heldNow = liveTileHolding(entry);
if (heldNow) { merged(heldNow, 'before asking'); return; }
if (machineId !== LOCAL_MACHINE_ID && machineState(machineId) !== 'connected') {
// Its machine is not reachable. Leave the entry saved and say so, rather than opening a local
// terminal in a path that means something different here.
Expand Down Expand Up @@ -2915,13 +2929,20 @@ async function restoreSession(entry: PersistedSession): Promise<void> {
toast(tr('cockpit.restore_machine_offline', { name: entry.label || entry.name, machine: machineName(machineId) }));
throw new Error('could not read the conversation list');
}
// Last look before anything starts: the awaits above are where an adoption lands.
const heldMeanwhile = liveTileHolding(entry);
if (heldMeanwhile) { merged(heldMeanwhile, 'while asking'); return; }
const liveIds = new Set(liveSessionsForPersist().filter(l => sameScope(l, entry)).map(l => l.sessionId).filter((x): x is string => !!x));
// Conversations the other not-yet-restored entries are waiting for — an id-less entry must not
// take one of those out from under them.
const reserved = new Set(restorable.filter(r => sameScope(r, entry)).map((r) => r.sessionId).filter((x): x is string => !!x));
const target = resolveRestoreTarget(entry, ids, liveIds, reserved);
const ok = await createSession({ path: entry.projectPath, name: entry.name, staleLevel: 'neutral', branch: null, dirty: 0, tileId: entry.tileId, sessionId: target.sessionId, mode: target.fresh ? 'new' : 'auto', label: entry.label ?? null, pinned: entry.pinned, agentId: owner, machineId });
// The decision, on the record. A restore that opens the wrong thing — or opens anything at all
// when it should not have — is invisible from the outside, and reading it back off a machine
// nobody is sitting at was impossible until this line.
window.devdeck.logDiagnostic(
`restore ${entry.label || entry.name} on ${machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId)}: `
`restore ${entry.label || entry.name} on ${where}: `
+ `saved=${entry.sessionId ?? '-'} onDisk=${ids.length} -> ${target.fresh ? 'fresh' : 'resume'} ${target.sessionId ?? '(new id)'} ok=${ok}`,
'info', 'restore',
);
Expand Down
1 change: 0 additions & 1 deletion src/renderer/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,6 @@
"cockpit.restore_machine_offline": "Cannot restore “{name}” — {machine} is not reachable. It stays saved.",
"cockpit.prev_machine_offline": "offline — restores when it connects",
"cockpit.machine_offline_input": "{machine} is offline — nothing you type reaches it until it reconnects",
"cockpit.restore_machine_offline_n": "{n} sessions on {machine} will restore when it connects",
"cockpit.restore_idle_left": "{n} sessions unused for over two days stay in previous sessions — click one to open it",
"cockpit.stale_to_previous": "{n} sessions are no longer running on {machine} — moved to previous sessions",
"cockpit.remote_file_unavailable": "That file is on {machine}, not on this computer.",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,6 @@
"cockpit.restore_machine_offline": "「{name}」を復元できません — {machine} に接続できません。項目は保持されます。",
"cockpit.prev_machine_offline": "オフライン — 接続時に復元",
"cockpit.machine_offline_input": "{machine} はオフラインです — 再接続まで入力は届きません",
"cockpit.restore_machine_offline_n": "{machine} の {n} 件のセッションは接続時に復元されます",
"cockpit.restore_idle_left": "2 日以上使われていないセッション {n} 件は以前のセッションに残しました — クリックで開きます",
"cockpit.stale_to_previous": "{machine} で実行されていないセッション {n} 件を以前のセッションへ移動しました",
"cockpit.remote_file_unavailable": "そのファイルはこの PC ではなく {machine} にあります。",
Expand Down
1 change: 0 additions & 1 deletion src/renderer/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,6 @@
"cockpit.restore_machine_offline": "“{name}”을(를) 복원할 수 없습니다 — {machine}에 연결되지 않습니다. 항목은 그대로 남습니다.",
"cockpit.prev_machine_offline": "오프라인 — 연결되면 복원",
"cockpit.machine_offline_input": "{machine}이(가) 오프라인입니다 — 다시 연결될 때까지 입력이 전달되지 않습니다",
"cockpit.restore_machine_offline_n": "{machine}의 세션 {n}개는 연결되면 복원됩니다",
"cockpit.restore_idle_left": "이틀 넘게 쓰지 않은 세션 {n}개는 이전 세션에 남겨 두었습니다 — 클릭하면 열립니다",
"cockpit.stale_to_previous": "{machine}에서 더 이상 실행되지 않는 세션 {n}개를 이전 세션으로 옮겼습니다",
"cockpit.remote_file_unavailable": "그 파일은 이 컴퓨터가 아니라 {machine}에 있습니다.",
Expand Down
Loading
Loading