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.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.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
15 changes: 15 additions & 0 deletions docs/releases/1.44.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# DevDeck 1.44.1

## No more "Assertion failed: remove_pty_baton" on quit or Update all

On Windows, closing DevDeck, or running **Update all**, with two or more sessions open could show a Microsoft Visual C++ Runtime Library dialog: `Assertion failed! … conpty.node … Expression: remove_pty_baton(baton->id)`. Other times DevDeck kept running in the background after its window closed, or exited with a crash.

- **Cause:** the terminal library DevDeck uses (node-pty) keeps its open terminals in one list. When a terminal ends, a background thread removes it from that list, without a lock. Two terminals ending at the same moment edited the list at once, and one of them could no longer find itself. A fix exists upstream only in a beta.
- **Fix:** DevDeck now ends its terminals one at a time. Each starts ending only after the previous one has reported its exit, by which time its thread is done with the list. This applies wherever terminals are ended: quitting, Update all, closing a group, and closing a single session.
- **Quitting with many sessions:** this takes up to about 1.5 seconds per session, instead of a fixed 5 seconds. A terminal that never reports its exit holds up the next one for at most 4 seconds.
- **Measured:** the automated Update-all check stopped or crashed at quit on 3 of 20 and 3 of 32 runs before. After the fix it passed 50 of 50.

## Also

- **Accessibility:** the sidebar's buttons are now named by what they show, in the same order, for voice control (WCAG 2.5.3). The group toggles, session rows and project rows are affected. axe-core 4.14 checks this.
- **Development:** esbuild 0.28.2, playwright 1.64.1, axe-core 4.14.0. Test runs no longer leave their temp folders behind.
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.44.0",
"version": "1.44.1",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
Expand Down
34 changes: 28 additions & 6 deletions src/main/ptyHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ describe('PtyHost', () => {
const work = host.shutdown();
expect(host.shutdown()).toBe(work);
expect(a.kill).toHaveBeenCalledTimes(1);
expect(b.kill).toHaveBeenCalledTimes(1);
expect(b.kill).not.toHaveBeenCalled(); // waits for a to finish exiting
expect(() => host.create('c', 'cmd', [], '.', 80, 24, () => {}, () => {})).toThrow('shutting down');
let finished = false;
void work.then(() => { finished = true; });
b.emitExit(0);
await Promise.resolve();
expect(finished).toBe(false);
a.emitExit(0);
await vi.waitFor(() => expect(b.kill).toHaveBeenCalledTimes(1));
expect(finished).toBe(false);
b.emitExit(0);
await work;
expect(finished).toBe(true);
});
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('PtyHost', () => {
expect(onData).toHaveBeenCalledWith('hello');
});

