Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c0b7e66
fix(server): make node-pty failures loud and actionable
StiensWout Aug 1, 2026
9f262f4
fix(web): terminal close shortcut can no longer reach the browser def…
StiensWout Aug 1, 2026
143087f
fix(desktop): holding Ctrl+W no longer closes the window
StiensWout Aug 1, 2026
95968fd
fix(server): stop shell fallback once spawn-helper is diagnosed
StiensWout Aug 1, 2026
8bca3c5
fix(web): new terminals no longer flash black while the renderer loads
StiensWout Aug 1, 2026
a8ebfe1
fix(web): bottom-bar splits no longer collapse into a separate group
StiensWout Aug 1, 2026
45bc0dc
refactor(server): type the spawn-helper diagnosis and move exit polic…
StiensWout Aug 1, 2026
ca8a88d
fix(server): judge spawn-helper executability by the calling process
StiensWout Aug 1, 2026
5712374
fix(terminal): close failures, held close chord, and split merges no …
StiensWout Aug 1, 2026
79be836
fix(web): track pending opens so reconcile can drop remote closes
StiensWout Aug 1, 2026
35f2a14
fix(web): drop pending terminals whose open request failed
StiensWout Aug 1, 2026
3bfce9b
fix(web): failed closes restore the terminal where it lived
StiensWout Aug 1, 2026
e0ad1c8
fix(web): never reuse a terminal id that may still have an open in fl…
StiensWout Aug 1, 2026
e72a980
fix(web): make terminal open/close rollbacks precise about unknown ou…
StiensWout Aug 1, 2026
5e0c3c3
fix(web): settle panel terminal opens and closes against server metadata
StiensWout Aug 1, 2026
5fd1ac3
fix(web): resize the PTY in lockstep with the terminal grid
StiensWout Aug 1, 2026
58bf3de
fix(web): apply grid resizes only once the PTY acknowledges
StiensWout Aug 1, 2026
c9b75cf
fix(web): never skip a PTY width the shell already redrew for
StiensWout Aug 1, 2026
80706b0
fix(web): settle recorded panel closes immediately and honor denied r…
StiensWout Aug 1, 2026
e38c250
fix(terminal): close three review gaps in resize and pending-close ha…
StiensWout Aug 1, 2026
8026b6a
fix(terminal): order resize replies behind queued output and close ro…
StiensWout Aug 1, 2026
69a7132
fix(server): strip replayable query traffic from terminal history
StiensWout Aug 1, 2026
013841c
fix(server): flush the resize reply while a drain is still publishing
StiensWout Aug 1, 2026
35e3055
chore: worktree action configures and starts the isolated dev environ…
StiensWout Aug 3, 2026
f94d7d1
feat(hub)!: single always-works pairing entry point
StiensWout Aug 3, 2026
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
13 changes: 13 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,19 @@ export const make = Effect.gen(function* () {
}
});

// Electron's windowMenu close role owns CmdOrCtrl+W. Holding the
// close-terminal shortcut auto-repeats faster than the renderer can re-arm
// its guard after the last terminal closes, and a single leaked repeat
// closes the whole window (and on Windows/Linux, the app). Only deliberate,
// non-repeated presses may act.
window.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || !input.isAutoRepeat) return;
const modifier = environment.platform === "darwin" ? input.meta : input.control;
if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
event.preventDefault();
}
});

window.on("page-title-updated", (event) => {
event.preventDefault();
window.setTitle(environment.displayName);
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,23 @@ it.layer(
}),
);

it.effect("does not retry other shells when spawn-helper is not executable", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
ptyAdapter.spawnFailures.push(
new Error(
'node-pty\'s spawn-helper at /pkg/spawn-helper is not executable, so every shell fails with "posix_spawnp failed". Fix it with: chmod +x "/pkg/spawn-helper"',
{ cause: new Error("posix_spawnp failed.") },
),
);

const snapshot = yield* manager.open(openInput());

assert.equal(snapshot.status, "error");
expect(ptyAdapter.spawnInputs).toHaveLength(1);
}),
);

