From 59b7e5a1cfb11fe9096d6bebb967eae894b72ce0 Mon Sep 17 00:00:00 2001 From: Amit Ray <51674969+amitray007@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:50:07 +0530 Subject: [PATCH] fix(terminal): prevent project action commands from getting stuck --- apps/server/src/terminal/Manager.test.ts | 73 +++++++++++++++++++++++- apps/server/src/terminal/Manager.ts | 70 ++++++++++++++++++++--- apps/web/src/components/ChatView.tsx | 35 +++++++----- packages/contracts/src/terminal.test.ts | 11 ++++ packages/contracts/src/terminal.ts | 6 ++ 5 files changed, 171 insertions(+), 24 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47d91e4516ec..39366525e513 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -542,6 +542,18 @@ it.layer( }), ); + it.effect("leaves an initial command unhandled for an existing shell", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const snapshot = yield* manager.open(openInput({ initialCommand: "npm run dev" })); + + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(snapshot.initialCommandHandled).toBeUndefined(); + }), + ); + it.effect("preserves structured context and causes for PTY I/O failures", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -724,9 +736,11 @@ it.layer( }), ); - it.effect("restarts a running session when open is called with a different cwd", () => + it.effect("handles an initial command when open restarts a running session", () => Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); + const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(5, { + shellResolver: () => "/bin/zsh", + }); const path = yield* Path.Path; const originalCwd = path.join(baseDir, "original"); const differentCwd = path.join(baseDir, "different"); @@ -742,12 +756,22 @@ it.layer( const logPath = yield* historyLogPath(logsDir); yield* waitFor(pathExists(logPath)); - const reopened = yield* manager.open(openInput({ cwd: differentCwd })); + const reopened = yield* manager.open( + openInput({ cwd: differentCwd, initialCommand: "npm run dev" }), + ); expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual([ + "-o", + "nopromptsp", + "-i", + "-c", + "trap ':' INT\nnpm run dev\nexec '/bin/zsh' '-o' 'nopromptsp'", + ]); assert.equal(firstProcess.killed, true); assert.equal(reopened.cwd, differentCwd); assert.equal(reopened.history, ""); + expect(reopened.initialCommandHandled).toBe(true); yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); }), ); @@ -1610,6 +1634,49 @@ it.layer( }), ); + it.effect("runs a fresh command and preserves interactive zsh after Ctrl+C", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/zsh", + }); + + const snapshot = yield* manager.open(openInput({ initialCommand: "npm run dev" })); + + expect(ptyAdapter.spawnInputs[0]?.args).toEqual([ + "-o", + "nopromptsp", + "-i", + "-c", + "trap ':' INT\nnpm run dev\nexec '/bin/zsh' '-o' 'nopromptsp'", + ]); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(snapshot.initialCommandHandled).toBe(true); + }), + ); + + it.effect("leaves fresh Windows shell startup unchanged for an initial command", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); + + const snapshot = yield* manager.open(openInput({ initialCommand: "npm run dev" })); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(snapshot.initialCommandHandled).toBeUndefined(); + }), + ); + it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb913fb..d312b61968a7 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -349,6 +349,16 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { }; } +function openSnapshot( + session: TerminalSessionState, + initialCommandHandled: boolean, +): TerminalSessionSnapshot { + const current = snapshot(session); + return initialCommandHandled && session.status === "running" + ? { ...current, initialCommandHandled: true } + : current; +} + function summary(session: TerminalSessionState): TerminalSummary { return { threadId: session.threadId, @@ -526,6 +536,33 @@ function formatShellCandidate(candidate: ShellCandidate): string { return `${candidate.shell} ${candidate.args.join(" ")}`; } +function quotePosixShellArgument(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function posixShellCandidateWithInitialCommand( + candidate: ShellCandidate, + initialCommand: string, +): ShellCandidate { + const baseArgs = candidate.args ?? []; + const reenterShell = [candidate.shell, ...baseArgs].map(quotePosixShellArgument).join(" "); + return { + shell: candidate.shell, + args: [...baseArgs, "-i", "-c", `trap ':' INT\n${initialCommand}\nexec ${reenterShell}`], + }; +} + +function supportsPosixInitialCommand( + candidates: ReadonlyArray, + platform: NodeJS.Platform, +): boolean { + if (platform === "win32") return false; + return candidates.every((candidate) => { + const shellName = basenameForPlatform(candidate.shell, platform).toLowerCase(); + return shellName === "zsh" || shellName === "bash" || shellName === "sh"; + }); +} + function uniqueShellCandidates(candidates: Array): ShellCandidate[] { const seen = new Set(); const ordered: ShellCandidate[] = []; @@ -1862,12 +1899,28 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func let ptyProcess: PtyAdapter.PtyProcess | null = null; let startedShell: string | null = null; + let initialCommandHandled = false; const startResult = yield* Effect.result( increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const resolvedShellCandidates = resolveShellCandidates( + shellResolver, + platform, + baseEnv, + ); + const initialCommand = input.initialCommand; + let shellCandidates = resolvedShellCandidates; + if ( + initialCommand !== undefined && + supportsPosixInitialCommand(resolvedShellCandidates, platform) + ) { + initialCommandHandled = true; + shellCandidates = resolvedShellCandidates.map((candidate) => + posixShellCandidateWithInitialCommand(candidate, initialCommand), + ); + } const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; @@ -1914,7 +1967,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); if (startResult._tag === "Success") { - return; + return initialCommandHandled; } { @@ -1957,6 +2010,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cause: error, ...(startedShell ? { shell: startedShell } : {}), }); + return false; } }); @@ -2187,7 +2241,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); yield* evictInactiveSessionsIfNeeded(); - yield* startSession( + const initialCommandHandled = yield* startSession( session, { threadId: input.threadId, @@ -2197,10 +2251,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, ...(input.env ? { env: input.env } : {}), + ...(input.initialCommand ? { initialCommand: input.initialCommand } : {}), }, "started", ); - return snapshot(session); + return openSnapshot(session, initialCommandHandled); } const liveSession = existing.value; @@ -2239,7 +2294,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (!liveSession.process) { - yield* startSession( + const initialCommandHandled = yield* startSession( liveSession, { threadId: input.threadId, @@ -2249,10 +2304,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols: targetCols, rows: targetRows, ...(input.env ? { env: input.env } : {}), + ...(input.initialCommand ? { initialCommand: input.initialCommand } : {}), }, "started", ); - return snapshot(liveSession); + return openSnapshot(liveSession, initialCommandHandled); } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { @@ -2262,7 +2318,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.updatedAt = yield* nowIso; } - return snapshot(liveSession); + return openSnapshot(liveSession, false); }); const open: TerminalManager["Service"]["open"] = (input) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3da816618a18..d8f0c023a796 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3019,6 +3019,7 @@ function ChatViewContent(props: ChatViewProps) { env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, + initialCommand: script.command, } : { threadId: activeThreadId, @@ -3026,6 +3027,7 @@ function ChatViewContent(props: ChatViewProps) { cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, + initialCommand: script.command, }; if (shouldCreateNewTerminal) { @@ -3046,20 +3048,25 @@ function ChatViewContent(props: ChatViewProps) { return; } - const writeResult = await writeTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }, - }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + // Older remote servers ignore the optional initialCommand field. Keep + // project actions working across version skew, even though only newer + // servers can avoid the fresh-shell startup race. + if (openResult.value.initialCommandHandled !== true) { + const writeResult = await writeTerminal({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, + }, + }); + if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } } }, [ diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..7089b26ae2d4 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -95,6 +95,17 @@ describe("TerminalOpenInput", () => { expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); }); + it("accepts an initial command", () => { + const parsed = decodeSync(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + initialCommand: "npm run dev", + }); + + expect(parsed.initialCommand).toBe("npm run dev"); + }); + it("rejects invalid env keys", () => { expect( decodes(TerminalOpenInput, { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..1ccd1ceb7d97 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -43,6 +43,10 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + /** Ask the server to run a command during shell startup when supported. */ + initialCommand: Schema.optional( + Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), + ), }); export type TerminalOpenInput = Schema.Codec.Encoded; @@ -107,6 +111,8 @@ export const TerminalSessionSnapshot = Schema.Struct({ label: Schema.String.check(Schema.isMaxLength(128)), updatedAt: Schema.String, sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + /** Present when terminal.open handled the requested initial command. */ + initialCommandHandled: Schema.optional(Schema.Boolean), }); export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;