it('write/resize/kill delegate to the right process; killAll kills everything', () => {
it('write/resize/kill delegate to the right process; killAll kills everything', async () => {
const a = fakeProc(), b = fakeProc();
const spawn = vi.fn().mockReturnValueOnce(a).mockReturnValueOnce(b) as unknown as PtySpawn;
const host = new PtyHost(spawn);
Expand All @@ -80,7 +80,29 @@ describe('PtyHost', () => {
host.write('a', 'x'); expect(a.write).toHaveBeenCalledWith('x');
host.resize('b', 120, 40); expect(b.resize).toHaveBeenCalledWith(120, 40);
host.kill('a'); expect(a.kill).toHaveBeenCalled();
host.killAll(); expect(b.kill).toHaveBeenCalled();
host.killAll(); expect(b.kill).not.toHaveBeenCalled();
a.emitExit(0);
await vi.waitFor(() => expect(b.kill).toHaveBeenCalled());
});

it('ends terminals one at a time, each after the previous exit (node-pty races two exits on Windows)', async () => {
vi.useFakeTimers();
try {
const procs = [fakeProc(), fakeProc(), fakeProc()];
const spawn = vi.fn();
for (const p of procs) spawn.mockReturnValueOnce(p);
const host = new PtyHost(spawn as unknown as PtySpawn);
for (const id of ['a', 'b', 'c']) host.create(id, 'pwsh', [], '.', 80, 24, () => {}, () => {});
host.killAll();
const killed = () => procs.map((p) => (p.kill as ReturnType<typeof vi.fn>).mock.calls.length);
expect(killed()).toEqual([1, 0, 0]);
procs[0].emitExit(0);
await vi.advanceTimersByTimeAsync(0);
expect(killed()).toEqual([1, 1, 0]);
// A terminal that never reports its exit holds the next one up only for a while.
await vi.advanceTimersByTimeAsync(4_000);
expect(killed()).toEqual([1, 1, 1]);
} finally { vi.useRealTimers(); }
});

it('onExit fires the per-id callback and drops the process (write after exit is a no-op)', () => {
Expand Down
29 changes: 26 additions & 3 deletions src/main/ptyHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export const MAX_SESSIONS = 32;
/** How long `taskkill` may take before the plain kill goes ahead without it. */
const TREE_KILL_TIMEOUT_MS = 3_000;

/** How long one ending terminal may hold up the next before it goes ahead anyway. */
const KILL_STEP_TIMEOUT_MS = 4_000;
/** Quitting waits at least this long for the terminals, and longer when there are many to end one by one. */
const SHUTDOWN_PER_TERMINAL_MS = 1_500;

export type TreeKiller = (pid: number) => Promise<void>;

/**
Expand Down Expand Up @@ -156,13 +161,17 @@ interface Session {
prefixTruncated: boolean;
/** Whether this terminal answers Claude Code's status probe (and so receives its status reports). */
programStatus: boolean;
/** Settles when the process has exited and node-pty has said so. */
exit: Promise<void>;
}

export class PtyHost {
private sessions = new Map<string, Session>();
private pendingExits = new Set<Promise<void>>();
private stopping = false;
private shutdownWork: Promise<void> | null = null;
/** The terminal being ended right now, until its exit lands; the next one waits for it. */
private ending: Promise<void> | null = null;
constructor(private readonly spawn: PtySpawn) {}

create(
Expand Down Expand Up @@ -205,6 +214,7 @@ export class PtyHost {
bufferedLength: 0,
prefixTruncated: false,
programStatus: answerProbe,
exit,
};
this.sessions.set(id, session);
proc.onData((data) => {
Expand Down Expand Up @@ -319,7 +329,18 @@ export class PtyHost {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
s.proc.kill();
// One at a time. On Windows, node-pty's exit threads remove a terminal from one shared list
// without a lock (fixed upstream only in a 0.15 beta), so two terminals ending at the same moment
// (quitting, Update all, closing a group) could corrupt it: an "Assertion failed:
// remove_pty_baton" dialog, or a crash on the way out. The next kill starts once the previous
// exit has been reported, and by then that thread is done with the list.
const step = (): Promise<void> => {
try { s.proc.kill(); } catch (error) { console.error('DevDeck: terminal close failed', error); }
return withTimeout(s.exit, KILL_STEP_TIMEOUT_MS, 'terminal exit').catch(() => undefined);
};
const next = this.ending ? this.ending.then(step) : step();
this.ending = next;
void next.then(() => { if (this.ending === next) this.ending = null; });
}
killAll(): void {
for (const id of [...this.sessions.keys()]) {
Expand All @@ -335,12 +356,14 @@ export class PtyHost {
}

/** Drain native exit callbacks while Electron's Node environment is still alive. */
shutdown(timeoutMs = 5_000): Promise<void> {
shutdown(timeoutMs?: number): Promise<void> {
if (this.shutdownWork) return this.shutdownWork;
this.stopping = true;
const exits = [...this.pendingExits]; // also includes terminals already closing
this.killAll();
this.shutdownWork = withTimeout(Promise.all(exits), timeoutMs, 'terminal shutdown').then(() => {});
// Terminals end one by one (see kill), so many of them take longer than a few at once would.
const budget = timeoutMs ?? Math.max(5_000, exits.length * SHUTDOWN_PER_TERMINAL_MS);
this.shutdownWork = withTimeout(Promise.all(exits), budget, 'terminal shutdown').then(() => {});
return this.shutdownWork;
}
}
Loading