it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(5, {
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,11 @@ function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
}

const message = messages.join(" ").toLowerCase();
// A non-executable node-pty spawn-helper fails identically for every shell;
// retrying candidates would only bury the actionable error under fallbacks.
if (message.includes("spawn-helper") && message.includes("not executable")) {
return false;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
return (
message.includes("posix_spawnp failed") ||
message.includes("enoent") ||
Expand Down
110 changes: 98 additions & 12 deletions apps/server/src/terminal/NodePtyAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,34 @@ it.effect("spawns through the public adapter with the provided host references",
it.effect("reports native module load failures as structured startup defects", () =>
Effect.gen(function* () {
const cause = new Error("native binding could not be loaded");
const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit);
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const previousExitCode = process.exitCode;

assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
assert.isTrue(Cause.hasDies(exit.cause));
const error = Cause.squash(exit.cause);
assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError);
assert.deepInclude(error, {
_tag: "NodePtyModuleLoadError",
platform: "win32",
architecture: "x64",
});
assert.equal(error.message, "Failed to load node-pty for win32-x64.");
try {
const exit = yield* NodePtyAdapter.make(() => Promise.reject(cause)).pipe(Effect.exit);

assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
assert.isTrue(Cause.hasDies(exit.cause));
const error = Cause.squash(exit.cause);
assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError);
assert.deepInclude(error, {
_tag: "NodePtyModuleLoadError",
platform: "win32",
architecture: "x64",
});
assert.equal(error.message, "Failed to load node-pty for win32-x64.");
}

// A broken install must never exit cleanly with no output.
assert.equal(process.exitCode, 1);
const written = stderrWrite.mock.calls.map((call) => String(call[0])).join("");
assert.include(written, "Failed to load node-pty for win32-x64.");
assert.include(written, "native binding could not be loaded");
assert.include(written, "reinstall t3");
} finally {
stderrWrite.mockRestore();
process.exitCode = previousExitCode;
}
}).pipe(
Effect.provide(
Expand All @@ -86,3 +101,74 @@ it.effect("reports native module load failures as structured startup defects", (
),
),
);

it("leaves non-posix_spawnp failures untouched", () => {
const cause = new Error("cwd does not exist");
const described = NodePtyAdapter.describeSpawnFailure({
cause,
platform: "darwin",
helperPath: "/pkg/node-pty/build/Release/spawn-helper",
helperIsExecutable: false,
});
assert.equal(described, cause);
});

it("explains posix_spawnp failures caused by a non-executable spawn-helper", () => {
const cause = new Error("posix_spawnp failed.");
const described = NodePtyAdapter.describeSpawnFailure({
cause,
platform: "darwin",
helperPath: "/pkg/node-pty/build/Release/spawn-helper",
helperIsExecutable: false,
});
assert.instanceOf(described, Error);
const error = described as Error;
assert.include(error.message, "spawn-helper");
assert.include(error.message, 'chmod +x "/pkg/node-pty/build/Release/spawn-helper"');
assert.equal(error.cause, cause);
});

it("keeps posix_spawnp failures as-is when the helper is executable or missing", () => {
const cause = new Error("posix_spawnp failed.");
assert.equal(
NodePtyAdapter.describeSpawnFailure({
cause,
platform: "darwin",
helperPath: "/pkg/node-pty/build/Release/spawn-helper",
helperIsExecutable: true,
}),
cause,
);
assert.equal(
NodePtyAdapter.describeSpawnFailure({
cause,
platform: "darwin",
helperPath: null,
helperIsExecutable: false,
}),
cause,
);
assert.equal(
NodePtyAdapter.describeSpawnFailure({
cause,
platform: "win32",
helperPath: "/pkg/node-pty/build/Release/spawn-helper",
helperIsExecutable: false,
}),
cause,
);
});

it("finds posix_spawnp mentions through nested error causes", () => {
const nested = new Error("outer wrapper", {
cause: new Error("posix_spawnp failed."),
});
const described = NodePtyAdapter.describeSpawnFailure({
cause: nested,
platform: "darwin",
helperPath: "/pkg/spawn-helper",
helperIsExecutable: false,
});
assert.instanceOf(described, Error);
assert.include((described as Error).message, "chmod +x");
});
178 changes: 147 additions & 31 deletions apps/server/src/terminal/NodePtyAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ export class NodePtyModuleLoadError extends Schema.TaggedErrorClass<NodePtyModul
override get message(): string {
return `Failed to load node-pty for ${this.platform}-${this.architecture}.`;
}

/**
* Full, user-facing explanation of the load failure. Written straight to
* stderr at startup so a broken install never exits without output.
*/
get diagnostic(): string {
const causeMessage = this.cause instanceof Error ? this.cause.message : String(this.cause);
return [
this.message,
`Caused by: ${causeMessage}`,
"",
"node-pty is the native module that powers t3 terminals. It is compiled (or a",
"prebuild is unpacked) when t3 is installed, so this usually means the install",
"could not produce a working binary for this machine:",
" - the machine is missing a C/C++ toolchain (macOS: `xcode-select --install`,",
" Debian/Ubuntu: `sudo apt-get install -y build-essential python3`), or",
" - t3 was installed with a different Node.js version or architecture than the",
" one running now.",
"Fix the toolchain, then reinstall t3 (`npm install -g t3`, or clear the npx",
"cache with `rm -rf ~/.npm/_npx` and re-run `npx t3`).",
].join("\n");
}
}

type NodePtyModuleLoader = () => Promise<typeof import("node-pty")>;
Expand Down Expand Up @@ -55,18 +77,69 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
if (platform === "win32") return;
if (didEnsureSpawnHelperExecutable) return;

// Resolution can fail transiently (and is folded into null), so leave the
// flag unset and let the next spawn rescan instead of giving up for good.
const helperPath = yield* resolveNodePtySpawnHelperPath;
if (!helperPath) return;
didEnsureSpawnHelperExecutable = true;

if (!(yield* fs.exists(helperPath))) {
// npm can extract the package without the exec bit on spawn-helper, which then
// surfaces as "posix_spawnp failed" for every shell. Chmod unconditionally
// instead of stat-then-chmod: in packaged mode some fs metadata can be missing.
const chmodResult = yield* Effect.result(fs.chmod(helperPath, 0o755));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (chmodResult._tag === "Success") {
didEnsureSpawnHelperExecutable = true;
return;
}

// Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing.
yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined));
// Leave the flag unset so the next spawn retries; a transient failure (e.g. a
// fleeting read-only mount) should not disable the repair forever.
yield* Effect.logWarning("failed to mark node-pty spawn-helper executable", {
helperPath,
error: chmodResult.failure,
remedy: `chmod +x "${helperPath}"`,
});
});

