Setup incomplete
- {workspaceName}
+
+ {workspaceName}
+
Workspace setup didn't finish. You can retry or remove it.
@@ -213,7 +218,12 @@ export function WorkspaceInitializingView({
Workspace setup failed
- {workspaceName}
+
+ {workspaceName}
+
{progress?.error && (
{progress.error}
diff --git a/packages/chat/src/server/trpc/service.test.ts b/packages/chat/src/server/trpc/service.test.ts
index 7adf7272d1e..af4f55e508b 100644
--- a/packages/chat/src/server/trpc/service.test.ts
+++ b/packages/chat/src/server/trpc/service.test.ts
@@ -20,14 +20,17 @@ mock.module("mastracode", () => ({
const { ChatRuntimeService } = await import("./service");
-function createRuntime(): RuntimeSession {
+function createRuntime(options?: {
+ respondToQuestion?: RuntimeSession["harness"]["respondToQuestion"];
+}): RuntimeSession {
return {
sessionId: SESSION_ID,
cwd: CWD,
harness: {
abort: mock(() => {}),
respondToToolApproval: mock(async (payload: unknown) => payload),
- respondToQuestion: mock(async (payload: unknown) => payload),
+ respondToQuestion:
+ options?.respondToQuestion ?? mock(async (payload: unknown) => payload),
respondToPlanApproval: mock(async (payload: unknown) => payload),
} as unknown as RuntimeSession["harness"],
mcpManager: null as RuntimeSession["mcpManager"],
@@ -39,11 +42,13 @@ function createRuntime(): RuntimeSession {
path: "/tmp/secret",
reason: "Need access",
},
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
};
}
-function createServiceHarness() {
- const runtime = createRuntime();
+function createServiceHarness(options?: Parameters[0]) {
+ const runtime = createRuntime(options);
const service = new ChatRuntimeService({
headers: async () => ({}),
apiUrl: "http://localhost:3000",
@@ -134,4 +139,61 @@ describe("ChatRuntimeService control mutations", () => {
});
expect(runtime.pendingSandboxQuestion).toBeNull();
});
+
+ it("does not clear pending question state when question response fails", async () => {
+ const respondToQuestion = mock(async () => {
+ throw new Error("failed to answer");
+ }) as RuntimeSession["harness"]["respondToQuestion"];
+ const { caller, runtime } = createServiceHarness({ respondToQuestion });
+
+ await expect(
+ caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload: { questionId: "sandbox-1", answer: "Yes" },
+ }),
+ ).rejects.toThrow("failed to answer");
+
+ expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(false);
+ expect(runtime.pendingSandboxQuestion).toEqual({
+ questionId: "sandbox-1",
+ path: "/tmp/secret",
+ reason: "Need access",
+ });
+ });
+
+ it("deduplicates concurrent responses for the same question", async () => {
+ let resolveResponse: (value: unknown) => void = () => {};
+ const respondToQuestion = mock(
+ () =>
+ new Promise((resolve) => {
+ resolveResponse = resolve;
+ }),
+ ) as RuntimeSession["harness"]["respondToQuestion"];
+ const { caller, runtime } = createServiceHarness({ respondToQuestion });
+ const payload = { questionId: "sandbox-1", answer: "Yes" };
+
+ const firstResponse = caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload,
+ });
+ const secondResponse = caller.session.question.respond({
+ sessionId: SESSION_ID,
+ cwd: CWD,
+ payload,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(respondToQuestion).toHaveBeenCalledTimes(1);
+ expect(runtime.answeredQuestionIds.has("sandbox-1")).toBe(true);
+ expect(runtime.pendingSandboxQuestion).toBeNull();
+
+ resolveResponse({ ok: true });
+
+ await expect(firstResponse).resolves.toEqual({ ok: true });
+ await expect(secondResponse).resolves.toEqual({ ok: true });
+ expect(runtime.pendingQuestionResponses.size).toBe(0);
+ });
});
diff --git a/packages/chat/src/server/trpc/service.ts b/packages/chat/src/server/trpc/service.ts
index 4a4351d0412..99a23149b0e 100644
--- a/packages/chat/src/server/trpc/service.ts
+++ b/packages/chat/src/server/trpc/service.ts
@@ -12,6 +12,7 @@ import {
getRuntimeMcpOverview,
type LifecycleEvent,
onUserPromptSubmit,
+ type RuntimeQuestionResponse,
type RuntimeSession,
reloadHookConfig,
restartRuntimeFromUserMessage,
@@ -36,6 +37,56 @@ import {
} from "./zod";
const ENABLE_MASTRA_MCP_SERVERS = false;
+
+type RuntimeQuestionPayload = Parameters<
+ RuntimeSession["harness"]["respondToQuestion"]
+>[0];
+
+function respondToQuestionWithOptimisticState(
+ runtime: RuntimeSession,
+ payload: RuntimeQuestionPayload,
+): Promise {
+ const questionId = payload.questionId;
+ const pendingResponse = runtime.pendingQuestionResponses.get(questionId);
+ if (pendingResponse) return pendingResponse;
+
+ const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId);
+ const previousSandboxQuestion = runtime.pendingSandboxQuestion;
+ const clearsSandboxQuestion =
+ previousSandboxQuestion?.questionId === questionId;
+
+ runtime.answeredQuestionIds.add(questionId);
+ if (clearsSandboxQuestion) {
+ runtime.pendingSandboxQuestion = null;
+ }
+
+ let responsePromise: Promise;
+ responsePromise = Promise.resolve()
+ .then(() => runtime.harness.respondToQuestion(payload))
+ .catch((error) => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ if (!wasAlreadyAnswered) {
+ runtime.answeredQuestionIds.delete(questionId);
+ }
+ if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) {
+ runtime.pendingSandboxQuestion = previousSandboxQuestion;
+ }
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ runtime.pendingQuestionResponses.delete(questionId);
+ }
+ });
+ runtime.pendingQuestionResponses.set(questionId, responsePromise);
+ return responsePromise;
+}
+
function resolveOmModelFromAuth(): string | undefined {
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY)
return "google/gemini-2.5-flash";
@@ -139,6 +190,8 @@ export class ChatRuntimeService {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: runtimeCwd,
};
syncRuntimeHookSessionId(sessionRuntime);
@@ -223,22 +276,30 @@ export class ChatRuntimeService {
? {
questionId: runtime.pendingSandboxQuestion.questionId,
question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
+ description: runtime.pendingSandboxQuestion.reason,
options: [
{
label: "Yes",
- description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`,
- },
- {
- label: "No",
- description: "Deny access.",
+ description: "Allow access.",
},
+ { label: "No", description: "Deny access." },
],
}
: null;
+ // Skip any pending question whose ID was already answered this turn.
+ // The harness only clears pendingQuestion on agent_end, so without this
+ // filter an answered ask_user question would permanently shadow the
+ // sandbox question that fired in the same turn.
+ const harnessPendingQuestion =
+ displayState.pendingQuestion &&
+ !runtime.answeredQuestionIds.has(
+ displayState.pendingQuestion.questionId,
+ )
+ ? displayState.pendingQuestion
+ : null;
return {
...displayState,
- pendingQuestion:
- displayState.pendingQuestion ?? sandboxPendingQuestion,
+ pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion,
errorMessage: currentMessageError ?? runtime.lastErrorMessage,
};
}),
@@ -347,13 +408,10 @@ export class ChatRuntimeService {
input.sessionId,
input.cwd,
);
- if (
- runtime.pendingSandboxQuestion?.questionId ===
- input.payload.questionId
- ) {
- runtime.pendingSandboxQuestion = null;
- }
- return runtime.harness.respondToQuestion(input.payload);
+ return respondToQuestionWithOptimisticState(
+ runtime,
+ input.payload,
+ );
}),
}),
diff --git a/packages/chat/src/server/trpc/utils/runtime/index.ts b/packages/chat/src/server/trpc/utils/runtime/index.ts
index 38ea1bdf76d..5560ce025c4 100644
--- a/packages/chat/src/server/trpc/utils/runtime/index.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/index.ts
@@ -7,6 +7,7 @@ export {
type RuntimeHookManager,
type RuntimeMcpManager,
type RuntimeMcpServerStatus,
+ type RuntimeQuestionResponse,
type RuntimeSession,
reloadHookConfig,
restartRuntimeFromUserMessage,
diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
index e4af003961a..8c661ac063d 100644
--- a/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/runtime.test.ts
@@ -58,6 +58,8 @@ function createRuntimeForTest(): {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
@@ -109,6 +111,8 @@ function createRuntimeForTitleTest(options?: {
mcpManualStatuses: new Map(),
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
@@ -346,6 +350,8 @@ describe("runtime message restart", () => {
mcpManualStatuses: new Map(),
lastErrorMessage: "stale error",
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
cwd: "/tmp",
};
diff --git a/packages/chat/src/server/trpc/utils/runtime/runtime.ts b/packages/chat/src/server/trpc/utils/runtime/runtime.ts
index 923e1fcfde1..5a3678eace5 100644
--- a/packages/chat/src/server/trpc/utils/runtime/runtime.ts
+++ b/packages/chat/src/server/trpc/utils/runtime/runtime.ts
@@ -16,6 +16,9 @@ export type RuntimeMcpManager = Awaited<
export type RuntimeHookManager = Awaited<
ReturnType
>["hookManager"];
+export type RuntimeQuestionResponse = Awaited<
+ ReturnType
+>;
export interface RuntimeMcpServerStatus {
connected: boolean;
@@ -35,6 +38,8 @@ export interface RuntimeSession {
path: string;
reason: string;
} | null;
+ answeredQuestionIds: Set;
+ pendingQuestionResponses: Map>;
cwd: string;
}
@@ -237,6 +242,8 @@ export function subscribeToSessionEvents(
if (isHarnessAgentStartEvent(event)) {
runtime.lastErrorMessage = null;
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
onLifecycleEvent?.({
sessionId: runtime.sessionId,
eventType: "Start",
@@ -245,6 +252,8 @@ export function subscribeToSessionEvents(
}
if (isHarnessAgentEndEvent(event)) {
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
const raw = event.reason;
const reason = raw === "aborted" || raw === "error" ? raw : "complete";
if (runtime.hookManager) {
diff --git a/packages/host-service/src/runtime/chat/chat.ts b/packages/host-service/src/runtime/chat/chat.ts
index 8fca5e6ffb7..ed9f83bb871 100644
--- a/packages/host-service/src/runtime/chat/chat.ts
+++ b/packages/host-service/src/runtime/chat/chat.ts
@@ -56,12 +56,13 @@ interface PendingSandboxQuestion {
interface ChatPendingQuestionOption {
label: string;
- description: string;
+ description?: string;
}
interface ChatPendingQuestion {
questionId: string;
question: string;
+ description?: string;
options: ChatPendingQuestionOption[];
}
@@ -99,6 +100,53 @@ interface RuntimeSession {
hookManager: RuntimeHookManager;
lastErrorMessage: string | null;
pendingSandboxQuestion: PendingSandboxQuestion | null;
+ answeredQuestionIds: Set;
+ pendingQuestionResponses: Map>;
+}
+
+function respondToQuestionWithOptimisticState(
+ runtime: RuntimeSession,
+ payload: ChatQuestionPayload,
+): Promise {
+ const questionId = payload.questionId;
+ const pendingResponse = runtime.pendingQuestionResponses.get(questionId);
+ if (pendingResponse) return pendingResponse;
+
+ const wasAlreadyAnswered = runtime.answeredQuestionIds.has(questionId);
+ const previousSandboxQuestion = runtime.pendingSandboxQuestion;
+ const clearsSandboxQuestion =
+ previousSandboxQuestion?.questionId === questionId;
+
+ runtime.answeredQuestionIds.add(questionId);
+ if (clearsSandboxQuestion) {
+ runtime.pendingSandboxQuestion = null;
+ }
+
+ let responsePromise: Promise;
+ responsePromise = Promise.resolve()
+ .then(() => runtime.harness.respondToQuestion(payload))
+ .catch((error) => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ if (!wasAlreadyAnswered) {
+ runtime.answeredQuestionIds.delete(questionId);
+ }
+ if (clearsSandboxQuestion && runtime.pendingSandboxQuestion === null) {
+ runtime.pendingSandboxQuestion = previousSandboxQuestion;
+ }
+ }
+ throw error;
+ })
+ .finally(() => {
+ if (
+ runtime.pendingQuestionResponses.get(questionId) === responsePromise
+ ) {
+ runtime.pendingQuestionResponses.delete(questionId);
+ }
+ });
+ runtime.pendingQuestionResponses.set(questionId, responsePromise);
+ return responsePromise;
}
interface RuntimeStoredMessage {
@@ -339,11 +387,15 @@ export class ChatRuntimeManager {
if (isObjectRecord(event) && event.type === "agent_start") {
runtime.lastErrorMessage = null;
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
return;
}
if (isObjectRecord(event) && event.type === "agent_end") {
runtime.pendingSandboxQuestion = null;
+ runtime.answeredQuestionIds.clear();
+ runtime.pendingQuestionResponses.clear();
}
});
}
@@ -386,6 +438,8 @@ export class ChatRuntimeManager {
hookManager: runtime.hookManager,
lastErrorMessage: null,
pendingSandboxQuestion: null,
+ answeredQuestionIds: new Set(),
+ pendingQuestionResponses: new Map(),
};
this.subscribeToSessionEvents(sessionRuntime);
this.runtimes.set(sessionId, sessionRuntime);
@@ -438,26 +492,32 @@ export class ChatRuntimeManager {
? currentMessage.errorMessage.trim()
: null;
+ // Skip any pending question whose ID was already answered this turn.
+ // The harness only clears pendingQuestion on agent_end, so without this
+ // filter an answered ask_user question would permanently shadow the
+ // sandbox question that fired in the same turn.
+ const harnessPendingQuestion =
+ displayState.pendingQuestion &&
+ !runtime.answeredQuestionIds.has(displayState.pendingQuestion.questionId)
+ ? displayState.pendingQuestion
+ : null;
+ const sandboxPendingQuestion = runtime.pendingSandboxQuestion
+ ? {
+ questionId: runtime.pendingSandboxQuestion.questionId,
+ question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
+ description: runtime.pendingSandboxQuestion.reason,
+ options: [
+ {
+ label: "Yes",
+ description: "Allow access.",
+ },
+ { label: "No", description: "Deny access." },
+ ],
+ }
+ : null;
return {
...displayState,
- pendingQuestion:
- displayState.pendingQuestion ??
- (runtime.pendingSandboxQuestion
- ? {
- questionId: runtime.pendingSandboxQuestion.questionId,
- question: `Grant sandbox access to "${runtime.pendingSandboxQuestion.path}"?`,
- options: [
- {
- label: "Yes",
- description: `Allow access. Reason: ${runtime.pendingSandboxQuestion.reason}`,
- },
- {
- label: "No",
- description: "Deny access.",
- },
- ],
- }
- : null),
+ pendingQuestion: harnessPendingQuestion ?? sandboxPendingQuestion,
errorMessage: currentMessageError ?? runtime.lastErrorMessage,
};
}
@@ -537,13 +597,7 @@ export class ChatRuntimeManager {
input.workspaceId,
);
- if (
- runtime.pendingSandboxQuestion?.questionId === input.payload.questionId
- ) {
- runtime.pendingSandboxQuestion = null;
- }
-
- return runtime.harness.respondToQuestion(input.payload);
+ return respondToQuestionWithOptimisticState(runtime, input.payload);
}
async respondToPlan(input: {
diff --git a/packages/host-service/src/runtime/teardown/teardown.ts b/packages/host-service/src/runtime/teardown/teardown.ts
index f929f73c98d..b740ae7439b 100644
--- a/packages/host-service/src/runtime/teardown/teardown.ts
+++ b/packages/host-service/src/runtime/teardown/teardown.ts
@@ -106,6 +106,7 @@ export async function runTeardown({
workspaceId,
db,
initialCommand,
+ listed: false,
});
if ("error" in session) {
return {
diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts
index fc8b859ecb7..cec378c0af0 100644
--- a/packages/host-service/src/terminal/terminal.ts
+++ b/packages/host-service/src/terminal/terminal.ts
@@ -43,6 +43,15 @@ type TerminalServerMessage =
| { type: "replay"; data: string };
const MAX_BUFFER_BYTES = 64 * 1024;
+const SOCKET_OPEN = 1;
+const SOCKET_CLOSING = 2;
+const SOCKET_CLOSED = 3;
+
+type TerminalSocket = {
+ send: (data: string) => void;
+ close: (code?: number, reason?: string) => void;
+ readyState: number;
+};
// ---------------------------------------------------------------------------
// OSC 133 shell readiness detection (FinalTerm semantic prompt standard).
@@ -67,17 +76,16 @@ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported";
interface TerminalSession {
terminalId: string;
+ workspaceId: string;
pty: IPty;
- socket: {
- send: (data: string) => void;
- close: (code?: number, reason?: string) => void;
- readyState: number;
- } | null;
+ sockets: Set;
buffer: string[];
bufferBytes: number;
+ createdAt: number;
exited: boolean;
exitCode: number;
exitSignal: number;
+ listed: boolean;
// Shell readiness (OSC 133)
shellReadyState: ShellReadyState;
@@ -90,14 +98,82 @@ interface TerminalSession {
/** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */
const sessions = new Map();
+function pruneAndCountOpenSockets(session: TerminalSession): number {
+ let openSockets = 0;
+ for (const socket of session.sockets) {
+ if (socket.readyState === SOCKET_OPEN) {
+ openSockets += 1;
+ } else if (
+ socket.readyState === SOCKET_CLOSING ||
+ socket.readyState === SOCKET_CLOSED
+ ) {
+ session.sockets.delete(socket);
+ }
+ }
+ return openSockets;
+}
+
+export interface TerminalSessionSummary {
+ terminalId: string;
+ workspaceId: string;
+ createdAt: number;
+ exited: boolean;
+ exitCode: number;
+ attached: boolean;
+}
+
+export function listTerminalSessions(
+ options: { workspaceId?: string; includeExited?: boolean } = {},
+): TerminalSessionSummary[] {
+ const includeExited = options.includeExited ?? true;
+
+ return Array.from(sessions.values())
+ .filter((session) => session.listed)
+ .filter(
+ (session) =>
+ options.workspaceId === undefined ||
+ session.workspaceId === options.workspaceId,
+ )
+ .filter((session) => includeExited || !session.exited)
+ .map((session) => ({
+ terminalId: session.terminalId,
+ workspaceId: session.workspaceId,
+ createdAt: session.createdAt,
+ exited: session.exited,
+ exitCode: session.exitCode,
+ attached: pruneAndCountOpenSockets(session) > 0,
+ }));
+}
+
function sendMessage(
socket: { send: (data: string) => void; readyState: number },
message: TerminalServerMessage,
) {
- if (socket.readyState !== 1) return;
+ if (socket.readyState !== SOCKET_OPEN) return;
socket.send(JSON.stringify(message));
}
+function broadcastMessage(
+ session: TerminalSession,
+ message: TerminalServerMessage,
+): number {
+ let sent = 0;
+ for (const socket of session.sockets) {
+ if (socket.readyState !== SOCKET_OPEN) {
+ if (
+ socket.readyState === SOCKET_CLOSING ||
+ socket.readyState === SOCKET_CLOSED
+ ) {
+ session.sockets.delete(socket);
+ }
+ continue;
+ }
+ sendMessage(socket, message);
+ sent += 1;
+ }
+ return sent;
+}
+
function bufferOutput(session: TerminalSession, data: string) {
session.buffer.push(data);
session.bufferBytes += data.length;
@@ -134,14 +210,13 @@ function resolveShellReady(
session.shellReadyTimeoutId = null;
}
// Flush held marker bytes — they weren't part of a full marker.
- // Send directly to a connected socket so the output isn't lost; fall back
- // to the output buffer when no client is currently attached.
+ // Broadcast to every attached socket so the output isn't lost; if no
+ // client is currently attached, fall back to the output buffer.
if (session.scanState.heldBytes.length > 0) {
const heldBytes = session.scanState.heldBytes;
session.scanState.heldBytes = "";
- if (session.socket?.readyState === 1) {
- sendMessage(session.socket, { type: "data", data: heldBytes });
- } else {
+ const sent = broadcastMessage(session, { type: "data", data: heldBytes });
+ if (sent === 0) {
bufferOutput(session, heldBytes);
}
}
@@ -166,6 +241,10 @@ export function disposeSession(terminalId: string, db: HostDb) {
clearTimeout(session.shellReadyTimeoutId);
session.shellReadyTimeoutId = null;
}
+ for (const socket of session.sockets) {
+ socket.close(1000, "Session disposed");
+ }
+ session.sockets.clear();
if (!session.exited) {
try {
session.pty.kill();
@@ -221,6 +300,8 @@ interface CreateTerminalSessionOptions {
db: HostDb;
/** Command to run after the shell is ready. Queued behind shellReadyPromise. */
initialCommand?: string;
+ /** Hidden sessions are process-internal and should not appear in user pickers. */
+ listed?: boolean;
}
export function createTerminalSessionInternal({
@@ -229,9 +310,11 @@ export function createTerminalSessionInternal({
themeType,
db,
initialCommand,
+ listed = true,
}: CreateTerminalSessionOptions): TerminalSession | { error: string } {
const existing = sessions.get(terminalId);
if (existing) {
+ if (listed) existing.listed = true;
return existing;
}
@@ -292,15 +375,18 @@ export function createTerminalSessionInternal({
};
}
+ const createdAt = Date.now();
+
db.insert(terminalSessions)
.values({
id: terminalId,
originWorkspaceId: workspaceId,
status: "active",
+ createdAt,
})
.onConflictDoUpdate({
target: terminalSessions.id,
- set: { status: "active", endedAt: null },
+ set: { status: "active", createdAt, endedAt: null },
})
.run();
@@ -318,13 +404,16 @@ export function createTerminalSessionInternal({
const session: TerminalSession = {
terminalId,
+ workspaceId,
pty,
- socket: null,
+ sockets: new Set(),
buffer: [],
bufferBytes: 0,
+ createdAt,
exited: false,
exitCode: 0,
exitSignal: 0,
+ listed,
shellReadyState: shellSupportsReady ? "pending" : "unsupported",
shellReadyResolve,
shellReadyPromise,
@@ -353,9 +442,7 @@ export function createTerminalSessionInternal({
}
if (data.length === 0) return;
- if (session.socket?.readyState === 1) {
- sendMessage(session.socket, { type: "data", data });
- } else {
+ if (broadcastMessage(session, { type: "data", data }) === 0) {
bufferOutput(session, data);
}
});
@@ -370,13 +457,11 @@ export function createTerminalSessionInternal({
.where(eq(terminalSessions.id, terminalId))
.run();
- if (session.socket?.readyState === 1) {
- sendMessage(session.socket, {
- type: "exit",
- exitCode: session.exitCode,
- signal: session.exitSignal,
- });
- }
+ broadcastMessage(session, {
+ type: "exit",
+ exitCode: session.exitCode,
+ signal: session.exitSignal,
+ });
});
if (initialCommand) {
@@ -441,13 +526,10 @@ export function registerWorkspaceTerminalRoute({
// REST list — enumerate live terminal sessions
app.get("/terminal/sessions", (c) => {
- const result = Array.from(sessions.values()).map((s) => ({
- terminalId: s.terminalId,
- exited: s.exited,
- exitCode: s.exitCode,
- attached: s.socket !== null,
- }));
- return c.json({ sessions: result });
+ const workspaceId = c.req.query("workspaceId") || undefined;
+ return c.json({
+ sessions: listTerminalSessions({ workspaceId, includeExited: true }),
+ });
});
app.get(
@@ -491,7 +573,7 @@ export function registerWorkspaceTerminalRoute({
return;
}
- result.socket = ws;
+ result.sockets.add(ws);
db.update(terminalSessions)
.set({ lastAttachedAt: Date.now() })
@@ -500,10 +582,7 @@ export function registerWorkspaceTerminalRoute({
return;
}
- if (existing.socket && existing.socket !== ws) {
- existing.socket.close(4000, "Displaced by new connection");
- }
- existing.socket = ws;
+ existing.sockets.add(ws);
db.update(terminalSessions)
.set({ lastAttachedAt: Date.now() })
@@ -522,18 +601,16 @@ export function registerWorkspaceTerminalRoute({
onMessage: (event, ws) => {
const session = sessions.get(terminalId ?? "");
- if (!session || session.socket !== ws) return;
+ if (!session || !session.sockets.has(ws)) return;
let message: TerminalClientMessage;
try {
message = JSON.parse(String(event.data)) as TerminalClientMessage;
} catch {
- if (session.socket) {
- sendMessage(session.socket, {
- type: "error",
- message: "Invalid terminal message payload",
- });
- }
+ sendMessage(ws, {
+ type: "error",
+ message: "Invalid terminal message payload",
+ });
return;
}
@@ -558,16 +635,12 @@ export function registerWorkspaceTerminalRoute({
onClose: (_event, ws) => {
const session = sessions.get(terminalId ?? "");
- if (session?.socket === ws) {
- session.socket = null;
- }
+ session?.sockets.delete(ws);
},
onError: (_event, ws) => {
const session = sessions.get(terminalId ?? "");
- if (session?.socket === ws) {
- session.socket = null;
- }
+ session?.sockets.delete(ws);
},
};
}),
diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts
index c90dfd7253b..823ceda946e 100644
--- a/packages/host-service/src/trpc/router/terminal/terminal.ts
+++ b/packages/host-service/src/trpc/router/terminal/terminal.ts
@@ -1,6 +1,11 @@
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
import { z } from "zod";
+import { terminalSessions, workspaces } from "../../../db/schema";
import {
createTerminalSessionInternal,
+ disposeSession,
+ listTerminalSessions,
parseThemeType,
} from "../../../terminal/terminal";
import { protectedProcedure, router } from "../../index";
@@ -34,4 +39,58 @@ export const terminalRouter = router({
return { terminalId: result.terminalId, status: "active" as const };
}),
+
+ listSessions: protectedProcedure
+ .input(
+ z.object({
+ workspaceId: z.string(),
+ }),
+ )
+ .query(({ input }) => ({
+ sessions: listTerminalSessions({
+ workspaceId: input.workspaceId,
+ includeExited: false,
+ }),
+ })),
+
+ killSession: protectedProcedure
+ .input(
+ z.object({
+ terminalId: z.string(),
+ workspaceId: z.string(),
+ }),
+ )
+ .mutation(({ ctx, input }) => {
+ const workspace = ctx.db.query.workspaces
+ .findFirst({ where: eq(workspaces.id, input.workspaceId) })
+ .sync();
+
+ if (!workspace) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Workspace not found",
+ });
+ }
+
+ const session = ctx.db.query.terminalSessions
+ .findFirst({ where: eq(terminalSessions.id, input.terminalId) })
+ .sync();
+
+ if (!session) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Terminal session not found",
+ });
+ }
+
+ if (session.originWorkspaceId !== input.workspaceId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Terminal session does not belong to this workspace",
+ });
+ }
+
+ disposeSession(input.terminalId, ctx.db);
+ return { terminalId: input.terminalId, status: "disposed" as const };
+ }),
});
diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts
index e54a92f7b44..989066ed357 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts
@@ -32,10 +32,13 @@ export const searchGitHubIssues = protectedProcedure
repo: repo.name,
issue_number: issueNumber,
});
- // issues.get returns PRs too — filter them out
+ // issues.get returns PRs too - filter them out
if (issue.pull_request) {
return { issues: [] };
}
+ if (!input.includeClosed && issue.state !== "open") {
+ return { issues: [] };
+ }
return {
issues: [
{
@@ -49,8 +52,9 @@ export const searchGitHubIssues = protectedProcedure
};
}
+ const stateFilter = input.includeClosed ? "" : " is:open";
const query =
- `repo:${repo.owner}/${repo.name} is:issue ${effectiveQuery}`.trim();
+ `repo:${repo.owner}/${repo.name} is:issue${stateFilter} ${effectiveQuery}`.trim();
const { data } = await octokit.search.issuesAndPullRequests({
q: query,
per_page: limit,
diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
index 77c0d9bd2d7..d790b560a14 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts
@@ -3,6 +3,14 @@ import { normalizeGitHubQuery } from "../normalize-github-query";
import { githubSearchInputSchema } from "../schemas";
import { resolveGithubRepo } from "../shared/project-helpers";
+function normalizePullRequestState(
+ state: string,
+ mergedAt: string | null | undefined,
+): "open" | "closed" | "merged" {
+ if (mergedAt) return "merged";
+ return state.toLowerCase() === "closed" ? "closed" : "open";
+}
+
export const searchPullRequests = protectedProcedure
.input(githubSearchInputSchema)
.query(async ({ ctx, input }) => {
@@ -32,13 +40,17 @@ export const searchPullRequests = protectedProcedure
repo: repo.name,
pull_number: prNumber,
});
+ const state = normalizePullRequestState(pr.state, pr.merged_at);
+ if (!input.includeClosed && state !== "open") {
+ return { pullRequests: [] };
+ }
return {
pullRequests: [
{
prNumber: pr.number,
title: pr.title,
url: pr.html_url,
- state: pr.state,
+ state,
isDraft: pr.draft ?? false,
authorLogin: pr.user?.login ?? null,
},
@@ -46,8 +58,9 @@ export const searchPullRequests = protectedProcedure
};
}
+ const stateFilter = input.includeClosed ? "" : " is:open";
const query =
- `repo:${repo.owner}/${repo.name} is:pr ${effectiveQuery}`.trim();
+ `repo:${repo.owner}/${repo.name} is:pr${stateFilter} ${effectiveQuery}`.trim();
const { data } = await octokit.search.issuesAndPullRequests({
q: query,
per_page: limit,
@@ -57,14 +70,20 @@ export const searchPullRequests = protectedProcedure
return {
pullRequests: data.items
.filter((item) => item.pull_request)
- .map((item) => ({
- prNumber: item.number,
- title: item.title,
- url: item.html_url,
- state: item.state,
- isDraft: item.draft ?? false,
- authorLogin: item.user?.login ?? null,
- })),
+ .map((item) => {
+ const state = normalizePullRequestState(
+ item.state,
+ item.pull_request?.merged_at,
+ );
+ return {
+ prNumber: item.number,
+ title: item.title,
+ url: item.html_url,
+ state,
+ isDraft: item.draft ?? false,
+ authorLogin: item.user?.login ?? null,
+ };
+ }),
};
} catch (err) {
console.warn("[workspaceCreation.searchPullRequests] failed", err);
diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts
index b5ea31f492f..bf82f949bc0 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts
@@ -112,6 +112,7 @@ export const githubSearchInputSchema = z.object({
projectId: z.string(),
query: z.string().optional(),
limit: z.number().min(1).max(100).optional(),
+ includeClosed: z.boolean().optional(),
});
export const githubIssueContentInputSchema = z.object({
diff --git a/packages/ui/src/components/ai-elements/braille-spinner.tsx b/packages/ui/src/components/ai-elements/braille-spinner.tsx
new file mode 100644
index 00000000000..af6e7151bfa
--- /dev/null
+++ b/packages/ui/src/components/ai-elements/braille-spinner.tsx
@@ -0,0 +1,29 @@
+import { useEffect, useState } from "react";
+import { cn } from "../../lib/utils";
+
+const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+const INTERVAL = 80;
+
+export function BrailleSpinner({ className }: { className?: string }) {
+ const [frame, setFrame] = useState(0);
+
+ useEffect(() => {
+ const id = setInterval(
+ () => setFrame((f) => (f + 1) % FRAMES.length),
+ INTERVAL,
+ );
+ return () => clearInterval(id);
+ }, []);
+
+ return (
+
+ {FRAMES[frame]}
+
+ );
+}
diff --git a/packages/ui/src/components/ai-elements/tool-call-row.tsx b/packages/ui/src/components/ai-elements/tool-call-row.tsx
new file mode 100644
index 00000000000..c19ec9e6d5f
--- /dev/null
+++ b/packages/ui/src/components/ai-elements/tool-call-row.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import {
+ ChevronDownIcon,
+ ChevronRightIcon,
+ TriangleAlertIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { ComponentType, ReactNode } from "react";
+import { useState } from "react";
+import { cn } from "../../lib/utils";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "../ui/collapsible";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+import { BrailleSpinner } from "./braille-spinner";
+
+export type ToolCallRowProps = {
+ /** Icon shown in the header (replaced by chevron on hover when expandable). */
+ icon: ComponentType<{ className?: string }>;
+ /**
+ * Header title. A plain string is wrapped in a ShimmerLabel that pulses while
+ * `isPending` is true. Any other ReactNode is rendered as-is (useful when the
+ * title contains interactive elements like clickable file paths).
+ */
+ title: ReactNode;
+ /** Optional muted text rendered after the title, truncated when too long. */
+ description?: ReactNode;
+ /** When true the title shimmers and the default status shows a spinner. */
+ isPending?: boolean;
+ /** When true the default status shows an X icon. */
+ isError?: boolean;
+ /** When true shows an outlined amber warning triangle inline after the description with a "Not configured" tooltip. */
+ isNotConfigured?: boolean;
+ /**
+ * Overrides the default status slot (X on error, nothing otherwise).
+ * Pass `null` to render nothing. Omit (undefined) to use the default.
+ */
+ statusNode?: ReactNode;
+ /**
+ * Extra element placed outside (after) the CollapsibleTrigger button — useful
+ * for action buttons that must not toggle expansion when clicked (e.g. "Open
+ * in pane").
+ */
+ headerExtra?: ReactNode;
+ /** Expandable content rendered inside the collapsible area with the left border. */
+ children?: ReactNode;
+ className?: string;
+};
+
+/**
+ * Shared collapsible row used by every tool call type.
+ *
+ * Provides a consistent layout:
+ * [icon/chevron] [title] [description ...] | [status] [headerExtra?]
+ * └── collapsible content with left border ─────────────────────────────┘
+ */
+export function ToolCallRow({
+ icon: Icon,
+ title,
+ description,
+ isPending = false,
+ isError = false,
+ isNotConfigured = false,
+ statusNode,
+ headerExtra,
+ children,
+ className,
+}: ToolCallRowProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const hasDetails = children != null && children !== false;
+
+ const defaultStatus = isError ? (
+
+ ) : null;
+
+ const resolvedDescription =
+ description ??
+ (isError ? (
+
+
+ Error
+
+ ) : null);
+
+ const titleContent =
+ typeof title === "string" ? (
+ {title}
+ ) : (
+ title
+ );
+
+ return (
+ hasDetails && setIsOpen(open)}
+ open={hasDetails ? isOpen : false}
+ >
+
+
+
+
+ {headerExtra}
+
+ {hasDetails && (
+
+ {children}
+
+ )}
+
+ );
+}