From 44ef3fdff3e171fd2b41132f90c43002ab17cc02 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 26 Mar 2026 20:01:24 -0700 Subject: [PATCH 1/4] Fix new terminals not starting in workspace directory When spawning a new PTY via pty:spawn, default the cwd to the first workspace folder if none is explicitly provided. Previously, new terminals inherited the extension host's working directory (typically home), while only restored sessions correctly used the saved cwd. Co-Authored-By: Claude Opus 4.6 (1M context) --- vscode-ext/src/message-router.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index f985ae6..dad7541 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -142,10 +142,15 @@ export function attachRouter( // Route webview messages to the PTY manager const messageDisposable = webview.onDidReceiveMessage((msg: WebviewMessage) => { switch (msg.type) { - case 'pty:spawn': + case 'pty:spawn': { claim(msg.id); - ptyManager.spawn(msg.id, msg.options); + const spawnOptions = { ...msg.options }; + if (!spawnOptions.cwd) { + spawnOptions.cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + ptyManager.spawn(msg.id, spawnOptions); break; + } case 'pty:input': ptyManager.write(msg.id, msg.data); break; From 4cfc23c5037df73355241f10594b78e326f9c21b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 26 Mar 2026 20:49:18 -0700 Subject: [PATCH 2/4] Launch shell as login shell so ~/.zprofile is sourced. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone MouseTerm was spawning a non-login shell, so brew shellenv (and anything else in ~/.zprofile) never ran—causing tools like asdf to be missing from PATH. Pass a `-` argv[0] to node-pty so the shell runs in login mode, matching Terminal.app, iTerm2, and VSCode. Co-Authored-By: Claude Opus 4.6 (1M context) --- standalone/sidecar/pty-core.js | 3 ++- standalone/sidecar/pty-core.test.js | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 9e1e606..ef6ff59 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -59,6 +59,7 @@ function resolveSpawnConfig(options, runtime = {}) { cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null, env: { ...env, TERM_PROGRAM: 'MouseTerm' }, shell: resolveDefaultShell(platform, env), + loginArg: platform === 'win32' ? [] : [`-${path.basename(resolveDefaultShell(platform, env))}`], }; } @@ -160,7 +161,7 @@ module.exports.create = function create(send, ptyModule) { let p; try { - p = pty.spawn(config.shell, [], { + p = pty.spawn(config.shell, config.loginArg, { name: 'xterm-256color', cols: config.cols, rows: config.rows, diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 6632155..26539cc 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -19,6 +19,7 @@ test('resolveSpawnConfig uses POSIX shell and home defaults', () => { assert.equal(config.cols, 80); assert.equal(config.rows, 30); assert.equal(config.env.TERM_PROGRAM, 'MouseTerm'); + assert.deepEqual(config.loginArg, ['-sh']); }); test('resolveSpawnConfig uses Windows shell and profile defaults', () => { @@ -35,6 +36,7 @@ test('resolveSpawnConfig uses Windows shell and profile defaults', () => { assert.equal(config.cwd, 'C:\\Users\\tester'); assert.equal(config.cwdWarning, null); assert.equal(config.env.TERM_PROGRAM, 'MouseTerm'); + assert.deepEqual(config.loginArg, []); }); test('resolveSpawnConfig preserves explicit cwd', () => { @@ -58,6 +60,7 @@ test('resolveSpawnConfig preserves explicit cwd', () => { assert.equal(config.cwdWarning, null); assert.equal(config.cols, 120); assert.equal(config.rows, 40); + assert.deepEqual(config.loginArg, ['-bash']); }); test('resolveSpawnConfig falls back to the default directory when explicit cwd is missing', () => { From 0697eae1ad43b91ae7cf27a04cc91ee0a4734df3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 26 Mar 2026 21:31:24 -0700 Subject: [PATCH 3/4] Use -l flag instead of argv[0] trick for login shell. node-pty sets argv[0] to the shell path and passes args as argv[1..n], so `-zsh` was being interpreted as CLI flags (`-z`, `s`, `h`), breaking the shell. Use the standard `-l` flag which all POSIX shells support. Co-Authored-By: Claude Opus 4.6 (1M context) --- standalone/sidecar/pty-core.js | 2 +- standalone/sidecar/pty-core.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index ef6ff59..6bec1b2 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -59,7 +59,7 @@ function resolveSpawnConfig(options, runtime = {}) { cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null, env: { ...env, TERM_PROGRAM: 'MouseTerm' }, shell: resolveDefaultShell(platform, env), - loginArg: platform === 'win32' ? [] : [`-${path.basename(resolveDefaultShell(platform, env))}`], + loginArg: platform === 'win32' ? [] : ['-l'], }; } diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 26539cc..15e48c3 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -19,7 +19,7 @@ test('resolveSpawnConfig uses POSIX shell and home defaults', () => { assert.equal(config.cols, 80); assert.equal(config.rows, 30); assert.equal(config.env.TERM_PROGRAM, 'MouseTerm'); - assert.deepEqual(config.loginArg, ['-sh']); + assert.deepEqual(config.loginArg, ['-l']); }); test('resolveSpawnConfig uses Windows shell and profile defaults', () => { @@ -60,7 +60,7 @@ test('resolveSpawnConfig preserves explicit cwd', () => { assert.equal(config.cwdWarning, null); assert.equal(config.cols, 120); assert.equal(config.rows, 40); - assert.deepEqual(config.loginArg, ['-bash']); + assert.deepEqual(config.loginArg, ['-l']); }); test('resolveSpawnConfig falls back to the default directory when explicit cwd is missing', () => { From 3aac0ee76dc2201dcbe2ee7fe7ec91f178ca4523 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 27 Mar 2026 14:20:38 -1000 Subject: [PATCH 4/4] Fix csh login shell spawning --- docs/specs/vscode.md | 1 + standalone/sidecar/pty-core.js | 16 ++++++++++++++-- standalone/sidecar/pty-core.test.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index a19b18a..1e42dce 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -55,6 +55,7 @@ Frontend Library (lib/src/) - **Save before kill.** Deactivate must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. - **Alarm state is global.** A single `AlarmManager` instance in `message-router.ts` is shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. - **PTY ownership.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a reconnecting router from stealing PTYs owned by another webview. +- **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - **mergeAlarmStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alarm states. Missing this causes alarm state to revert on restore. - **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. - **retainContextWhenHidden.** Set on `WebviewPanel` (editor tabs) but NOT on `WebviewView` (bottom panel). The view relies on reconnect/replay when it becomes visible again; the panel keeps its DOM alive. diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 6bec1b2..03d1065 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -23,6 +23,17 @@ function resolveDefaultShell(platform = process.platform, env = process.env) { return env.SHELL || '/bin/sh'; } +const LOGIN_ARG_UNSUPPORTED_SHELLS = new Set(['csh', 'tcsh']); + +function resolveLoginArg(shell, platform = process.platform) { + if (platform === 'win32') { + return []; + } + + const shellName = path.posix.basename(shell || '').toLowerCase(); + return LOGIN_ARG_UNSUPPORTED_SHELLS.has(shellName) ? [] : ['-l']; +} + function resolveDefaultCwd(platform = process.platform, env = process.env, osModule = os) { const homedir = safeResolve(() => osModule.homedir()); const tmpdir = safeResolve(() => osModule.tmpdir()); @@ -51,6 +62,7 @@ function resolveSpawnConfig(options, runtime = {}) { const fsModule = runtime.fsModule || fs; const defaultCwd = resolveDefaultCwd(platform, env, osModule); const missingExplicitCwd = Boolean(cwd) && !directoryExists(cwd, fsModule); + const shell = resolveDefaultShell(platform, env); return { cols, @@ -58,8 +70,8 @@ function resolveSpawnConfig(options, runtime = {}) { cwd: missingExplicitCwd ? defaultCwd : (cwd || defaultCwd), cwdWarning: missingExplicitCwd ? `unable to restore because directory ${cwd} was removed` : null, env: { ...env, TERM_PROGRAM: 'MouseTerm' }, - shell: resolveDefaultShell(platform, env), - loginArg: platform === 'win32' ? [] : ['-l'], + shell, + loginArg: resolveLoginArg(shell, platform), }; } diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 15e48c3..a642c1e 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -63,6 +63,20 @@ test('resolveSpawnConfig preserves explicit cwd', () => { assert.deepEqual(config.loginArg, ['-l']); }); +test('resolveSpawnConfig skips -l for csh-style shells that reject it', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'linux', + env: { SHELL: '/bin/tcsh' }, + osModule: { + homedir: () => '/home/tester', + tmpdir: () => '/tmp/fallback', + }, + }); + + assert.equal(config.shell, '/bin/tcsh'); + assert.deepEqual(config.loginArg, []); +}); + test('resolveSpawnConfig falls back to the default directory when explicit cwd is missing', () => { const config = resolveSpawnConfig( { cwd: '/gone', cols: 120, rows: 40 }, @@ -86,6 +100,20 @@ test('resolveSpawnConfig falls back to the default directory when explicit cwd i assert.equal(config.rows, 40); }); +test('resolveSpawnConfig skips -l for csh', () => { + const config = resolveSpawnConfig(undefined, { + platform: 'darwin', + env: { SHELL: '/bin/csh' }, + osModule: { + homedir: () => '/Users/tester', + tmpdir: () => '/tmp/fallback', + }, + }); + + assert.equal(config.shell, '/bin/csh'); + assert.deepEqual(config.loginArg, []); +}); + test('create buffers scrollback for getScrollback requests', () => { const events = []; const listeners = {};