export const spawnHelperNotExecutableMessage = (helperPath: string): string =>
`node-pty's spawn-helper at ${helperPath} is not executable, so every shell fails with "posix_spawnp failed". Fix it with: chmod +x "${helperPath}"`;

const causeMentionsPosixSpawnFailure = (cause: unknown): boolean => {
let current: unknown = cause;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
if (typeof current === "string") {
return current.toLowerCase().includes("posix_spawnp failed");
}
if (current instanceof Error) {
if (current.message.toLowerCase().includes("posix_spawnp failed")) {
return true;
}
current = current.cause;
continue;
}
return false;
}
return false;
};

/**
* Wraps a spawn failure with an actionable message when the real culprit is a
* non-executable spawn-helper. Without this, the shell-candidate fallback in the
* terminal manager retries every shell and reports the failure as if no working
* shell existed on the machine.
*/
export const describeSpawnFailure = (input: {
cause: unknown;
platform: string;
helperPath: string | null;
helperIsExecutable: boolean;
}): unknown => {
if (input.platform === "win32") return input.cause;
if (!causeMentionsPosixSpawnFailure(input.cause)) return input.cause;
if (input.helperPath === null || input.helperIsExecutable) return input.cause;
return new Error(spawnHelperNotExecutableMessage(input.helperPath), { cause: input.cause });
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
};

