Skip to content
Closed
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
73 changes: 70 additions & 3 deletions apps/server/src/terminal/Manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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");
Expand All@@ -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 === ""));
}),
);
Expand DownExpand Up@@ -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, {
Expand Down
70 changes: 63 additions & 7 deletions apps/server/src/terminal/Manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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<ShellCandidate>,
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 | null>): ShellCandidate[] {
const seen = new Set<string>();
const ordered: ShellCandidate[] = [];
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -1914,7 +1967,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
);

if (startResult._tag === "Success") {
return;
return initialCommandHandled;
}

{
Expand DownExpand Up@@ -1957,6 +2010,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
cause: error,
...(startedShell ? { shell: startedShell } : {}),
});
return false;
}
});

Expand DownExpand Up@@ -2187,7 +2241,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
});

yield* evictInactiveSessionsIfNeeded();
yield* startSession(
const initialCommandHandled = yield* startSession(
session,
{
threadId: input.threadId,
Expand All@@ -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;
Expand DownExpand Up@@ -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,
Expand All@@ -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) {
Expand All@@ -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) =>
Expand Down
35 changes: 21 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3019,13 +3019,15 @@ function ChatViewContent(props: ChatViewProps) {
env: runtimeEnv,
cols: SCRIPT_TERMINAL_COLS,
rows: SCRIPT_TERMINAL_ROWS,
initialCommand: script.command,
}
: {
threadId: activeThreadId,
terminalId: targetTerminalId,
cwd: targetCwd,
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
env: runtimeEnv,
initialCommand: script.command,
};

if (shouldCreateNewTerminal) {
Expand All@@ -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}".`,
);
}
}
},
[
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/terminal.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, {
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/terminal.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof TerminalOpenInput>;

Expand DownExpand Up@@ -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;

Expand Down
Loading