Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 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
24 changes: 23 additions & 1 deletion apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import { ServerConfig, type StartupPresentation } from "../config.ts";
import { runServer } from "../server.ts";
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";

/**
* Defects thrown while building the server layer can be swallowed by headless
* startup logging, which used to make `npx t3` exit cleanly with no output on a
* broken install. The entrypoint owns process-exit policy, so render whatever
* diagnosis the defect carries and make sure the exit code is non-zero.
*/
const hasDiagnostic = (defect: unknown): defect is { readonly diagnostic: string } =>
typeof defect === "object" &&
defect !== null &&
typeof (defect as { diagnostic?: unknown }).diagnostic === "string";

const reportStartupDefect = (defect: unknown) =>
Effect.sync(() => {
process.exitCode = 1;
if (hasDiagnostic(defect)) {
process.stderr.write(`${defect.diagnostic}\n`);
}
});

export const runServerCommand = (
flags: CliServerFlags,
options?: {
Expand All @@ -15,7 +34,10 @@ export const runServerCommand = (
Effect.gen(function* () {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveServerConfig(flags, logLevel, options);
return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
return yield* runServer.pipe(
Effect.provideService(ServerConfig, config),
Effect.tapDefect(reportStartupDefect),
);
});

export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
Expand Down
23 changes: 23 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,29 @@ it.layer(
}),
);

it.effect("does not retry other shells when spawn-helper is not executable", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, getEvents } = yield* createManager();
ptyAdapter.spawnFailures.push(
new PtyAdapter.SpawnHelperNotExecutableError({
helperPath: "/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);

// The user must see the fix, not the generic "tried these shells" wrapper.
const events = yield* getEvents;
const errorEvent = events.find((event) => event.type === "error");
assert.isDefined(errorEvent);
assert.include(errorEvent?.message ?? "", 'chmod +x "/pkg/spawn-helper"');
}),
);

it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager(5, {
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,12 @@ function resolveShellCandidates(
}

function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean {
// A non-executable node-pty spawn-helper fails identically for every shell;
// retrying candidates would only bury the actionable error under fallbacks.
if (PtyAdapter.hasSpawnHelperNotExecutableCause(error)) {
Comment thread
StiensWout marked this conversation as resolved.
return false;
}

const queue: unknown[] = [error];
const seen = new Set<unknown>();
const messages: string[] = [];
Expand Down Expand Up @@ -1959,7 +1965,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func

yield* evictInactiveSessionsIfNeeded();

const message = error.message;
// The outer spawn error only says which shells were tried. When the real
// culprit is diagnosed further down the chain, publish that instead so the
// user sees the fix rather than a generic failure.
const message = (PtyAdapter.findSpawnHelperNotExecutableCause(error) ?? error).message;
yield* publishEvent({
type: "error",
threadId: session.threadId,
Expand Down
159 changes: 159 additions & 0 deletions apps/server/src/terminal/NodePtyAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ it.effect("reports native module load failures as structured startup defects", (
architecture: "x64",
});
assert.equal(error.message, "Failed to load node-pty for win32-x64.");
// The CLI entrypoint renders this; a broken install must never exit
// cleanly with no output.
assert.include(error.diagnostic, "Failed to load node-pty for win32-x64.");
assert.include(error.diagnostic, "native binding could not be loaded");
assert.include(error.diagnostic, "reinstall t3");
}
}).pipe(
Effect.provide(
Expand All @@ -86,3 +91,157 @@ it.effect("reports native module load failures as structured startup defects", (
),
),
);

it("resolves helper executability against the calling process, not any exec bit", () => {
const owned = { ownerUid: 501, ownerGid: 20 };

// Owner-only binary: executable for its owner, not for anyone else.
assert.isTrue(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o100,
...owned,
processUid: 501,
processGids: [20],
}),
);
assert.isFalse(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o100,
...owned,
processUid: 502,
processGids: [21],
}),
);

// Group and other bits are honoured independently of the owner bit.
assert.isTrue(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o010,
...owned,
processUid: 502,
processGids: [20],
}),
);
assert.isFalse(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o600,
...owned,
processUid: 501,
processGids: [20],
}),
);
assert.isTrue(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o001,
...owned,
processUid: 502,
processGids: [21],
}),
);

// Root and unknown identities fall back to "somebody can execute this".
assert.isTrue(
NodePtyAdapter.modeIsExecutableFor({ mode: 0o100, ...owned, processUid: 0, processGids: [0] }),
);
assert.isFalse(
NodePtyAdapter.modeIsExecutableFor({ mode: 0o644, ...owned, processUid: 0, processGids: [0] }),
);
assert.isTrue(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o100,
ownerUid: null,
ownerGid: null,
processUid: null,
processGids: [],
}),
);

// The reported bug: a 0644 helper is never executable for anyone.
assert.isFalse(
NodePtyAdapter.modeIsExecutableFor({
mode: 0o644,
...owned,
processUid: 501,
processGids: [20],
}),
);
});

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, PtyAdapter.SpawnHelperNotExecutableError);
const error = described as PtyAdapter.SpawnHelperNotExecutableError;
assert.equal(error.helperPath, "/pkg/node-pty/build/Release/spawn-helper");
assert.include(error.message, 'chmod +x "/pkg/node-pty/build/Release/spawn-helper"');
assert.equal(error.cause, cause);
// The terminal manager keys its retry decision off the tag, not the wording.
assert.isTrue(PtyAdapter.hasSpawnHelperNotExecutableCause(error));
assert.isTrue(
PtyAdapter.hasSpawnHelperNotExecutableCause(
new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell: "/bin/zsh", cause: error }),
),
);
assert.isFalse(PtyAdapter.hasSpawnHelperNotExecutableCause(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");
});
Loading
Loading