class NodePtyProcess implements PtyAdapter.PtyProcess {
private readonly process: import("node-pty").IPty;

Expand Down Expand Up @@ -126,38 +199,81 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* (
architecture,
cause,
}),
}).pipe(Effect.orDie);

const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
ensureNodePtySpawnHelperExecutable().pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(HostProcessPlatform, platform),
Effect.provideService(HostProcessArchitecture, architecture),
Effect.orElseSucceed(() => undefined),
}).pipe(
// The defect below can be swallowed by headless startup logging, which used
// to make `npx t3` exit cleanly with no output on a broken install. Write
// the diagnosis straight to stderr and force a failing exit code first.
Effect.tapError((error) =>
Effect.sync(() => {
process.exitCode = 1;
process.stderr.write(`${error.diagnostic}\n`);
}),
),
Effect.orDie,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
);

const ensureSpawnHelperExecutable = ensureNodePtySpawnHelperExecutable().pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(HostProcessPlatform, platform),
Effect.provideService(HostProcessArchitecture, architecture),
);
const resolveSpawnHelperPath = resolveNodePtySpawnHelperPath.pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(HostProcessPlatform, platform),
Effect.provideService(HostProcessArchitecture, architecture),
);
const spawnHelperIsExecutable = (helperPath: string) =>
fs.stat(helperPath).pipe(
Effect.map((info) => (info.mode & 0o111) !== 0),
// When metadata is unavailable (packaged mode) assume the helper is fine
// rather than pointing users at a chmod that may not help.
Effect.orElseSucceed(() => true),
);

return PtyAdapter.PtyAdapter.of({
spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) {
yield* ensureNodePtySpawnHelperExecutableCached;
const ptyProcess = yield* Effect.try({
try: () =>
nodePty.spawn(input.shell, input.args ?? [], {
cwd: input.cwd,
cols: input.cols,
rows: input.rows,
env: input.env,
name: platform === "win32" ? "xterm-color" : "xterm-256color",
}),
catch: (cause) =>
new PtyAdapter.PtySpawnError({
adapter: "node-pty",
shell: input.shell,
cause,
}),
yield* ensureSpawnHelperExecutable;
const attempt = yield* Effect.result(
Effect.try({
try: () =>
nodePty.spawn(input.shell, input.args ?? [], {
cwd: input.cwd,
cols: input.cols,
rows: input.rows,
env: input.env,
name: platform === "win32" ? "xterm-color" : "xterm-256color",
}),
catch: (cause) =>
new PtyAdapter.PtySpawnError({
adapter: "node-pty",
shell: input.shell,
cause,
}),
}),
);
if (attempt._tag === "Success") {
return new NodePtyProcess(attempt.success);
}

const spawnCause = attempt.failure.cause;
if (platform === "win32" || !causeMentionsPosixSpawnFailure(spawnCause)) {
return yield* attempt.failure;
}
const helperPath = yield* resolveSpawnHelperPath;
const helperIsExecutable =
helperPath === null ? true : yield* spawnHelperIsExecutable(helperPath);
return yield* new PtyAdapter.PtySpawnError({
adapter: "node-pty",
shell: input.shell,
cause: describeSpawnFailure({
cause: spawnCause,
platform,
helperPath,
helperIsExecutable,
}),
});
return new NodePtyProcess(ptyProcess);
}),
});
});
Expand Down
Loading
Loading