From 7a7c2265a620a4873068d472789fc2d3a1a5b7ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 20:02:05 -0800 Subject: [PATCH 01/23] Restore Cursor adapter on stacked branch Co-authored-by: codex --- .../src/provider/Layers/CursorAdapter.test.ts | 655 +++++++ .../src/provider/Layers/CursorAdapter.ts | 1566 +++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 26 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Services/CursorAdapter.test.ts | 131 ++ .../src/provider/Services/CursorAdapter.ts | 110 ++ apps/server/src/serverLayers.ts | 5 + apps/web/src/routes/_chat.settings.tsx | 7 + apps/web/src/session-logic.test.ts | 11 +- apps/web/src/session-logic.ts | 2 +- 10 files changed, 2504 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/provider/Layers/CursorAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapter.ts create mode 100644 apps/server/src/provider/Services/CursorAdapter.test.ts create mode 100644 apps/server/src/provider/Services/CursorAdapter.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 0000000000..76a46d106e --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,655 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import readline from "node:readline"; + +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); +const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); +const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy"); + +class FakeCursorAcpProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly requests: Array<{ method: string; params: unknown }> = []; + killed = false; + + private readonly input = readline.createInterface({ input: this.stdin }); + private permissionRequestId = 700; + lastPermissionSelection: string | undefined; + + constructor() { + super(); + this.input.on("line", (line) => { + const message = JSON.parse(line) as Record; + if (typeof message.method === "string") { + this.handleRequest(message); + return; + } + + if (message.id === this.permissionRequestId) { + const optionId = + (message.result as { outcome?: { optionId?: unknown } } | undefined)?.outcome?.optionId; + if (typeof optionId === "string") { + this.lastPermissionSelection = optionId; + } + } + }); + } + + kill(): boolean { + if (this.killed) { + return true; + } + this.killed = true; + this.emit("exit", 0, null); + return true; + } + + emitPermissionRequest(): void { + this.emitServerMessage({ + jsonrpc: "2.0", + id: this.permissionRequestId, + method: "session/request_permission", + params: { + sessionId: "acp-session-1", + toolCall: { + toolCallId: "tool-perm-1", + kind: "execute", + title: "`pwd`", + }, + options: [ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ], + }, + }); + } + + private handleRequest(message: Record): void { + const method = message.method; + const id = message.id; + if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) { + return; + } + this.requests.push({ method, params: message.params }); + + switch (method) { + case "initialize": { + const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined) + ?.protocolVersion; + if (typeof protocolVersion !== "number") { + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: "Invalid params", + data: { + _errors: [], + protocolVersion: { + _errors: ["Invalid input: expected number, received undefined"], + }, + }, + }, + }); + return; + } + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + }, + authMethods: [{ id: "cursor_login" }], + }, + }); + return; + } + case "authenticate": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/new": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + sessionId: "acp-session-1", + modes: { + currentModeId: "agent", + availableModes: [{ id: "agent" }], + }, + }, + }); + return; + case "session/load": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/set_model": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/prompt": { + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + kind: "execute", + title: "`pwd`", + rawInput: { command: "pwd" }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "/tmp/project", + stderr: "", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + stopReason: "end_turn", + }, + }); + return; + } + case "session/cancel": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: "Method not found", + }, + }); + return; + default: + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); + } + } + + private emitServerMessage(message: unknown): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +describe("CursorAdapterLive", () => { + it.effect("returns validation error for non-cursor provider on startSession", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter + .startSession({ + provider: "codex", + threadId: THREAD_ID, + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "startSession", + issue: "Expected provider 'cursor' but received 'codex'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps ACP prompt/update events into canonical runtime events", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.configured", + "auth.status", + "auth.status", + "session.started", + "thread.started", + "session.state.changed", + "turn.started", + "content.delta", + "content.delta", + "item.started", + "item.completed", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = events[6]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(String(turnStarted.turnId), String(turn.turnId)); + } + + const completion = events[12]; + assert.equal(completion?.type, "turn.completed"); + if (completion?.type === "turn.completed") { + assert.equal(completion.payload.state, "completed"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes requested model to ACP process startup", () => { + const fake = new FakeCursorAcpProcess(); + let createProcessInput: + | { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + } + | undefined; + const layer = makeCursorAdapterLive({ + createProcess: (input) => { + createProcessInput = input; + return fake as never; + }, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + model: "composer-1.5", + runtimeMode: "full-access", + }); + + assert.deepEqual(createProcessInput?.model, "composer-1.5"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("writes provider-native observability records when enabled", () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + method?: string; + threadId?: string; + }; + }> = []; + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + nativeEventLogger: { + filePath: "memory://cursor-native-events", + write: (event) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + return Effect.void; + }, + close: () => Effect.void, + }, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + assert.equal(nativeEvents.length > 0, true); + assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); + assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true); + assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resumes ACP session using resumeCursor.acpSessionId", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: RESUME_THREAD_ID, + cwd: "/tmp/project", + resumeCursor: { + acpSessionId: "acp-session-resume", + }, + runtimeMode: "full-access", + }); + + const methods = new Set(fake.requests.map((request) => request.method)); + assert.equal(methods.has("session/load"), true); + assert.equal(methods.has("session/new"), false); + + const loadRequest = fake.requests.find((request) => request.method === "session/load"); + assert.deepEqual(loadRequest?.params, { + sessionId: "acp-session-resume", + cwd: "/tmp/project", + mcpServers: [], + }); + assert.equal(session.threadId, RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + acpSessionId: "acp-session-resume", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("accepts legacy resumeCursor.sessionId for ACP session resume", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: LEGACY_RESUME_THREAD_ID, + cwd: "/tmp/project", + resumeCursor: { + sessionId: "acp-session-legacy", + }, + runtimeMode: "full-access", + }); + + const loadRequest = fake.requests.find((request) => request.method === "session/load"); + assert.deepEqual(loadRequest?.params, { + sessionId: "acp-session-legacy", + cwd: "/tmp/project", + mcpServers: [], + }); + assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID); + assert.deepEqual(session.resumeCursor, { + acpSessionId: "acp-session-legacy", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("bridges permission requests to request.opened/request.resolved", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "approval-required", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + const opened = yield* Stream.runHead(adapter.streamEvents); + assert.equal(opened._tag, "Some"); + if (opened._tag !== "Some") { + return; + } + assert.equal(opened.value.type, "request.opened"); + if (opened.value.type !== "request.opened") { + return; + } + const runtimeRequestId = opened.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(runtimeRequestId), + "acceptForSession", + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + assert.equal(resolved.value.payload.decision, "acceptForSession"); + assert.equal(fake.lastPermissionSelection, "allow-always"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("auto-approves cursor permission requests when approval policy is never", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + + assert.equal(resolved.value.payload.decision, "acceptForSession"); + assert.equal(fake.lastPermissionSelection, "allow-always"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects empty prompt input before starting a turn", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + const result = yield* adapter + .sendTurn({ + threadId: session.threadId, + input: " ", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }), + ); + + assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps tool_call item types consistent through tool_call_update", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find( + (event) => event.type === "item.started" && String(event.itemId) === "tool-1", + ); + const completed = events.find( + (event) => event.type === "item.completed" && String(event.itemId) === "tool-1", + ); + + assert.equal(started?.type, "item.started"); + assert.equal(completed?.type, "item.completed"); + if (started?.type !== "item.started" || completed?.type !== "item.completed") { + return; + } + + assert.equal(started.payload.itemType, "command_execution"); + assert.equal(completed.payload.itemType, "command_execution"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 0000000000..15423b0c7c --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1566 @@ +/** + * CursorAdapterLive - Scoped live implementation for the Cursor ACP provider adapter. + * + * Spawns `agent acp` over stdio, manages JSON-RPC session lifecycle, and maps + * ACP notifications/requests into canonical provider runtime events. + * + * @module CursorAdapterLive + */ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import readline from "node:readline"; + +import { + ApprovalRequestId, + EventId, + type CanonicalItemType, + type CanonicalRequestType, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type RuntimeMode, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Queue, Random, Schema, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + CursorAdapter, + type CursorAdapterShape, + CursorAcpInitializeResult, + CursorAcpPermissionRequest, + CursorAcpSessionNewResult, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "../Services/CursorAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "cursor" as const; +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +const CURSOR_ACP_PROTOCOL_VERSION = 1; + +interface CursorResumeState { + readonly acpSessionId?: string; +} + +interface PendingRequest { + readonly method: string; + readonly timeout: ReturnType; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; +} + +interface PendingPermission { + readonly jsonRpcId: string | number; + readonly requestType: CanonicalRequestType; + readonly options: ReadonlyArray<{ optionId: string }>; +} + +interface CursorTurnState { + readonly turnId: TurnId; + readonly assistantItemId: ReturnType; + readonly startedToolCalls: Set; + readonly toolCalls: Map; + readonly items: Array; +} + +interface CursorSessionContext { + session: ProviderSession; + runtimeMode: RuntimeMode; + readonly child: ChildProcessWithoutNullStreams; + readonly output: readline.Interface; + readonly pending: Map; + readonly pendingPermissions: Map; + readonly turns: Array<{ + id: TurnId; + items: Array; + }>; + turnState: CursorTurnState | undefined; + acpSessionId: string; + nextRpcId: number; + stopping: boolean; +} + +export interface CursorAdapterLiveOptions { + readonly createProcess?: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + }) => ChildProcessWithoutNullStreams; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function asProviderItemId(value: string): ProviderItemId { + return ProviderItemId.makeUnsafe(value); +} + +function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { + return RuntimeRequestId.makeUnsafe(value); +} + +function toSessionError( + threadId: ThreadId, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause, + }); + } + return undefined; +} + +function toRequestError( + threadId: ThreadId, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(threadId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType { + const normalizedKind = asString(kind)?.toLowerCase(); + const normalizedTitle = asString(title)?.toLowerCase(); + + if (normalizedKind === "execute") { + return "command_execution"; + } + if (normalizedKind === "edit" || normalizedKind === "write") { + return "file_change"; + } + if (normalizedKind === "mcp") { + return "mcp_tool_call"; + } + if (normalizedTitle?.includes("terminal")) { + return "command_execution"; + } + return "dynamic_tool_call"; +} + +function normalizeRequestType(toolCall: unknown): CanonicalRequestType { + const record = asObject(toolCall); + const kind = asString(record?.kind)?.toLowerCase(); + if (kind === "execute") { + return "command_execution_approval"; + } + if (kind === "edit" || kind === "write") { + return "file_change_approval"; + } + return "unknown"; +} + +function selectCursorPermissionOption( + options: ReadonlyArray<{ optionId: string }>, + decision: "acceptForSession" | "accept" | "decline" | "cancel", +): string | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + const allowOnce = options.find((option) => option.optionId === "allow-once"); + const rejectOnce = options.find((option) => option.optionId === "reject-once"); + + if (decision === "acceptForSession") { + return allowAlways?.optionId ?? allowOnce?.optionId; + } + if (decision === "accept") { + return allowOnce?.optionId ?? allowAlways?.optionId; + } + return rejectOnce?.optionId ?? options[0]?.optionId; +} + +function selectCursorAutoApprovalOption( + options: ReadonlyArray<{ optionId: string }>, +): { optionId: string; decision: "acceptForSession" | "accept" } | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + if (allowAlways) { + return { + optionId: allowAlways.optionId, + decision: "acceptForSession", + }; + } + const allowOnce = options.find((option) => option.optionId === "allow-once"); + if (allowOnce) { + return { + optionId: allowOnce.optionId, + decision: "accept", + }; + } + return undefined; +} + +function titleForItemType(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function summarizeToolOutput(rawOutput: unknown): string | undefined { + const output = asObject(rawOutput); + if (!output) return undefined; + + const stdout = asString(output.stdout); + if (stdout && stdout.trim().length > 0) { + return stdout.trim().slice(0, 400); + } + + const summary = JSON.stringify(output); + return summary.length > 400 ? `${summary.slice(0, 397)}...` : summary; +} + +function mapStopReasonToTurnState( + stopReason: string | undefined, +): "completed" | "failed" | "interrupted" | "cancelled" { + if (stopReason === "cancelled") return "cancelled"; + if (stopReason === "interrupted") return "interrupted"; + return "completed"; +} + +function readCursorResumeState(resumeCursor: unknown): CursorResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + + const cursor = resumeCursor as { + acpSessionId?: unknown; + sessionId?: unknown; + }; + + const acpSessionId = + typeof cursor.acpSessionId === "string" + ? cursor.acpSessionId + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + + if (!acpSessionId) { + return {}; + } + return { acpSessionId }; +} + +function writeCursorMessage(context: CursorSessionContext, message: unknown): void { + if (!context.child.stdin.writable) { + throw new Error("Cannot write to Cursor ACP stdin."); + } + context.child.stdin.write(`${JSON.stringify(message)}\n`); +} + +function makeCursorAdapter(options?: CursorAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const spawnCursorAcp = (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + }): ChildProcessWithoutNullStreams => { + if (options?.createProcess) { + return options.createProcess(input); + } + const args = input.model ? ["--model", input.model, "acp"] : ["acp"]; + return spawn(input.binaryPath, args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + }); + }; + + const sendRequest = ( + context: CursorSessionContext, + method: string, + params: unknown, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise => { + const id = context.nextRpcId; + context.nextRpcId += 1; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + context.pending.delete(String(id)); + reject(new Error(`Timed out waiting for ${method}.`)); + }, timeoutMs); + + context.pending.set(String(id), { + method, + timeout, + resolve, + reject, + }); + + writeCursorMessage(context, { + jsonrpc: "2.0", + id, + method, + params, + }); + }); + }; + + const resolvePendingRequest = ( + context: CursorSessionContext, + message: Record, + ) => { + const id = message.id; + if (typeof id !== "string" && typeof id !== "number") { + return; + } + const pending = context.pending.get(String(id)); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + context.pending.delete(String(id)); + + const error = asObject(message.error); + if (error) { + pending.reject(new Error(`${pending.method} failed: ${JSON.stringify(error)}`)); + return; + } + + pending.resolve(message.result); + }; + + const decodePermissionRequest = Schema.decodeUnknownSync(CursorAcpPermissionRequest); + const decodeSessionUpdateNotification = Schema.decodeUnknownSync( + CursorAcpSessionUpdateNotification, + ); + + const emitRuntimeWarning = ( + context: CursorSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), + }); + }); + + const completeTurn = ( + context: CursorSessionContext, + state: "completed" | "failed" | "interrupted" | "cancelled", + errorMessage?: string, + stopReason?: string, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return; + } + + const itemStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: itemStamp.eventId, + provider: PROVIDER, + createdAt: itemStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: asRuntimeItemId(turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + providerTurnId: String(turnState.turnId), + providerItemId: turnState.assistantItemId, + }, + }); + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + payload: { + state, + ...(stopReason ? { stopReason } : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + providerTurnId: String(turnState.turnId), + }, + }); + + context.turnState = undefined; + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + ...(errorMessage ? { lastError: errorMessage } : {}), + updatedAt: yield* nowIso, + }; + }); + + const handlePermissionRequest = ( + context: CursorSessionContext, + request: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodePermissionRequest(request); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP permission request.", + error, + ); + } + + return Effect.gen(function* () { + const requestId = ApprovalRequestId.makeUnsafe(randomUUID()); + const requestType = normalizeRequestType(decoded.params.toolCall); + const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId })); + const detail = asString(asObject(decoded.params.toolCall)?.title); + + if (context.runtimeMode === "full-access") { + const selection = + selectCursorAutoApprovalOption(options) ?? + (options[0] + ? { + optionId: options[0].optionId, + decision: "accept", + } + : undefined); + if (!selection) { + return yield* emitRuntimeWarning( + context, + "Cursor ACP permission request contained no selectable options.", + decoded.params, + ); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: decoded.id, + result: { + outcome: { + outcome: "selected", + optionId: selection.optionId, + }, + }, + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision: selection.decision, + resolution: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(decoded.id), + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + }); + return; + } + + context.pendingPermissions.set(requestId, { + jsonRpcId: decoded.id, + requestType, + options, + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + ...(detail ? { detail } : {}), + args: decoded.params, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(decoded.id), + }, + raw: { + source: "cursor.acp.request", + method: decoded.method, + payload: decoded, + }, + }); + }); + }; + + const handleSessionUpdateNotification = ( + context: CursorSessionContext, + notification: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodeSessionUpdateNotification(notification); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP session/update notification.", + error, + ); + } + + return Effect.gen(function* () { + const update = decoded.params.update; + + const base = { + provider: PROVIDER, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + ...(context.turnState + ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } + : {}), + raw: { + source: "cursor.acp.notification" as const, + method: decoded.method, + messageType: update.sessionUpdate, + payload: decoded, + }, + }; + + switch (update.sessionUpdate) { + case "available_commands_update": { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + payload: { + config: { + availableCommands: update.availableCommands, + }, + }, + }); + return; + } + + case "agent_thought_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: "reasoning_text", + delta: update.content.text, + }, + }); + return; + } + + case "agent_message_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(context.turnState.assistantItemId), + payload: { + streamKind: "assistant_text", + delta: update.content.text, + }, + }); + return; + } + + case "tool_call": { + if (!context.turnState) return; + const seen = context.turnState.startedToolCalls.has(update.toolCallId); + const itemType = normalizeToolItemType(update.kind, update.title); + const title = update.title ?? titleForItemType(itemType); + context.turnState.toolCalls.set(update.toolCallId, { itemType, title }); + const detail = asString(asObject(update.rawInput)?.command); + + if (!seen) { + context.turnState.startedToolCalls.add(update.toolCallId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.started", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.updated", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + case "tool_call_update": { + if (!context.turnState) return; + const status = update.status === "completed" ? "completed" : "inProgress"; + const trackedTool = context.turnState.toolCalls.get(update.toolCallId); + const itemType = trackedTool?.itemType ?? "dynamic_tool_call"; + const title = trackedTool?.title ?? titleForItemType(itemType); + const stamp = yield* makeEventStamp(); + const eventType = update.status === "completed" ? "item.completed" : "item.updated"; + yield* offerRuntimeEvent({ + ...base, + type: eventType, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(update.toolCallId), + payload: { + itemType, + status, + title, + ...(summarizeToolOutput(update.rawOutput) + ? { detail: summarizeToolOutput(update.rawOutput) } + : {}), + ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}), + }, + }); + if (update.status === "completed") { + context.turnState.toolCalls.delete(update.toolCallId); + } + return; + } + } + }); + }; + + const handleStdoutLine = (context: CursorSessionContext, line: string): void => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + Effect.runFork( + emitRuntimeWarning(context, "Received invalid JSON from Cursor ACP.", { + line, + }), + ); + return; + } + + const message = asObject(parsed); + if (!message) { + Effect.runFork( + emitRuntimeWarning(context, "Received non-object protocol message from Cursor ACP."), + ); + return; + } + + if (nativeEventLogger) { + try { + const nativeMethod = + typeof message.method === "string" + ? message.method + : typeof message.id === "string" || typeof message.id === "number" + ? "cursor/acp/response" + : "cursor/acp/message"; + const nativeKind = + typeof message.method === "string" && + (typeof message.id === "string" || typeof message.id === "number") + ? "request" + : typeof message.method === "string" + ? "notification" + : "session"; + Effect.runFork( + nativeEventLogger + .write( + { + observedAt: new Date().toISOString(), + event: { + id: EventId.makeUnsafe(randomUUID()), + kind: nativeKind, + provider: PROVIDER, + createdAt: new Date().toISOString(), + method: nativeMethod, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), + payload: message, + }, + }, + null, + ), + ); + } catch { + // Native logging must never block or break protocol handling. + } + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + typeof message.method === "string" + ) { + if (message.method === "session/request_permission") { + Effect.runFork(handlePermissionRequest(context, message)); + return; + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unsupported server request: ${message.method}`, + }, + }); + return; + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + ("result" in message || "error" in message) + ) { + resolvePendingRequest(context, message); + return; + } + + if (typeof message.method === "string") { + if (message.method === "session/update") { + Effect.runFork(handleSessionUpdateNotification(context, message)); + return; + } + + Effect.runFork( + emitRuntimeWarning( + context, + `Unhandled Cursor ACP notification '${message.method}'.`, + message, + ), + ); + return; + } + + Effect.runFork( + emitRuntimeWarning(context, "Received unrecognized protocol message from Cursor ACP.", { + message, + }), + ); + }; + + const stopSessionInternal = ( + context: CursorSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopping) return; + context.stopping = true; + + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Cursor session stopped before request completion.")); + } + context.pending.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped.", "cancelled"); + } + + context.output.close(); + if (!context.child.killed) { + context.child.kill(); + } + + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + }); + } + + sessions.delete(context.session.threadId); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (context.stopping || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const cwd = input.cwd ?? process.cwd(); + const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; + const binaryPath = cursorOptions?.binaryPath ?? "agent"; + const resumeState = readCursorResumeState(input.resumeCursor); + + const child = yield* Effect.try({ + try: () => + spawnCursorAcp({ + binaryPath, + cwd, + env: process.env, + ...(input.model ? { model: input.model } : {}), + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to spawn Cursor ACP process."), + cause, + }), + }); + + const output = readline.createInterface({ input: child.stdout }); + + const session: ProviderSession = { + threadId: input.threadId, + provider: PROVIDER, + status: "connecting", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: CursorSessionContext = { + session, + runtimeMode: input.runtimeMode, + child, + output, + pending: new Map(), + pendingPermissions: new Map(), + turns: [], + turnState: undefined, + acpSessionId: resumeState?.acpSessionId ?? "", + nextRpcId: 1, + stopping: false, + }; + + output.on("line", (line) => { + handleStdoutLine(context, line); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message.length === 0) { + return; + } + Effect.runFork( + emitRuntimeWarning(context, "Cursor ACP stderr output", { + message, + }), + ); + }); + + child.on("error", (error) => { + Effect.runFork( + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + payload: { + message: error.message || "Cursor ACP process error.", + class: "transport_error", + detail: error, + }, + }); + }), + ); + }); + + child.on("exit", (code, signal) => { + if (context.stopping) { + return; + } + Effect.runFork( + Effect.gen(function* () { + if (context.turnState) { + yield* completeTurn( + context, + "failed", + `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + ); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + payload: { + reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + exitKind: code === 0 ? "graceful" : "error", + recoverable: code === 0, + }, + }); + + sessions.delete(context.session.threadId); + }), + ); + }); + + sessions.set(input.threadId, context); + + const initializeResult = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "initialize", { + protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, + }), + catch: (cause) => toRequestError(input.threadId, "initialize", cause), + }); + const decodedInitialize = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Cursor initialize response did not match expected schema.", + cause, + }), + }); + + const initStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: initStamp.eventId, + provider: PROVIDER, + createdAt: initStamp.createdAt, + threadId: input.threadId, + payload: { + config: decodedInitialize, + }, + raw: { + source: "cursor.acp.response", + method: "initialize", + payload: initializeResult, + }, + }); + + const authenticateRequest = { methodId: "cursor_login" }; + const authStartStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authStartStamp.eventId, + provider: PROVIDER, + createdAt: authStartStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: true, + }, + raw: { + source: "cursor.acp.request", + method: "authenticate", + payload: authenticateRequest, + }, + }); + + const authenticateResult = yield* Effect.tryPromise({ + try: async () => sendRequest(context, "authenticate", authenticateRequest), + catch: (cause) => toRequestError(input.threadId, "authenticate", cause), + }).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ); + const authEndStamp = yield* makeEventStamp(); + if (!authenticateResult.ok) { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + createdAt: authEndStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: false, + error: toMessage(authenticateResult.error, "Cursor authentication failed."), + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: { + error: toMessage(authenticateResult.error, "Cursor authentication failed."), + }, + }, + }); + } else { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + createdAt: authEndStamp.createdAt, + threadId: input.threadId, + payload: { + isAuthenticating: false, + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: authenticateResult.value, + }, + }); + } + + const acpSessionId = yield* Effect.tryPromise({ + try: async () => { + if (resumeState?.acpSessionId) { + await sendRequest(context, "session/load", { + sessionId: resumeState.acpSessionId, + cwd, + mcpServers: [], + }); + return resumeState.acpSessionId; + } + + const sessionNewParams: { + cwd: string; + mcpServers: []; + model?: string; + } = { + cwd, + mcpServers: [], + }; + if (input.model) { + sessionNewParams.model = input.model; + } + const result = await sendRequest(context, "session/new", sessionNewParams); + const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); + return decoded.sessionId; + }, + catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause), + }); + + context.acpSessionId = acpSessionId; + context.session = { + ...context.session, + status: "ready", + resumeCursor: { + acpSessionId, + }, + updatedAt: yield* nowIso, + }; + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + createdAt: sessionStartedStamp.createdAt, + threadId: input.threadId, + payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, + }); + + const threadStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: threadStartedStamp.eventId, + provider: PROVIDER, + createdAt: threadStartedStamp.createdAt, + threadId: input.threadId, + payload: { + providerThreadId: acpSessionId, + }, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + createdAt: readyStamp.createdAt, + threadId: input.threadId, + payload: { + state: "ready", + }, + }); + + return { + ...context.session, + }; + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + + if (context.turnState) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, + }); + } + + const promptText = input.input?.trim(); + if (!promptText || promptText.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: CursorTurnState = { + turnId, + assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), + startedToolCalls: new Set(), + toolCalls: new Map(), + items: [], + }; + + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const startedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: startedStamp.eventId, + provider: PROVIDER, + createdAt: startedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: input.model ? { model: input.model } : {}, + providerRefs: { + providerTurnId: String(turnId), + }, + }); + + const promptResultRaw = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/prompt", { + sessionId: context.acpSessionId, + prompt: [{ type: "text", text: promptText }], + }), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + }); + + const promptResult = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor session/prompt response did not match expected schema.", + cause, + }), + }); + const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason); + yield* completeTurn( + context, + turnStateValue, + turnStateValue === "failed" ? "Cursor prompt failed." : undefined, + promptResult.stopReason, + ); + + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + resumeCursor: { + acpSessionId: context.acpSessionId, + }, + }; + + return { + threadId: context.session.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + if (!context.turnState) { + return; + } + + const cancelResult = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), + catch: (cause) => toRequestError(threadId, "session/cancel", cause), + }).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: () => ({ ok: true as const }), + }), + ); + + if (!cancelResult.ok) { + yield* emitRuntimeWarning( + context, + "Cursor ACP session/cancel is unavailable; marking turn as interrupted.", + cancelResult.error, + ); + } + + yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); + }); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return { + threadId: context.session.threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`, + }), + ); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingPermissions.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + const optionId = selectCursorPermissionOption(pending.options, decision); + + if (!optionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `No selectable permission options for request: ${requestId}`, + }); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: pending.jsonRpcId, + result: { + outcome: { + outcome: "selected", + optionId, + }, + }, + }); + + context.pendingPermissions.delete(requestId); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...((context.turnState ? { turnId: context.turnState.turnId } : {})), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType: pending.requestType, + decision, + resolution: { + optionId, + }, + }, + providerRefs: { + ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), + providerRequestId: String(pending.jsonRpcId), + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId, + }, + }, + }); + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, + }), + ); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopping; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "unsupported", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CursorAdapterShape; + }); +} + +export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); + +export function makeCursorAdapterLive(options?: CursorAdapterLiveOptions) { + return Layer.effect(CursorAdapter, makeCursorAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index a50112a62d..13347a7053 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -27,11 +28,30 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; const layer = it.layer( Layer.mergeAll( Layer.provide( ProviderAdapterRegistryLive, - Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.mergeAll( + Layer.succeed(CodexAdapter, fakeCodexAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), + ), ), NodeServices.layer, ), @@ -42,10 +62,12 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c7e..ef7ab2f913 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -26,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter]; + : [yield* CodexAdapter, yield* CursorAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Services/CursorAdapter.test.ts b/apps/server/src/provider/Services/CursorAdapter.test.ts new file mode 100644 index 0000000000..007a212e15 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.test.ts @@ -0,0 +1,131 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + CursorAcpPermissionRequest, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "./CursorAdapter.ts"; + +describe("Cursor ACP schemas", () => { + it("decodes session/update thought and message chunks", () => { + const thought = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, + }); + + expect(thought.params.update.sessionUpdate).toBe("agent_thought_chunk"); + + const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, + }); + + expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); + }); + + it("decodes tool call lifecycle updates", () => { + const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { command: "pwd" }, + }, + }, + }); + + expect(started.params.update.sessionUpdate).toBe("tool_call"); + + const completed = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "ok", + stderr: "", + }, + }, + }, + }); + + expect(completed.params.update.sessionUpdate).toBe("tool_call_update"); + }); + + it("decodes permission requests", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpPermissionRequest)({ + jsonrpc: "2.0", + id: 9, + method: "session/request_permission", + params: { + sessionId: "sess-1", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + + expect(decoded.method).toBe("session/request_permission"); + expect(decoded.params.options).toHaveLength(2); + }); + + it("decodes prompt completion result payload", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ + stopReason: "end_turn", + }); + + expect(decoded.stopReason).toBe("end_turn"); + }); + + it("rejects unsupported update types", () => { + expect(() => + Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "unknown_update", + }, + }, + }), + ).toThrow(); + }); +}); diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 0000000000..405ea3689b --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,110 @@ +/** + * CursorAdapter - Cursor ACP implementation of the generic provider adapter contract. + * + * Defines ACP JSON-RPC schemas used by the Cursor adapter layer. + * + * @module CursorAdapter + */ +import { Schema, ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]); +export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type; + +export const CursorAcpTextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}); +export type CursorAcpTextContent = typeof CursorAcpTextContent.Type; + +export const CursorAcpSessionUpdate = Schema.Union([ + Schema.Struct({ + sessionUpdate: Schema.Literal("available_commands_update"), + availableCommands: Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + }), + ), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + toolCallId: Schema.String, + title: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + rawInput: Schema.optional(Schema.Unknown), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + toolCallId: Schema.String, + status: Schema.String, + rawOutput: Schema.optional(Schema.Unknown), + }), +]); +export type CursorAcpSessionUpdate = typeof CursorAcpSessionUpdate.Type; + +export const CursorAcpSessionUpdateNotification = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + method: Schema.Literal("session/update"), + params: Schema.Struct({ + sessionId: Schema.String, + update: CursorAcpSessionUpdate, + }), +}); +export type CursorAcpSessionUpdateNotification = typeof CursorAcpSessionUpdateNotification.Type; + +export const CursorAcpPermissionOption = Schema.Struct({ + optionId: Schema.String, + name: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), +}); +export type CursorAcpPermissionOption = typeof CursorAcpPermissionOption.Type; + +export const CursorAcpPermissionRequest = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + id: CursorAcpJsonRpcId, + method: Schema.Literal("session/request_permission"), + params: Schema.Struct({ + sessionId: Schema.String, + toolCall: Schema.optional(Schema.Unknown), + options: Schema.Array(CursorAcpPermissionOption), + }), +}); +export type CursorAcpPermissionRequest = typeof CursorAcpPermissionRequest.Type; + +export const CursorAcpInitializeResult = Schema.Struct({ + protocolVersion: Schema.optional(Schema.Int), + agentCapabilities: Schema.optional(Schema.Unknown), + authMethods: Schema.optional(Schema.Array(Schema.Unknown)), +}); +export type CursorAcpInitializeResult = typeof CursorAcpInitializeResult.Type; + +export const CursorAcpSessionNewResult = Schema.Struct({ + sessionId: Schema.String, + modes: Schema.optional(Schema.Unknown), +}); +export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type; + +export const CursorAcpSessionPromptResult = Schema.Struct({ + stopReason: Schema.optional(Schema.String), +}); +export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type; + +export interface CursorAdapterShape extends ProviderAdapterShape { + readonly provider: "cursor"; +} + +export class CursorAdapter extends ServiceMap.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b9..e9c6a72c2d 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -57,8 +58,12 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const cursorAdapterLayer = makeCursorAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a272..c762c5f24a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "cursor", + title: "Cursor", + description: "Save additional Cursor model slugs for the picker and `/model` command.", + placeholder: "your-cursor-model-slug", + example: "openai/gpt-oss-120b", + }, ] as const; function getCustomModelsForProvider( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d48..33aebebc69 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -640,14 +640,9 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { + it("advertises codex and cursor on the cursor branch while keeping Claude Code as coming soon", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); - expect(PROVIDER_OPTIONS).toEqual([ - { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, - { value: "cursor", label: "Cursor", available: false }, - ]); expect(claude).toEqual({ value: "claudeCode", label: "Claude Code", @@ -655,8 +650,8 @@ describe("PROVIDER_OPTIONS", () => { }); expect(cursor).toEqual({ value: "cursor", - label: "Cursor", - available: false, + label: "Cursor Agent", + available: true, }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b2..2b34070f1a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -19,7 +19,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeCode", label: "Claude Code", available: false }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor Agent", available: true }, ]; export interface WorkLogEntry { From f8f196787b2ba594bf3ce7a62292996101898724 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 21:57:56 -0800 Subject: [PATCH 02/23] refactor: restore cursor provider surface on stacked branch\n\nCo-authored-by: codex --- .../OrchestrationEngineHarness.integration.ts | 2 +- .../TestProviderAdapter.integration.ts | 10 +- .../orchestrationEngine.integration.test.ts | 415 +++++++++++++++++- .../Layers/CheckpointReactor.test.ts | 145 +++++- .../Layers/ProviderCommandReactor.test.ts | 249 ++++++++++- .../Layers/ProviderCommandReactor.ts | 6 +- .../Layers/ProviderRuntimeIngestion.test.ts | 56 ++- .../provider/Layers/ProviderService.test.ts | 33 +- .../Layers/ProviderSessionDirectory.test.ts | 22 + .../Layers/ProviderSessionDirectory.ts | 2 +- apps/web/src/appSettings.test.ts | 27 ++ apps/web/src/appSettings.ts | 10 + apps/web/src/components/ChatView.tsx | 351 +++++++++++++-- apps/web/src/composerDraftStore.test.ts | 6 +- apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/routes/_chat.settings.tsx | 14 + apps/web/src/store.test.ts | 57 ++- apps/web/src/store.ts | 32 +- packages/contracts/src/model.ts | 108 ++++- packages/contracts/src/orchestration.test.ts | 24 +- packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/provider.test.ts | 47 ++ packages/contracts/src/provider.ts | 13 + packages/contracts/src/providerRuntime.ts | 5 + packages/shared/src/model.test.ts | 126 +++++- packages/shared/src/model.ts | 221 +++++++++- 26 files changed, 1893 insertions(+), 92 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b6ae7ee982..fda249d8af 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -189,7 +189,7 @@ export interface OrchestrationIntegrationHarness { } interface MakeOrchestrationIntegrationHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; readonly realCodex?: boolean; } diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 82c08da3e9..25ce8773bd 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -35,7 +35,7 @@ export interface TestTurnResponse { export type FixtureProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: string; readonly turnId?: string | undefined; @@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record): ProviderRunti export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( threadId: ThreadId, response: TestTurnResponse, @@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness { } interface MakeTestProviderAdapterHarnessOptions { - readonly provider?: "codex"; + readonly provider?: "codex" | "claudeCode"; } function nowIso(): string { @@ -206,7 +206,7 @@ function nowIso(): string { } function sessionNotFound( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ @@ -216,7 +216,7 @@ function sessionNotFound( } function missingSessionEffect( - provider: "codex", + provider: "codex" | "claudeCode", threadId: ThreadId, ): Effect.Effect { return Effect.fail(sessionNotFound(provider, threadId)); diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 3b0a3a4002..7ac8310676 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -32,7 +32,7 @@ const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); const FIXTURE_TURN_ID = "fixture-turn"; const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); -type IntegrationProvider = "codex"; +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -842,3 +842,416 @@ it.live( }), ), ); + +it.live("starts a claudeCode session on first turn when provider is requested", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live("recovers claudeCode sessions after provider stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.providerService.stopAll(); + yield* waitForSync( + () => harness.adapterHarness.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + }); + yield* waitForSync( + () => harness.adapterHarness.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.threadId, "thread-1"); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness.getApprovalResponses(THREAD_ID), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.threadId === "thread-1", + ); + assert.equal(thread.session?.threadId, "thread-1"); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested"); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness.getInterruptCalls(THREAD_ID), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live("reverts claudeCode turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.session?.threadId === "thread-1", + ); + + yield* harness.adapterHarness.queueTurnResponse(THREAD_ID, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurn?.turnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), + true, + ); + assert.equal( + gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), + false, + ); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(THREAD_ID), [1]); + }), + "claudeCode", + ), +); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index d675c85ff5..afdc214b3b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -43,7 +43,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -57,7 +57,7 @@ function createProviderServiceHarness( cwd: string, hasSession = true, sessionCwd = cwd, - providerName: ProviderSession["provider"] = "codex", + providerName: "codex" | "claudeCode" = "codex", ) { const now = new Date().toISOString(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -234,6 +234,7 @@ describe("CheckpointReactor", () => { readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly providerSessionCwd?: string; + readonly providerName?: "codex" | "claudeCode"; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -241,7 +242,7 @@ describe("CheckpointReactor", () => { cwd, options?.hasSession ?? true, options?.providerSessionCwd ?? cwd, - "codex", + options?.providerName ?? "codex", ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -474,6 +475,69 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); + it("captures pre-turn and completion checkpoints for claudeCode runtime events", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + providerName: "claudeCode", + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), + provider: "claudeCode", + + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + }); + await waitForGitRefExists( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), + provider: "claudeCode", + + createdAt: new Date().toISOString(), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + payload: { state: "completed" }, + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1, + ); + + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), + ).toBe(true); + }); + it("appends capture failure activity when turn diff summary cannot be derived", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = new Date().toISOString(); @@ -780,7 +844,7 @@ describe("CheckpointReactor", () => { expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); @@ -789,6 +853,75 @@ describe("CheckpointReactor", () => { ).toBe(false); }); + it("executes provider revert and emits thread.reverted for claudeCode sessions", async () => { + const harness = await createHarness({ providerName: "claudeCode" }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnId: asTurnId("turn-claude-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + + numTurns: 1, + }); + }); + it("processes consecutive revert requests with deterministic rollback sequencing", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); @@ -874,11 +1007,11 @@ describe("CheckpointReactor", () => { expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ - threadId: ThreadId.makeUnsafe("thread-1"), + numTurns: 1, }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4f352435fe..d65016c3aa 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -93,7 +93,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - input.provider === "codex" + (input.provider === "codex" || + input.provider === "claudeCode" || + input.provider === "cursor") ? input.provider : "codex"; const resumeCursor = @@ -185,7 +187,7 @@ describe("ProviderCommandReactor", () => { listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (provider) => Effect.succeed({ - sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", + sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session", }), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -382,6 +384,80 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts first turn with requested provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-first"), + role: "user", + text: "hello claude", + attachments: [], + }, + provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeCode", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.providerName).toBe("claudeCode"); + expect(thread?.session?.threadId).toBe("thread-1"); + }); + + it("starts first turn with cursor provider when provider is specified", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-cursor"), + role: "user", + text: "hello cursor", + attachments: [], + }, + provider: "cursor", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "cursor", + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + runtimeMode: "approval-required", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.providerName).toBe("cursor"); + expect(thread?.session?.threadId).toBe("thread-1"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -428,6 +504,111 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("reuses the same cursor session when requested model is unchanged", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + + it("keeps cursor session/model when model change is unsupported", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "gpt-5.3-codex", + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -513,6 +694,66 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("switches provider by restarting the session when turn request provider changes", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "claudeCode", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + provider: "claudeCode", + runtimeMode: "approval-required", + }); + expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty("resumeCursor"); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.threadId).toBe("thread-1"); + expect(thread?.session?.providerName).toBe("claudeCode"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + }); + it("does not stop the active session when restart fails before rebind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -708,7 +949,7 @@ describe("ProviderCommandReactor", () => { harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ - provider: "codex", + provider: "cursor", method: "session/request_permission", detail: "Unknown pending permission request: approval-request-1", }), @@ -723,7 +964,7 @@ describe("ProviderCommandReactor", () => { session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "running", - providerName: "codex", + providerName: "cursor", runtimeMode: "approval-required", activeTurnId: null, lastError: null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc20..11a66690c2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -213,7 +213,11 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName === "codex" || + thread.session?.providerName === "claudeCode" || + thread.session?.providerName === "cursor" + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846c..10f5f96617 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -452,6 +452,60 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { + const harness = await createHarness(); + const seededAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeCode", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-claude-placeholder", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-claude-placeholder"), + provider: "claudeCode", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-claude-placeholder"), + status: "completed", + }); + + await waitForThread( + harness.engine, + (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, + ); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 63b41d6b06..667e76d7d6 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -52,7 +52,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: "codex" | "claudeCode" | "cursor"; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -217,12 +217,15 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeCode"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + : provider === "claudeCode" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeCode"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -247,6 +250,7 @@ function makeProviderServiceLayer() { return { codex, + claude, layer, }; } @@ -493,6 +497,29 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes explicit claudeCode provider session starts to the claude adapter", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-claude"), { + provider: "claudeCode", + threadId: asThreadId("thread-claude"), + cwd: "/tmp/project-claude", + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "claudeCode"); + assert.equal(routing.claude.startSession.mock.calls.length, 1); + const startInput = routing.claude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { provider?: string; cwd?: string }; + assert.equal(startPayload.provider, "claudeCode"); + assert.equal(startPayload.cwd, "/tmp/project-claude"); + } + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1882c1cc0e..942d04fd0e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -204,4 +204,26 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("accepts cursor provider bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + yield* directory.upsert({ + provider: "cursor", + threadId, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "cursor"); + const resolvedBinding = yield* directory.getBinding(threadId); + assertSome(resolvedBinding, { + threadId, + provider: "cursor", + }); + if (Option.isSome(resolvedBinding)) { + assert.equal(resolvedBinding.value.threadId, threadId); + } + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439bf..be677be3f3 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -25,7 +25,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 5ab5d3c90a..ec779b7cd7 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -22,6 +22,17 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("normalizes provider-specific aliases for claude and cursor", () => { + expect(normalizeCustomModelSlugs(["sonnet"], "claudeCode")).toEqual([]); + expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeCode")).toEqual([ + "claude/custom-sonnet", + ]); + expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]); + expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([ + "cursor/custom-model", + ]); + }); }); describe("getAppModelOptions", () => { @@ -47,6 +58,14 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("keeps a saved custom provider model available as an exact slug option", () => { + const options = getAppModelOptions("claudeCode", ["claude/custom-opus"], "claude/custom-opus"); + + expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( + true, + ); + }); }); describe("resolveAppModelSelection", () => { @@ -83,6 +102,14 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); + + it("includes provider-specific custom slugs in non-codex model lists", () => { + const claudeOptions = getAppModelOptions("claudeCode", ["claude/custom-opus"]); + const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]); + + expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); + expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true); + }); }); describe("resolveAppServiceTier", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a6..37ab8f4ab4 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -28,6 +28,8 @@ const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), + cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -45,6 +47,12 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + customCursorModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -108,6 +116,8 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), + customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f4199..2eacc13992 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,10 +1,12 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, + CURSOR_REASONING_OPTIONS, EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, + type CursorReasoningOption, type MessageId, type ProjectId, type ProjectEntry, @@ -25,8 +27,12 @@ import { import { getDefaultModel, getDefaultReasoningEffort, + getCursorModelCapabilities, + getCursorModelFamilyOptions, getReasoningEffortOptions, normalizeModelSlug, + parseCursorModelSelection, + resolveCursorModelFromSelection, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { @@ -72,7 +78,6 @@ import { findLatestProposedPlan, type PendingApproval, type PendingUserInput, - type ProviderPickerKind, PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, @@ -799,11 +804,23 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const customModelsByProvider = useMemo( + () => ({ + codex: settings.customCodexModels, + claudeCode: settings.customClaudeModels, + cursor: settings.customCursorModels, + }), + [settings.customClaudeModels, settings.customCodexModels, settings.customCursorModels], + ); + const cursorModelSelectionLockedReason = + hasThreadStarted && selectedProvider === "cursor" + ? "Cursor currently does not support changing models after the first message in a thread." + : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -830,21 +847,46 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); - const selectedModelForPicker = selectedModel; + const selectedCursorModel = useMemo( + () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), + [selectedModel, selectedProvider], + ); + const selectedCursorModelCapabilities = useMemo( + () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null), + [selectedCursorModel], + ); + const hasSelectedCursorTraits = Boolean( + selectedCursorModelCapabilities && + (selectedCursorModelCapabilities.supportsReasoning || + selectedCursorModelCapabilities.supportsFast || + selectedCursorModelCapabilities.supportsThinking), + ); + const selectedModelForPicker = + selectedProvider === "cursor" && selectedCursorModel + ? selectedCursorModel.family + : selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), [settings], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; + if (selectedProvider !== "cursor") { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + } + + const currentOptions = modelOptionsByProvider.cursor; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + : selectedModelForPicker; }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, + PROVIDER_OPTIONS.filter( + (option) => + option.available && (lockedProvider === null || option.value === lockedProvider), ).flatMap((option) => modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, @@ -3060,26 +3102,76 @@ export default function ChatView({ threadId }: ChatViewProps) { const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; + if (cursorModelSelectionLockedReason !== null && provider === "cursor") { + scheduleComposerFocus(); + return; + } if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } + const parsedCursorSelection = + provider === "cursor" ? parseCursorModelSelection(model) : null; + const resolvedModel = + provider === "cursor" && parsedCursorSelection?.family === model + ? resolveCursorModelFromSelection({ family: parsedCursorSelection.family }) + : resolveAppModelSelection(provider, customModelsByProvider[provider], model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel( - activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), - ); + setComposerDraftModel(activeThread.id, resolvedModel); scheduleComposerFocus(); }, [ activeThread, + cursorModelSelectionLockedReason, + customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, - settings.customCodexModels, ], ); + const onCursorReasoningSelect = useCallback( + (reasoning: CursorReasoningOption) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning, + fast: cursorSelection.fast, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorFastModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: enabled, + thinking: cursorSelection.thinking, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); + const onCursorThinkingModeChange = useCallback( + (enabled: boolean) => { + if (selectedProvider !== "cursor") return; + const cursorSelection = parseCursorModelSelection(selectedModel); + const nextModel = resolveCursorModelFromSelection({ + family: cursorSelection.family, + reasoning: cursorSelection.reasoning, + fast: cursorSelection.fast, + thinking: enabled, + }); + onProviderModelSelect("cursor", nextModel); + }, + [onProviderModelSelect, selectedModel, selectedProvider], + ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); @@ -3624,16 +3716,84 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Provider/model picker */} - + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + + {selectedProvider === "cursor" ? ( + <> + {hasSelectedCursorTraits && ( + + )} - {selectedProvider === "codex" && selectedEffort != null ? ( + {selectedCursorModel && + selectedCursorModelCapabilities && + hasSelectedCursorTraits && ( + <> + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + + )} + + ) : selectedProvider === "codex" && selectedEffort != null ? ( <> option.available); const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, @@ -5297,13 +5449,24 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; }): Record> { + const cursorFamilyOptions = getCursorModelFamilyOptions(); return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), + cursor: [ + ...cursorFamilyOptions, + ...getAppModelOptions("cursor", settings.customCursorModels).filter( + (option) => + option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug), + ), + ], }; } -const PROVIDER_ICON_BY_PROVIDER: Record = { +const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeCode: ClaudeAI, cursor: CursorIcon, @@ -5339,6 +5502,10 @@ function resolveModelForProviderPicker( return resolved.slug; } + if (provider === "cursor") { + return parseCursorModelSelection(normalized).family; + } + return null; } @@ -5379,8 +5546,15 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { } > -
+ +
+

Accent color override

+

+ Custom color for this provider's usage bar. Leave unset to use the global accent color. +

+
+ { + const color = normalizeAccentColor(event.target.value); + updateSettings({ + providerAccentColors: { + ...settings.providerAccentColors, + [provider]: color, + }, + }); + }} + /> + + {settings.providerAccentColors[provider] ?? "global"} + + {settings.providerAccentColors[provider] ? ( + + ) : null} +
+
); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index a1246e76f8..cf6ff9adba 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -641,13 +641,27 @@ describe("deriveActiveWorkStartedAt", () => { describe("PROVIDER_OPTIONS", () => { it("advertises all currently integrated providers", () => { + const copilot = PROVIDER_OPTIONS.find((option) => option.value === "copilot"); const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); + const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode"); + const geminiCli = PROVIDER_OPTIONS.find((option) => option.value === "geminiCli"); + const amp = PROVIDER_OPTIONS.find((option) => option.value === "amp"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, + { value: "copilot", label: "GitHub Copilot", available: true }, { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor Agent", available: true }, + { value: "opencode", label: "OpenCode", available: true }, + { value: "geminiCli", label: "Gemini CLI", available: true }, + { value: "amp", label: "AMPcode", available: true }, + { value: "kilo", label: "Kilo", available: true }, ]); + expect(copilot).toEqual({ + value: "copilot", + label: "GitHub Copilot", + available: true, + }); expect(claude).toEqual({ value: "claudeCode", label: "Claude Code", @@ -658,5 +672,20 @@ describe("PROVIDER_OPTIONS", () => { label: "Cursor Agent", available: true, }); + expect(opencode).toEqual({ + value: "opencode", + label: "OpenCode", + available: true, + }); + expect(geminiCli).toEqual({ + value: "geminiCli", + label: "Gemini CLI", + available: true, + }); + expect(amp).toEqual({ + value: "amp", + label: "AMPcode", + available: true, + }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 3d64172b4d..5e188d711d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -18,8 +18,13 @@ export const PROVIDER_OPTIONS: Array<{ available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, + { value: "copilot", label: "GitHub Copilot", available: true }, { value: "claudeCode", label: "Claude Code", available: true }, { value: "cursor", label: "Cursor Agent", available: true }, + { value: "opencode", label: "OpenCode", available: true }, + { value: "geminiCli", label: "Gemini CLI", available: true }, + { value: "amp", label: "AMPcode", available: true }, + { value: "kilo", label: "Kilo", available: true }, ]; export interface WorkLogEntry { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index d16826b4be..b001251327 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -143,18 +143,36 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { + if ( + providerName === "codex" || + providerName === "copilot" || + providerName === "claudeCode" || + providerName === "cursor" || + providerName === "opencode" || + providerName === "geminiCli" || + providerName === "amp" || + providerName === "kilo" + ) { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const COPILOT_MODEL_SLUGS = new Set(getModelOptions("copilot").map((option) => option.slug)); const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); +const OPENCODE_MODEL_SLUGS = new Set(getModelOptions("opencode").map((option) => option.slug)); +const GEMINI_CLI_MODEL_SLUGS = new Set(getModelOptions("geminiCli").map((option) => option.slug)); +const _AMP_MODEL_SLUGS = new Set(getModelOptions("amp").map((option) => option.slug)); +const KILO_MODEL_SLUGS = new Set(getModelOptions("kilo").map((option) => option.slug)); const CURSOR_DISTINCT_MODEL_SLUGS = new Set( [...CURSOR_MODEL_SLUGS].filter( - (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), + (slug) => + !CODEX_MODEL_SLUGS.has(slug) && + !COPILOT_MODEL_SLUGS.has(slug) && + !CLAUDE_MODEL_SLUGS.has(slug) && + !OPENCODE_MODEL_SLUGS.has(slug), ), ); @@ -164,11 +182,24 @@ function inferProviderForThreadModel(input: { }): ProviderKind { if ( input.sessionProviderName === "codex" || + input.sessionProviderName === "copilot" || input.sessionProviderName === "claudeCode" || - input.sessionProviderName === "cursor" + input.sessionProviderName === "cursor" || + input.sessionProviderName === "opencode" || + input.sessionProviderName === "geminiCli" || + input.sessionProviderName === "amp" || + input.sessionProviderName === "kilo" ) { return input.sessionProviderName; } + const normalizedCopilot = normalizeModelSlug(input.model, "copilot"); + if (normalizedCopilot && COPILOT_MODEL_SLUGS.has(normalizedCopilot)) { + return "copilot"; + } + const normalizedGeminiCli = normalizeModelSlug(input.model, "geminiCli"); + if (normalizedGeminiCli && GEMINI_CLI_MODEL_SLUGS.has(normalizedGeminiCli)) { + return "geminiCli"; + } const normalizedCursor = normalizeModelSlug(input.model, "cursor"); if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) { return "cursor"; @@ -181,9 +212,19 @@ function inferProviderForThreadModel(input: { if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; } + const normalizedOpencode = normalizeModelSlug(input.model, "opencode"); + if (normalizedOpencode && OPENCODE_MODEL_SLUGS.has(normalizedOpencode)) { + return "opencode"; + } + const normalizedKilo = normalizeModelSlug(input.model, "kilo"); + if (normalizedKilo && KILO_MODEL_SLUGS.has(normalizedKilo)) { + return "kilo"; + } + if (input.model.includes("/")) { + return "opencode"; + } if ( input.model.trim().startsWith("composer-") || - input.model.trim().startsWith("gemini-") || input.model.trim().endsWith("-thinking") ) { return "cursor"; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 91e6a61107..443c5b8bf6 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -182,6 +182,10 @@ export function createWsNativeApi(): NativeApi { return showContextMenuFallback(items, position); }, }, + provider: { + listModels: (input) => transport.request(WS_METHODS.providerListModels, input), + getUsage: (input) => transport.request(WS_METHODS.providerGetUsage, input), + }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), diff --git a/bun.lock b/bun.lock index 864a37c7c3..f03f26bdf3 100644 --- a/bun.lock +++ b/bun.lock @@ -48,8 +48,12 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.71", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@github/copilot": "1.0.2", + "@github/copilot-sdk": "^0.1.32", + "@opencode-ai/sdk": "^1.2.21", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -173,6 +177,8 @@ "vitest": "^4.0.0", }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.71", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pIsQJnM7Y+cJHL7aFY6SCCW3FIni218gVEpPqG8XGowfYxboFNBbNssWiUNRwthT8bp9jypcX7q5kx0Xsw14xg=="], + "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], @@ -333,6 +339,22 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@github/copilot": ["@github/copilot@1.0.2", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.2", "@github/copilot-darwin-x64": "1.0.2", "@github/copilot-linux-arm64": "1.0.2", "@github/copilot-linux-x64": "1.0.2", "@github/copilot-win32-arm64": "1.0.2", "@github/copilot-win32-x64": "1.0.2" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww=="], + + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw=="], + + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg=="], + + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.2", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q=="], + + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.2", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@0.1.32", "", { "dependencies": { "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA=="], + + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.2", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.2", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ=="], + "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], @@ -483,6 +505,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.21", "", {}, "sha512-7XF7QlXDP5iAbGcAsxsrmKlIjRtSzKgbwuDHPPuZbILkhGOfCo05XwPidAZrVWucZ/re44oc/psw2zDECaZOpQ=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], @@ -793,7 +817,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -1761,19 +1785,19 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], + "turbo": ["turbo@2.8.14", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.14", "turbo-darwin-arm64": "2.8.14", "turbo-linux-64": "2.8.14", "turbo-linux-arm64": "2.8.14", "turbo-windows-64": "2.8.14", "turbo-windows-arm64": "2.8.14" }, "bin": { "turbo": "bin/turbo" } }, "sha512-UCTxeMNYT1cKaHiIFdLCQ7ulI+jw5i5uOnJOrRXsgUD7G3+OjlUjwVd7JfeVt2McWSVGjYA3EVW/v1FSsJ5DtA=="], - "turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-9sFi7n2lLfEsGWi5OEoA/eTtQU2BPKtzSYKqufMtDeRmqMT9vKjbv9gJCRkllSVE9BOXA0qXC3diyX8V8rKIKw=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aS4yJuy6A1PCLws+PJpZP0qCURG8Y5iVx13z/WAbKyeDTY6W6PiGgcEllSaeLGxyn++382ztN/EZH85n2zZ6VQ=="], - "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], + "turbo-linux-64": ["turbo-linux-64@2.8.14", "", { "os": "linux", "cpu": "x64" }, "sha512-XC6wPUDJkakjhNLaS0NrHDMiujRVjH+naEAwvKLArgqRaFkNxjmyNDRM4eu3soMMFmjym6NTxYaF74rvET+Orw=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ChfE7isyVNjZrVSPDwcfqcHLG/FuIBbOFxnt1FM8vSuBGzHAs8AlTdwFNIxlEMJfZ8Ad9mdMxdmsCUPIWiQ6cg=="], - "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], + "turbo-windows-64": ["turbo-windows-64@2.8.14", "", { "os": "win32", "cpu": "x64" }, "sha512-FTbIeQL1ycLFW2t9uQNMy+bRSzi3Xhwun/e7ZhFBdM+U0VZxxrtfYEBM9CHOejlfqomk6Jh7aRz0sJoqYn39Hg=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-KgZX12cTyhY030qS7ieT8zRkhZZE2VWJasDFVUSVVn17nR7IShpv68/7j5UqJNeRLIGF1XPK0phsP5V5yw3how=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], @@ -1867,7 +1891,7 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], @@ -1937,6 +1961,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@anthropic-ai/claude-agent-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], @@ -1975,6 +2001,8 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@github/copilot-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -2009,6 +2037,16 @@ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@types/cacheable-request/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + + "@types/keyv/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + + "@types/responselike/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + + "@types/ws/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + + "@types/yauzl/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2021,6 +2059,8 @@ "boxen/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "bun-types/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -2029,6 +2069,8 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "electron/@types/node": ["@types/node@24.11.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-HTsxyfkxTNxOXBsEdgIOzbMgBjDGPvkTfw0B1m09j1LFPk8u3tSL8SNBRTSc381wXXX/Wp93qPi1kQXwnWuHgA=="], + "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], @@ -2061,6 +2103,8 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/config/upstream-pr-tracks.json b/config/upstream-pr-tracks.json new file mode 100644 index 0000000000..f5c090ec3d --- /dev/null +++ b/config/upstream-pr-tracks.json @@ -0,0 +1,23 @@ +{ + "upstreamRemote": "upstream", + "forkRemote": "origin", + "baseBranch": "main", + "integrationBranch": "codex/alpha", + "trackedPrs": [ + { + "number": 179, + "title": "Claude Code adapter", + "localBranch": "codex/pr-179-track" + }, + { + "number": 295, + "title": "GitHub Copilot adapter", + "localBranch": "codex/pr-295-track" + }, + { + "number": 364, + "title": "OpenCode adapter", + "localBranch": "codex/pr-364-track" + } + ] +} diff --git a/docs/custom-alpha-workflow.md b/docs/custom-alpha-workflow.md index 1919604396..07a152cc29 100644 --- a/docs/custom-alpha-workflow.md +++ b/docs/custom-alpha-workflow.md @@ -14,7 +14,7 @@ This clone is intended to use: - `origin` = `https://github.com/aaditagrawal/t3code.git` - `upstream` = `https://github.com/pingdotgg/t3code` -- `main` tracking `upstream/main` +- `main` as the downstream base branch kept close to `upstream/main` - `codex/alpha` as the persistent custom integration branch - `../t3code-alpha` as the dedicated alpha worktree @@ -147,7 +147,7 @@ git remote -v Keep these branch roles stable: -- `main`: clean mirror of `upstream/main` +- `main`: downstream base branch for your fork, rebased or merged forward from `upstream/main` - `codex/alpha`: long-lived integration branch for your custom local app - `codex/feat-`: focused feature branches created from `codex/alpha` - `codex/pr--track`: one local tracking branch per upstream PR you want to follow @@ -314,6 +314,31 @@ Never merge an old open PR branch directly into `codex/alpha` without comparing Instead: +### Config file and refresh command + +The tracked PR list lives in: + +- `config/upstream-pr-tracks.json` + +Refresh all tracked PR branches with: + +```bash +bun run sync:upstream-prs +``` + +That command: + +- fetches each tracked PR head from `upstream` +- updates the matching local `codex/pr--track` branch +- reports unique commits and diff stats versus `main` +- reports pending commits and diff stats versus `codex/alpha` + +Current tracked PRs in this repo: + +- `#179` Claude Code adapter +- `#295` GitHub Copilot adapter +- `#364` OpenCode adapter + ### 1. Fetch the PR into a dedicated tracking branch Example for PR `178`: diff --git a/package.json b/package.json index ee2fe457bf..02e10dcefe 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,11 @@ "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, "devDependencies": { - "@types/node": "catalog:", + "@types/node": "^24.12.0", "oxfmt": "^0.35.0", - "oxlint": "^1.50.0", - "turbo": "^2.3.3", - "vitest": "catalog:" + "oxlint": "^1.51.0", + "turbo": "^2.8.14", + "vitest": "^4.0.18" }, "engines": { "bun": "^1.3.9", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 92f5b502c9..39890a3250 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,6 +20,12 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { + ProviderGetUsageInput, + ProviderListModelsInput, + ProviderListModelsResult, + ProviderUsageResult, +} from "./provider"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -134,6 +140,10 @@ export interface NativeApi { position?: { x: number; y: number }, ) => Promise; }; + provider: { + listModels: (input: ProviderListModelsInput) => Promise; + getUsage: (input: ProviderGetUsageInput) => Promise; + }; server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index d9770ce49f..8934e47bd0 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -13,6 +13,18 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const CopilotModelOptions = Schema.Struct({}); +export type CopilotModelOptions = typeof CopilotModelOptions.Type; + +export const OpencodeModelOptions = Schema.Struct({ + providerId: Schema.optional(Schema.String), + modelId: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), + reasoningEffort: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), +}); +export type OpencodeModelOptions = typeof OpencodeModelOptions.Type; + export const ClaudeCodeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), }); @@ -25,16 +37,41 @@ export const CursorModelOptions = Schema.Struct({ }); export type CursorModelOptions = typeof CursorModelOptions.Type; +export const GeminiCliModelOptions = Schema.Struct({ + thinkingBudget: Schema.optional(Schema.Number), +}); +export type GeminiCliModelOptions = typeof GeminiCliModelOptions.Type; + +export const AmpModelOptions = Schema.Struct({ + mode: Schema.optional(Schema.Literals(["smart", "rush", "deep"])), +}); +export type AmpModelOptions = typeof AmpModelOptions.Type; + +export const KiloModelOptions = Schema.Struct({ + providerId: Schema.optional(Schema.String), + modelId: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), + reasoningEffort: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), +}); +export type KiloModelOptions = typeof KiloModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + copilot: Schema.optional(CopilotModelOptions), claudeCode: Schema.optional(ClaudeCodeModelOptions), cursor: Schema.optional(CursorModelOptions), + opencode: Schema.optional(OpencodeModelOptions), + geminiCli: Schema.optional(GeminiCliModelOptions), + amp: Schema.optional(AmpModelOptions), + kilo: Schema.optional(KiloModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; type ModelOption = { readonly slug: string; readonly name: string; + readonly pricingTier?: string; }; type CursorModelFamilyOption = { @@ -64,6 +101,34 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + copilot: [ + // Multipliers sourced from https://docs.github.com/en/copilot/concepts/billing/copilot-requests + { slug: "gpt-5.4", name: "GPT-5.4", pricingTier: "1x" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", pricingTier: "1x" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex", pricingTier: "1x" }, + { slug: "gpt-5.2", name: "GPT-5.2", pricingTier: "1x" }, + { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", pricingTier: "1x" }, + { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex", pricingTier: "1x" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini (Preview)", pricingTier: "0.33x" }, + { slug: "gpt-5.1", name: "GPT-5.1", pricingTier: "1x" }, + { slug: "gpt-5-mini", name: "GPT-5 mini" }, // included, no premium cost + { slug: "gpt-4.1", name: "GPT-4.1" }, // included, no premium cost + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", pricingTier: "1x" }, + { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", pricingTier: "1x" }, + { slug: "claude-sonnet-4", name: "Claude Sonnet 4", pricingTier: "1x" }, + { slug: "claude-opus-4.6", name: "Claude Opus 4.6", pricingTier: "3x" }, + { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 Fast (Preview)", pricingTier: "30x" }, + { slug: "claude-opus-4.5", name: "Claude Opus 4.5", pricingTier: "3x" }, + { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5", pricingTier: "0.33x" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro (Preview)", pricingTier: "1x" }, + { slug: "gemini-3-pro", name: "Gemini 3 Pro (Preview)", pricingTier: "1x" }, + { slug: "gemini-3-flash", name: "Gemini 3 Flash (Preview)", pricingTier: "0.33x" }, + { slug: "gemini-2.5-pro", name: "Gemini 2.5 Pro", pricingTier: "1x" }, + { slug: "grok-code-fast-1", name: "Grok Code Fast 1", pricingTier: "0.25x" }, + { slug: "goldeneye", name: "Goldeneye (Preview)" }, + { slug: "qwen2.5", name: "Qwen2.5" }, + { slug: "raptor-mini", name: "Raptor mini (Preview)" }, // included, no premium cost + ], claudeCode: [ { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, @@ -90,6 +155,20 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" }, { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, ], + opencode: [] as ModelOption[], + geminiCli: [ + { slug: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { slug: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { slug: "gemini-3-pro-preview", name: "Gemini 3 Pro" }, + { slug: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, + { slug: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, + ], + amp: [ + { slug: "smart", name: "Smart (Opus 4.6)" }, + { slug: "rush", name: "Rush (Fast)" }, + { slug: "deep", name: "Deep (GPT-5.3 Codex)" }, + ], + kilo: [] as ModelOption[], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -99,8 +178,13 @@ export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][numbe export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + copilot: "gpt-5.4", claudeCode: "claude-sonnet-4-6", cursor: "opus-4.6-thinking", + opencode: "gpt-5", + geminiCli: "gemini-2.5-pro", + amp: "smart", + kilo: "gpt-5", } as const satisfies Record; // Backward compatibility for existing Codex-only call sites. @@ -115,6 +199,25 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + copilot: null, claudeCode: null, cursor: null, + opencode: null, + kilo: null, + geminiCli: null, + amp: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4531804b9e..61f0ff2c51 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,16 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeCode", "cursor"]); +export const ProviderKind = Schema.Union([ + Schema.Literal("codex"), + Schema.Literal("copilot"), + Schema.Literal("claudeCode"), + Schema.Literal("cursor"), + Schema.Literal("opencode"), + Schema.Literal("geminiCli"), + Schema.Literal("amp"), + Schema.Literal("kilo"), +]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -235,6 +244,16 @@ const OrchestrationLatestTurnState = Schema.Literals([ ]); export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type; +export const OrchestrationTurnUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + cached_tokens: Schema.optional(Schema.Number), + duration_ms: Schema.optional(Schema.Number), + tool_calls: Schema.optional(Schema.Number), +}); +export type OrchestrationTurnUsage = typeof OrchestrationTurnUsage.Type; + export const OrchestrationLatestTurn = Schema.Struct({ turnId: TurnId, state: OrchestrationLatestTurnState, @@ -242,6 +261,7 @@ export const OrchestrationLatestTurn = Schema.Struct({ startedAt: Schema.NullOr(IsoDateTime), completedAt: Schema.NullOr(IsoDateTime), assistantMessageId: Schema.NullOr(MessageId), + usage: Schema.optional(OrchestrationTurnUsage), }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; @@ -477,6 +497,7 @@ const ThreadSessionSetCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, session: OrchestrationSession, + turnUsage: Schema.optional(OrchestrationTurnUsage), createdAt: IsoDateTime, }); @@ -715,6 +736,7 @@ export const ThreadSessionStopRequestedPayload = Schema.Struct({ export const ThreadSessionSetPayload = Schema.Struct({ threadId: ThreadId, session: OrchestrationSession, + turnUsage: Schema.optional(OrchestrationTurnUsage), }); export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index b7ba9ae148..dfe67b9df6 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -64,10 +64,48 @@ const CursorProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const CopilotProviderStartOptions = Schema.Struct({ + cliPath: Schema.optional(TrimmedNonEmptyStringSchema), + configDir: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const OpencodeProviderStartOptions = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyStringSchema), + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + hostname: Schema.optional(TrimmedNonEmptyStringSchema), + port: Schema.optional(Schema.Number), + workspace: Schema.optional(TrimmedNonEmptyStringSchema), + username: Schema.optional(TrimmedNonEmptyStringSchema), + password: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const GeminiCliProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const AmpProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + +const KiloProviderStartOptions = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyStringSchema), + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + hostname: Schema.optional(TrimmedNonEmptyStringSchema), + port: Schema.optional(Schema.Number), + workspace: Schema.optional(TrimmedNonEmptyStringSchema), + username: Schema.optional(TrimmedNonEmptyStringSchema), + password: Schema.optional(TrimmedNonEmptyStringSchema), +}); + const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), cursor: Schema.optional(CursorProviderStartOptions), + opencode: Schema.optional(OpencodeProviderStartOptions), + geminiCli: Schema.optional(GeminiCliProviderStartOptions), + amp: Schema.optional(AmpProviderStartOptions), + kilo: Schema.optional(KiloProviderStartOptions), }); export const ProviderSessionStartInput = Schema.Struct({ @@ -132,6 +170,61 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ }); export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; +// ── Provider model discovery ──────────────────────────────────────── + +export const ProviderListModelsInput = Schema.Struct({ + provider: ProviderKind, +}); +export type ProviderListModelsInput = typeof ProviderListModelsInput.Type; + +export interface ProviderModelOption { + readonly slug: string; + readonly name: string; + readonly pricingTier?: string; +} + +export interface ProviderListModelsResult { + readonly models: ReadonlyArray; +} + +// ── Provider usage / quota ────────────────────────────────────────── + +export const ProviderGetUsageInput = Schema.Struct({ + provider: ProviderKind, +}); +export type ProviderGetUsageInput = typeof ProviderGetUsageInput.Type; + +export interface ProviderUsageQuota { + readonly plan?: string; + readonly used?: number; + readonly limit?: number; + readonly resetDate?: string; + readonly percentUsed?: number; +} + +export interface ProviderSessionUsage { + readonly totalCostUsd?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedTokens?: number; + readonly totalTokens?: number; + readonly turnCount?: number; +} + +export interface ProviderModelMultiplier { + readonly model: string; + readonly name: string; + readonly multiplier: number; +} + +export interface ProviderUsageResult { + readonly provider: string; + readonly quota?: ProviderUsageQuota; + readonly quotas?: ReadonlyArray; + readonly sessionUsage?: ProviderSessionUsage; + readonly modelMultipliers?: ReadonlyArray; +} + const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); export const ProviderEvent = Schema.Struct({ diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 7f578c276e..6daf679fe2 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -56,7 +56,7 @@ describe("ProviderRuntimeEvent", () => { const parsed = decodeRuntimeEvent({ type: "user-input.requested", eventId: "event-2", - provider: "claudeCode", + provider: "codex", sessionId: "runtime-session-2", createdAt: "2026-02-28T00:00:01.000Z", threadId: "thread-2", @@ -94,7 +94,7 @@ describe("ProviderRuntimeEvent", () => { const parsed = decodeRuntimeEvent({ type: "user-input.resolved", eventId: "event-3", - provider: "claudeCode", + provider: "codex", sessionId: "runtime-session-2", createdAt: "2026-02-28T00:00:02.000Z", threadId: "thread-2", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 66bd61fba9..0aa2ea3006 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -19,12 +19,24 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.notification", "codex.app-server.request", "codex.eventmsg", + "copilot.sdk.session-event", + "copilot.sdk.synthetic", "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", "cursor.acp.notification", "cursor.acp.request", "cursor.acp.response", + "opencode.server.event", + "opencode.server.permission", + "opencode.server.question", + "kilo.server.event", + "kilo.server.permission", + "kilo.server.question", + "amp.cli.system", + "amp.cli.assistant", + "amp.cli.result", + "gemini.cli.event", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 1100b4f9df..994a7aa1f7 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -20,6 +20,7 @@ import { GitRunStackedActionInput, GitStatusInput, } from "./git"; +import { ProviderGetUsageInput, ProviderListModelsInput } from "./provider"; import { TerminalClearInput, TerminalCloseInput, @@ -64,6 +65,10 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Provider methods + providerListModels: "provider.listModels", + providerGetUsage: "provider.getUsage", + // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -126,6 +131,10 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Provider methods + tagRequestBody(WS_METHODS.providerListModels, ProviderListModelsInput), + tagRequestBody(WS_METHODS.providerGetUsage, ProviderGetUsageInput), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 3b61097b24..507b0425a5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -90,9 +90,14 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record> = { - claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), + opencode: new Set(MODEL_OPTIONS_BY_PROVIDER.opencode.map((option) => option.slug)), + kilo: new Set(MODEL_OPTIONS_BY_PROVIDER.kilo.map((option) => option.slug)), + geminiCli: new Set(MODEL_OPTIONS_BY_PROVIDER.geminiCli.map((option) => option.slug)), + amp: new Set(MODEL_OPTIONS_BY_PROVIDER.amp.map((option) => option.slug)), }; const CURSOR_MODEL_FAMILY_SET = new Set( @@ -252,9 +257,12 @@ export function resolveModelSlug( return DEFAULT_MODEL_BY_PROVIDER[provider]; } - return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) - ? normalized - : DEFAULT_MODEL_BY_PROVIDER[provider]; + const catalog = MODEL_SLUG_SET_BY_PROVIDER[provider]; + if (catalog.size === 0) { + return normalized; + } + + return catalog.has(normalized) ? normalized : DEFAULT_MODEL_BY_PROVIDER[provider]; } export function resolveModelSlugForProvider( diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs new file mode 100644 index 0000000000..db367dd1ab --- /dev/null +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const configPath = path.join(repoRoot, "config", "upstream-pr-tracks.json"); + +function runGit(args, options = {}) { + const output = execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + return typeof output === "string" ? output.trim() : ""; +} + +function tryRunGit(args) { + try { + return runGit(args); + } catch { + return null; + } +} + +function loadConfig() { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid upstream PR tracking config."); + } + if (!Array.isArray(parsed.trackedPrs) || parsed.trackedPrs.length === 0) { + throw new Error("No tracked PRs configured."); + } + return parsed; +} + +function splitLines(output) { + if (!output) return []; + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function formatSection(title) { + console.log(`\n${title}`); + console.log("-".repeat(title.length)); +} + +function getComparisonSummary(baseRef, headRef) { + const mergeBase = tryRunGit(["merge-base", baseRef, headRef]); + const uniqueCommits = splitLines( + tryRunGit([ + "log", + "--right-only", + "--cherry-pick", + "--no-merges", + "--oneline", + `${baseRef}...${headRef}`, + ]), + ); + const diffStat = tryRunGit(["diff", "--stat", `${baseRef}...${headRef}`]) || "(no diff)"; + return { + mergeBase, + uniqueCommits, + diffStat, + }; +} + +function main() { + const config = loadConfig(); + const baseBranch = process.argv[2] || config.baseBranch; + const integrationBranch = config.integrationBranch; + + console.log("Refreshing upstream PR tracking branches"); + console.log(`Repo: ${repoRoot}`); + console.log(`Upstream remote: ${config.upstreamRemote}`); + console.log(`Fork remote: ${config.forkRemote}`); + console.log(`Base branch: ${baseBranch}`); + console.log(`Integration branch: ${integrationBranch}`); + + runGit(["fetch", config.upstreamRemote]); + + for (const pr of config.trackedPrs) { + const prRef = `pull/${pr.number}/head:${pr.localBranch}`; + console.log(`\nFetching PR #${pr.number} into ${pr.localBranch}`); + runGit(["fetch", config.upstreamRemote, prRef]); + + const branchSha = runGit(["rev-parse", pr.localBranch]); + const baseSummary = getComparisonSummary(baseBranch, pr.localBranch); + const integrationSummary = getComparisonSummary(integrationBranch, pr.localBranch); + + formatSection(`PR #${pr.number}: ${pr.title}`); + console.log(`URL: https://github.com/pingdotgg/t3code/pull/${pr.number}`); + console.log(`Tracking branch: ${pr.localBranch}`); + console.log(`Branch SHA: ${branchSha}`); + console.log(`Merge base with ${baseBranch}: ${baseSummary.mergeBase ?? "(missing)"}`); + console.log(`Unique commits vs ${baseBranch}: ${baseSummary.uniqueCommits.length}`); + if (baseSummary.uniqueCommits.length > 0) { + for (const line of baseSummary.uniqueCommits) { + console.log(` ${line}`); + } + } else { + console.log(" none"); + } + + console.log(`Merge base with ${integrationBranch}: ${integrationSummary.mergeBase ?? "(missing)"}`); + console.log(`Pending commits vs ${integrationBranch}: ${integrationSummary.uniqueCommits.length}`); + if (integrationSummary.uniqueCommits.length > 0) { + for (const line of integrationSummary.uniqueCommits) { + console.log(` ${line}`); + } + } else { + console.log(" none"); + } + + console.log(`Diff stat vs ${baseBranch}:`); + console.log(baseSummary.diffStat); + console.log(`Diff stat vs ${integrationBranch}:`); + console.log(integrationSummary.diffStat); + } +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`sync-upstream-pr-tracks failed: ${message}`); + process.exitCode = 1; +} From 30571524bdbe42229af7f12d4e17c64d7fe157bf Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 8 Mar 2026 20:47:11 +0530 Subject: [PATCH 17/23] fix: address all PR review feedback from CodeRabbit - Fix claude-agent-sdk.d.ts query signature (prompt accepts string, options optional, Query return type) - Forward numTurns in rollbackThread for Kilo, OpenCode, GeminiCli, Amp adapters - Guard ClaudeCodeAdapter.startSession against duplicate threadIds - Reject unsupported attachments in ClaudeCodeAdapter instead of silently dropping - Replace ClaudeCodeAdapter rollback with explicit not-supported error - Add CopilotAdapter layer cleanup finalizer, fix turn bookkeeping, emit teardown events - Convert CopilotAdapter attachment validation to use Effect.forEach - Read workspace from OpenCode resume cursor, guard against unsupported attachments - Fix diff-only assistant turn suppression in ChatView - Preserve isCustom flag in ModelOptionEntry, add staleTime to model queries - Add WCAG contrast checking for accent colors with fallback - Derive contrast-safe terminal colors for ThreadTerminalDrawer - Drop invalid provider accent overrides instead of normalizing to default - Fail fast on duplicate provider adapter registration - Fix README headings, add Kilo to supported agents - Remove duplicate ProviderRuntimeIngestion test - Make Gemini CLI live tests explicitly opt-in - Fix hardcoded runtimeMode in GeminiCli listSessions - Add stable activeAssistantItemId for Amp content deltas - Kill child process on Kilo server start timeout - Derive repo URL from git remote in sync script - Update plan docs for multi-provider scope --- .plans/17-claude-code.md | 19 +++ README.md | 17 +-- apps/server/src/ampServerManager.ts | 53 ++++++--- .../server/src/geminiCliServerManager.test.ts | 112 +++++++++--------- apps/server/src/geminiCliServerManager.ts | 2 +- apps/server/src/kiloServerManager.ts | 5 + .../Layers/ProviderRuntimeIngestion.test.ts | 54 --------- .../provider/Layers/ClaudeCodeAdapter.test.ts | 69 ++--------- .../src/provider/Layers/ClaudeCodeAdapter.ts | 57 +++++++-- .../src/provider/Layers/CopilotAdapter.ts | 110 +++++++++++++---- .../Layers/ProviderAdapterRegistry.ts | 11 +- .../server/src/provider/claude-agent-sdk.d.ts | 10 +- apps/web/src/accentColor.ts | 89 +++++++++++++- apps/web/src/appSettings.ts | 4 +- apps/web/src/components/ChatView.tsx | 23 ++-- .../src/components/ThreadTerminalDrawer.tsx | 35 +++++- scripts/sync-upstream-pr-tracks.mjs | 18 ++- 17 files changed, 438 insertions(+), 250 deletions(-) diff --git a/.plans/17-claude-code.md b/.plans/17-claude-code.md index 822e978f50..d8e1d15201 100644 --- a/.plans/17-claude-code.md +++ b/.plans/17-claude-code.md @@ -1,5 +1,7 @@ # Plan: Claude Code Integration (Orchestration Architecture) +> **Note -- Multi-provider scope:** This plan was originally written for the Claude Code adapter, but the patterns described here (adapter shape, canonical runtime mapping, resume cursor ownership, provider registry wiring, and orchestration integration) apply equally to the full multi-provider adapter infrastructure now implemented in this PR: **ClaudeCodeAdapter**, **CopilotAdapter**, **OpenCodeAdapter**, **GeminiCliAdapter**, **KiloAdapter**, and **AmpAdapter**. Where the text says "Claude adapter", read it as the reference implementation; every other adapter follows the same contract surface. + ## Why this plan was rewritten The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: @@ -113,6 +115,14 @@ Baseline adapter options to support from day one: 10. `hooks` 11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) +### 2.1.b Credential management and resource limits + +Each provider manages its own authentication externally: + +1. **Environment variables and CLI auth** -- Credentials are resolved via provider-native mechanisms (e.g. `ANTHROPIC_API_KEY` for Claude, `OPENAI_API_KEY` for Codex, `gh auth` for Copilot). The adapter layer never stores or brokers credentials directly; it relies on the underlying CLI/SDK picking them up from the environment. +2. **Per-provider rate limiting** -- Each server manager (`codexAppServerManager`, `claudeCodeServerManager`, etc.) is responsible for honoring its provider's rate limits. Adapters should surface rate-limit errors as `ProviderAdapterProcessError` so orchestration can report them cleanly. +3. **Concurrent session limits** -- The number of simultaneous provider sessions is bounded by system resources (open processes, file descriptors, memory). `ProviderSessionDirectory` tracks active sessions but does not enforce hard caps; operators should monitor resource usage when running multiple providers concurrently. + ### 2.2 Claude runtime bridge Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. @@ -416,6 +426,15 @@ Add/extend integration tests around: These should validate real orchestration flows, not just adapter behavior. +### 6.5 Multi-provider test scenarios + +Cover cross-provider interactions that single-adapter tests miss: + +1. **Provider switching mid-conversation** -- Start a thread on Codex, then switch to Claude (or any other provider) for the next turn. Verify the old session is stopped, bindings are updated, and the new adapter receives the correct `providerOptions`. +2. **Concurrent active sessions** -- Run sessions on two or more different providers simultaneously. Verify events from each session are routed to the correct orchestration thread without cross-contamination. +3. **Resume cursor isolation** -- Persist resume cursors from two different providers, then attempt to resume each. Confirm that one provider's cursor cannot accidentally be used to resume another provider's session (adapter parse should reject mismatched cursors). +4. **Provider health monitoring** -- Simulate a provider becoming unavailable (process crash, binary missing). Verify `listProviderStatuses()` reflects the degraded state and that orchestration surfaces a clear error to the client rather than hanging. + --- ## Phase 7: Rollout order diff --git a/README.md b/README.md index 757b4949df..9fccf5e9ce 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ - # T3 Code +# T3 Code - T3 Code is a minimal web GUI for coding agents. It supports Codex, Claude Code, Cursor, Copilot, Gemini CLI, Amp, and OpenCode. +T3 Code is a minimal web GUI for coding agents. It supports Codex, Claude Code, Cursor, Copilot, Gemini CLI, Amp, Kilo, and OpenCode. - ## Getting started +## Getting started - ### CLI +### CLI > [!WARNING] > You need at least one supported coding agent installed and authorized. See the supported agents list below. @@ -13,11 +13,11 @@ npx t3 ``` - ### Desktop app +### Desktop app You can also just install the desktop app. It's cooler. Install it from the [Releases page](https://github.com/pingdotgg/t3code/releases). - ## Supported agents +## Supported agents - [Codex CLI](https://github.com/openai/codex) (requires v0.37.0 or later) - [Claude Code](https://github.com/anthropics/claude-code) @@ -25,14 +25,15 @@ - [Copilot](https://github.com/features/copilot) - [Gemini CLI](https://github.com/google-gemini/gemini-cli) - [Amp](https://ampcode.com) + - [Kilo](https://kilo.dev) - [OpenCode](https://opencode.ai) - ## Notes +## Notes - This project is very early in development. Expect bugs. - We are not accepting contributions yet. - Maintaining a custom fork or alpha branch? See [docs/custom-alpha-workflow.md](docs/custom-alpha-workflow.md). - ## Need help? +## Need help? Join the [Discord](https://discord.gg/jn4EGJjrvv) if you need support. diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index c9a9ebdaeb..087e758ee4 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -74,6 +74,8 @@ interface AmpSession { runtimeMode: string; status: "ready" | "running" | "closed"; activeTurnId: TurnId | undefined; + /** Stable itemId reused across content.delta events within a single assistant message. */ + activeAssistantItemId: RuntimeItemId | undefined; /** Maps parent_tool_use_id → RuntimeTaskId for tracking subagent tasks. */ readonly subagentTasks: Map; readonly createdAt: string; @@ -207,6 +209,7 @@ export class AmpServerManager extends EventEmitter<{ runtimeMode: input.runtimeMode, status: "ready", activeTurnId: undefined, + activeAssistantItemId: undefined, subagentTasks: new Map(), createdAt: now, updatedAt: now, @@ -539,6 +542,7 @@ export class AmpServerManager extends EventEmitter<{ }); session.status = "ready"; session.activeTurnId = undefined; + session.activeAssistantItemId = undefined; session.updatedAt = new Date().toISOString(); } } @@ -549,27 +553,47 @@ export class AmpServerManager extends EventEmitter<{ block: AmpContentBlock, ): void { switch (block.type) { - case "text": - this.emitEvent(threadId, session.activeTurnId, { - type: "content.delta", - payload: { - streamKind: "assistant_text", - delta: block.text, + case "text": { + if (!session.activeAssistantItemId) { + session.activeAssistantItemId = RuntimeItemId.makeUnsafe(randomUUID()); + } + this.emitEvent( + threadId, + session.activeTurnId, + { + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: block.text, + }, }, - }); + session.activeAssistantItemId, + ); break; + } - case "thinking": - this.emitEvent(threadId, session.activeTurnId, { - type: "content.delta", - payload: { - streamKind: "reasoning_text", - delta: block.thinking, + case "thinking": { + if (!session.activeAssistantItemId) { + session.activeAssistantItemId = RuntimeItemId.makeUnsafe(randomUUID()); + } + this.emitEvent( + threadId, + session.activeTurnId, + { + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: block.thinking, + }, }, - }); + session.activeAssistantItemId, + ); break; + } case "tool_use": { + // A tool use starts a new assistant message segment — clear the active item. + session.activeAssistantItemId = undefined; const itemType = classifyToolName(block.name); const itemId = RuntimeItemId.makeUnsafe(block.id); this.emitEvent( @@ -714,6 +738,7 @@ export class AmpServerManager extends EventEmitter<{ session.status = "ready"; session.activeTurnId = undefined; + session.activeAssistantItemId = undefined; session.updatedAt = new Date().toISOString(); } diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index 1b5a69b000..e10a8bda98 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -94,6 +94,9 @@ describe("GeminiCliServerManager", () => { } }); + // TODO: Strengthen this test by mocking child_process.spawn and asserting it + // is NOT called during startSession. Currently we only verify session state, + // which doesn't prove that no process was spawned. it("does not spawn a process on startSession (lazy per-turn spawning)", async () => { const manager = new GeminiCliServerManager(); try { @@ -559,56 +562,59 @@ const hasGemini = await (async () => { } })(); -describe.skipIf(!hasGemini)("GeminiCliServerManager live integration", () => { - it( - "sends a prompt and receives streaming events ending with turn.completed", - async () => { - const manager = new GeminiCliServerManager(); - const events: ProviderRuntimeEvent[] = []; - manager.on("event", (event) => events.push(event)); - - try { - await manager.startSession({ - threadId: asThreadId("live-thread"), - provider: "geminiCli", - runtimeMode: "full-access", - model: "gemini-2.5-flash", - }); - - const result = await manager.sendTurn({ - threadId: asThreadId("live-thread"), - input: "Reply with exactly the word PONG", - }); - - expect(result.threadId).toBe("live-thread"); - expect(result.turnId).toBeTruthy(); - - // Wait for the turn to complete. - await vi.waitFor( - () => { - const completed = events.find((e) => e.type === "turn.completed"); - expect(completed).toBeDefined(); - }, - { timeout: 30_000, interval: 500 }, - ); - - // Should have received content deltas. - const deltas = events.filter((e) => e.type === "content.delta"); - expect(deltas.length).toBeGreaterThan(0); - - // The text should contain "PONG" somewhere. - const fullText = deltas - .map((e) => (e.payload as { delta: string }).delta) - .join(""); - expect(fullText.toLowerCase()).toContain("pong"); - - // Turn should be completed successfully. - const completed = events.find((e) => e.type === "turn.completed"); - expect((completed?.payload as { state: string }).state).toBe("completed"); - } finally { - manager.stopAll(); - } - }, - 60_000, - ); -}); +describe.skipIf(!hasGemini || process.env.RUN_GEMINI_LIVE_TESTS !== "1")( + "GeminiCliServerManager live integration", + () => { + it( + "sends a prompt and receives streaming events ending with turn.completed", + async () => { + const manager = new GeminiCliServerManager(); + const events: ProviderRuntimeEvent[] = []; + manager.on("event", (event) => events.push(event)); + + try { + await manager.startSession({ + threadId: asThreadId("live-thread"), + provider: "geminiCli", + runtimeMode: "full-access", + model: "gemini-2.5-flash", + }); + + const result = await manager.sendTurn({ + threadId: asThreadId("live-thread"), + input: "Reply with exactly the word PONG", + }); + + expect(result.threadId).toBe("live-thread"); + expect(result.turnId).toBeTruthy(); + + // Wait for the turn to complete. + await vi.waitFor( + () => { + const completed = events.find((e) => e.type === "turn.completed"); + expect(completed).toBeDefined(); + }, + { timeout: 30_000, interval: 500 }, + ); + + // Should have received content deltas. + const deltas = events.filter((e) => e.type === "content.delta"); + expect(deltas.length).toBeGreaterThan(0); + + // The text should contain "PONG" somewhere. + const fullText = deltas + .map((e) => (e.payload as { delta: string }).delta) + .join(""); + expect(fullText.toLowerCase()).toContain("pong"); + + // Turn should be completed successfully. + const completed = events.find((e) => e.type === "turn.completed"); + expect((completed?.payload as { state: string }).state).toBe("completed"); + } finally { + manager.stopAll(); + } + }, + 60_000, + ); + }, +); diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 55e35445ab..5451fe5703 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -388,7 +388,7 @@ export class GeminiCliServerManager extends EventEmitter<{ sessions.push({ provider: PROVIDER, status: session.status === "closed" ? "closed" : "ready", - runtimeMode: "full-access", + runtimeMode: session.runtimeMode as ProviderSession["runtimeMode"], threadId: session.threadId, cwd: session.cwd, model: session.model, diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 82c611a90c..74cb0b1abd 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -1201,6 +1201,11 @@ export class KiloServerManager extends EventEmitter { const startedBaseUrl = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // Process may already be dead. + } reject( new Error( `Timed out waiting for Kilo server to start after ${SERVER_START_TIMEOUT_MS}ms`, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 308768df38..ec0a5596ab 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -506,60 +506,6 @@ describe("ProviderRuntimeIngestion", () => { ); }); - it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { - const harness = await createHarness(); - const seededAt = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.makeUnsafe("cmd-session-seed-claude-placeholder"), - threadId: ThreadId.makeUnsafe("thread-1"), - session: { - threadId: ThreadId.makeUnsafe("thread-1"), - status: "ready", - providerName: "claudeCode", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: seededAt, - lastError: null, - }, - createdAt: seededAt, - }), - ); - - harness.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-started-claude-placeholder"), - provider: "claudeCode", - createdAt: new Date().toISOString(), - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-claude-placeholder"), - }); - - await waitForThread( - harness.engine, - (thread) => - thread.session?.status === "running" && - thread.session?.activeTurnId === "turn-claude-placeholder", - ); - - harness.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-completed-claude-placeholder"), - provider: "claudeCode", - createdAt: new Date().toISOString(), - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-claude-placeholder"), - status: "completed", - }); - - await waitForThread( - harness.engine, - (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, - ); - }); - it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index ec1e87d172..2e396d7156 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -10,6 +10,7 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Random, Stream } from "effect"; import { + ProviderAdapterRequestError, ProviderAdapterValidationError, } from "../Errors.ts"; import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; @@ -756,7 +757,7 @@ describe("ClaudeCodeAdapterLive", () => { ); }); - it.effect("supports rollbackThread by trimming in-memory turns and preserving earlier turns", () => { + it.effect("rollbackThread returns ProviderAdapterRequestError because Claude Code does not support rewinding", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; @@ -767,68 +768,12 @@ describe("ClaudeCodeAdapterLive", () => { runtimeMode: "full-access", }); - const firstTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "first", - attachments: [], - }); - - const firstCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe( - Stream.runHead, - Effect.forkChild, - ); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-first", - } as unknown as SDKMessage); - - const firstCompleted = yield* Fiber.join(firstCompletedFiber); - assert.equal(firstCompleted._tag, "Some"); - if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { - assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); + const result = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); + assert.equal(result._tag, "ProviderAdapterRequestError"); + if (result._tag === "ProviderAdapterRequestError") { + assert.equal(result.method, "thread.rollback"); + assert.ok(result.detail?.includes("not supported")); } - - const secondTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "second", - attachments: [], - }); - - const secondCompletedFiber = yield* Stream.filter(adapter.streamEvents, (event) => event.type === "turn.completed").pipe( - Stream.runHead, - Effect.forkChild, - ); - - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "sdk-session-rollback", - uuid: "result-second", - } as unknown as SDKMessage); - - const secondCompleted = yield* Fiber.join(secondCompletedFiber); - assert.equal(secondCompleted._tag, "Some"); - if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { - assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); - } - - const threadBeforeRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadBeforeRollback.turns.length, 2); - - const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); - assert.equal(rolledBack.turns.length, 1); - assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); - - const threadAfterRollback = yield* adapter.readThread(session.threadId); - assert.equal(threadAfterRollback.turns.length, 1); - assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 1a9d9067b8..69333efb11 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -383,14 +383,30 @@ function titleForTool(itemType: CanonicalItemType): string { } } -function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { +function buildUserMessage( + input: ProviderSendTurnInput, +): Effect.Effect { const fragments: string[] = []; if (input.input && input.input.trim().length > 0) { fragments.push(input.input.trim()); } - for (const attachment of input.attachments ?? []) { + const attachments = input.attachments ?? []; + const unsupportedAttachments = attachments.filter((a) => a.type !== "image"); + + if (unsupportedAttachments.length > 0) { + const types = [...new Set(unsupportedAttachments.map((a) => a.type))].join(", "); + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Unsupported attachment type(s): ${types}. Claude Code only supports image attachments as descriptive text.`, + }), + ); + } + + for (const attachment of attachments) { if (attachment.type === "image") { fragments.push( `Attached image: ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes).`, @@ -400,7 +416,18 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { const text = fragments.join("\n\n"); - return { + if (text.length === 0) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: + "Cannot send an empty turn: no text input and all attachments were reduced to empty content.", + }), + ); + } + + return Effect.succeed({ type: "user", session_id: "", parent_tool_use_id: null, @@ -408,7 +435,7 @@ function buildUserMessage(input: ProviderSendTurnInput): SDKUserMessage { role: "user", content: [{ type: "text", text }], }, - } as SDKUserMessage; + } as SDKUserMessage); } function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { @@ -1510,6 +1537,12 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = resumeState?.threadId ?? input.threadId; + // Guard against duplicate threadId: stop/cleanup any existing session before creating a new one. + const existingContext = sessions.get(threadId); + if (existingContext) { + yield* stopSessionInternal(existingContext, { emitExitEvent: true }); + } + const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( Stream.filter((item) => item.type === "message"), @@ -1832,7 +1865,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }, }); - const message = buildUserMessage(input); + const message = yield* buildUserMessage(input); yield* Queue.offer(context.promptQueue, { type: "message", @@ -1863,13 +1896,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return yield* snapshotThread(context); }); - const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, numTurns) => + const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (threadId, _numTurns) => Effect.gen(function* () { - const context = yield* requireSession(threadId); - const nextLength = Math.max(0, context.turns.length - numTurns); - context.turns.splice(nextLength); - yield* updateResumeCursor(context); - return yield* snapshotThread(context); + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread.rollback", + detail: + "Claude Code rollback is not supported without rewinding the underlying provider session.", + }); }); const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = ( diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 19ed9eb230..50673f2b1b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1075,6 +1075,14 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => record.toolTitlesByCallId.delete(event.data.toolCallId); } if (event.type === "abort" || event.type === "session.idle") { + // If the turn terminates before assistant.turn_start consumed the + // pending ID, remove the stale entry so it never leaks into a future + // turn. + if (record.currentTurnId) { + record.pendingTurnIds = record.pendingTurnIds.filter( + (id) => id !== record.currentTurnId, + ); + } record.currentTurnId = undefined; record.currentProviderTurnId = undefined; } @@ -1088,7 +1096,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => return Effect.succeed(record); }; - const stopRecord = async (record: ActiveCopilotSession) => { + const stopRecord = async ( + record: ActiveCopilotSession, + options?: { readonly emitExitEvent?: boolean }, + ) => { record.unsubscribe(); try { await record.session.destroy(); @@ -1100,12 +1111,51 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } catch { // best effort } - for (const pending of record.pendingApprovalResolvers.values()) { + + const teardownEvents: ProviderRuntimeEvent[] = []; + + for (const [requestId, pending] of record.pendingApprovalResolvers) { pending.resolve({ kind: "denied-interactively-by-user" }); + teardownEvents.push( + makeSyntheticEvent( + record.threadId, + "request.resolved", + { + requestType: pending.requestType, + decision: "cancel", + resolution: { kind: "denied-interactively-by-user" }, + }, + { requestId, turnId: pending.turnId }, + ), + ); } - for (const pending of record.pendingUserInputResolvers.values()) { + record.pendingApprovalResolvers.clear(); + + for (const [requestId, pending] of record.pendingUserInputResolvers) { pending.resolve({ answer: "", wasFreeform: true }); + teardownEvents.push( + makeSyntheticEvent( + record.threadId, + "user-input.resolved", + { answers: {} }, + { requestId, turnId: pending.turnId }, + ), + ); + } + record.pendingUserInputResolvers.clear(); + + if (options?.emitExitEvent !== false) { + teardownEvents.push( + makeSyntheticEvent(record.threadId, "session.exited", { + reason: "stopped", + }), + ); + } + + if (teardownEvents.length > 0) { + await emitRuntimeEvents(teardownEvents); } + sessions.delete(record.threadId); }; @@ -1263,25 +1313,28 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => : input.model && input.model !== record.model ? undefined : record.reasoningEffort; - const attachments = (input.attachments ?? []) - .map((attachment) => { - const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, - attachment, - }); - if (!attachmentPath) { - throw new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.send", - detail: `Invalid attachment id '${attachment.id}'.`, + const attachments = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment, }); - } - return { - type: "file" as const, - path: attachmentPath, - displayName: attachment.name, - }; - }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + return { + type: "file" as const, + path: attachmentPath, + displayName: attachment.name, + }; + }), + ); yield* validateSessionConfiguration({ client: record.client, @@ -1418,7 +1471,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const record = yield* getSessionRecord(threadId); yield* Effect.tryPromise({ try: async () => { - await stopRecord(record); + await stopRecord(record, { emitExitEvent: true }); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1493,7 +1546,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const stopAll: CopilotAdapterShape["stopAll"] = () => Effect.tryPromise({ try: async () => { - await Promise.all(Array.from(sessions.values()).map((record) => stopRecord(record))); + await Promise.all( + Array.from(sessions.values()).map((record) => stopRecord(record, { emitExitEvent: true })), + ); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1504,6 +1559,15 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }), }); + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, record]) => + Effect.promise(() => stopRecord(record, { emitExitEvent: false }).catch(() => undefined)), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + return { provider: PROVIDER, capabilities: { diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index d724102ecc..7c6fc711aa 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -7,6 +7,7 @@ * * @module ProviderAdapterRegistryLive */ +import type { ProviderKind } from "@t3tools/contracts"; import { Effect, Layer } from "effect"; import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; @@ -43,7 +44,15 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption yield* AmpAdapter, yield* KiloAdapter, ]; - const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); + const byProvider = new Map>(); + for (const adapter of adapters) { + if (byProvider.has(adapter.provider)) { + throw new Error( + `Duplicate provider adapter registration for provider "${adapter.provider}"`, + ); + } + byProvider.set(adapter.provider, adapter); + } const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { const adapter = byProvider.get(provider); diff --git a/apps/server/src/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts index b35a308450..68bb69015e 100644 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ b/apps/server/src/provider/claude-agent-sdk.d.ts @@ -102,14 +102,16 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly additionalDirectories?: ReadonlyArray; } - export function query(input: { - readonly prompt: AsyncIterable; - readonly options: Options; - }): AsyncIterable & { + export type Query = AsyncIterable & { readonly interrupt?: () => Promise; readonly setModel?: (model?: string) => Promise; readonly setPermissionMode?: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens?: (maxThinkingTokens: number | null) => Promise; readonly close?: () => void; }; + + export function query(input: { + readonly prompt: string | AsyncIterable; + readonly options?: Options; + }): Query; } diff --git a/apps/web/src/accentColor.ts b/apps/web/src/accentColor.ts index 0845defbf7..516e65eaf5 100644 --- a/apps/web/src/accentColor.ts +++ b/apps/web/src/accentColor.ts @@ -26,6 +26,15 @@ export function normalizeAccentColor(value: string | null | undefined): string { return `#${hexValue}`; } +/** + * Returns true when `value` is a syntactically valid hex accent color + * (3- or 6-digit hex with leading `#`). Unlike `normalizeAccentColor`, + * this does not substitute a default for invalid values. + */ +export function isValidAccentColor(value: string | null | undefined): boolean { + return HEX_COLOR_PATTERN.test(value?.trim() ?? ""); +} + function hexToRgb(color: string): { r: number; g: number; b: number } { const normalized = normalizeAccentColor(color); return { @@ -35,6 +44,14 @@ function hexToRgb(color: string): { r: number; g: number; b: number } { }; } +function clampChannel(v: number): number { + return Math.min(255, Math.max(0, Math.round(v))); +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${clampChannel(r).toString(16).padStart(2, "0")}${clampChannel(g).toString(16).padStart(2, "0")}${clampChannel(b).toString(16).padStart(2, "0")}`; +} + function toLinearChannel(channel: number): number { const normalized = channel / 255; if (normalized <= 0.04045) { @@ -52,7 +69,7 @@ function relativeLuminance(color: string): number { ); } -function contrastRatio(a: string, b: string): number { +export function contrastRatio(a: string, b: string): number { const lumA = relativeLuminance(a); const lumB = relativeLuminance(b); const lighter = Math.max(lumA, lumB); @@ -60,10 +77,22 @@ function contrastRatio(a: string, b: string): number { return (lighter + 0.05) / (darker + 0.05); } -export function resolveAccentForegroundColor(color: string): "#ffffff" | "#111827" { +/** WCAG AA minimum contrast ratio for normal text. */ +const WCAG_AA_CONTRAST_THRESHOLD = 4.5; + +/** + * Returns the foreground color that provides the best contrast against the + * given accent color, or `null` if neither white nor dark text can achieve + * the WCAG AA contrast threshold (4.5:1). + */ +export function resolveAccentForegroundColor(color: string): "#ffffff" | "#111827" | null { const normalized = normalizeAccentColor(color); const whiteContrast = contrastRatio(normalized, "#ffffff"); const darkContrast = contrastRatio(normalized, "#111827"); + const bestContrast = Math.max(whiteContrast, darkContrast); + if (bestContrast < WCAG_AA_CONTRAST_THRESHOLD) { + return null; + } return darkContrast > whiteContrast ? "#111827" : "#ffffff"; } @@ -73,6 +102,51 @@ export function resolveAccentColorRgba(color: string, alpha: number): string { return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${safeAlpha})`; } +/** Minimum contrast ratio for terminal text against its background. */ +const TERMINAL_MIN_CONTRAST = 3.0; + +/** + * Returns a variant of `accent` guaranteed to have at least + * `TERMINAL_MIN_CONTRAST` (3:1) against `background`. If the raw accent + * already passes, it is returned unchanged. Otherwise it is progressively + * mixed toward white (dark backgrounds) or black (light backgrounds) until + * the threshold is met. + */ +export function contrastSafeTerminalColor(accent: string, background: string): string { + const normalized = normalizeAccentColor(accent); + if (contrastRatio(normalized, background) >= TERMINAL_MIN_CONTRAST) { + return normalized; + } + + const bgLum = relativeLuminance(background); + const accentRgb = hexToRgb(normalized); + // Mix toward white for dark backgrounds, toward black for light ones. + const targetR = bgLum < 0.5 ? 255 : 0; + const targetG = bgLum < 0.5 ? 255 : 0; + const targetB = bgLum < 0.5 ? 255 : 0; + + // Binary search for the minimum mix ratio that achieves the threshold. + let lo = 0; + let hi = 1; + for (let i = 0; i < 16; i++) { + const mid = (lo + hi) / 2; + const r = accentRgb.r + (targetR - accentRgb.r) * mid; + const g = accentRgb.g + (targetG - accentRgb.g) * mid; + const b = accentRgb.b + (targetB - accentRgb.b) * mid; + const candidate = rgbToHex(r, g, b); + if (contrastRatio(candidate, background) >= TERMINAL_MIN_CONTRAST) { + hi = mid; + } else { + lo = mid; + } + } + + const r = accentRgb.r + (targetR - accentRgb.r) * hi; + const g = accentRgb.g + (targetG - accentRgb.g) * hi; + const b = accentRgb.b + (targetB - accentRgb.b) * hi; + return rgbToHex(r, g, b); +} + export function applyAccentColorToDocument(color: string): void { if (typeof document === "undefined") { return; @@ -80,7 +154,14 @@ export function applyAccentColorToDocument(color: string): void { const normalized = normalizeAccentColor(color); const foreground = resolveAccentForegroundColor(normalized); + + // If the accent has insufficient contrast with both white and dark text, + // fall back to the default accent color which is known to be safe. + const effectiveColor = foreground !== null ? normalized : DEFAULT_ACCENT_COLOR; + const effectiveForeground = + foreground ?? (resolveAccentForegroundColor(DEFAULT_ACCENT_COLOR) as "#ffffff" | "#111827"); + const rootStyle = document.documentElement.style; - rootStyle.setProperty("--accent-color", normalized); - rootStyle.setProperty("--accent-color-foreground", foreground); + rootStyle.setProperty("--accent-color", effectiveColor); + rootStyle.setProperty("--accent-color-foreground", effectiveForeground); } diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 443cf26ab3..b5d7b5463e 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { DEFAULT_ACCENT_COLOR, normalizeAccentColor } from "./accentColor"; +import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -160,7 +160,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { accentColor: normalizeAccentColor(settings.accentColor), providerAccentColors: Object.fromEntries( Object.entries(settings.providerAccentColors) - .filter(([, v]) => v.trim().length > 0) + .filter(([, v]) => isValidAccentColor(v)) .map(([k, v]) => [k, normalizeAccentColor(v)]), ), }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb750badbe..e16cb7fdc4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -883,9 +883,18 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; - const copilotModelsQuery = useQuery(providerListModelsQueryOptions("copilot")); - const opencodeModelsQuery = useQuery(providerListModelsQueryOptions("opencode")); - const kiloModelsQuery = useQuery(providerListModelsQueryOptions("kilo")); + const copilotModelsQuery = useQuery({ + ...providerListModelsQueryOptions("copilot"), + staleTime: Infinity, + }); + const opencodeModelsQuery = useQuery({ + ...providerListModelsQueryOptions("opencode"), + staleTime: Infinity, + }); + const kiloModelsQuery = useQuery({ + ...providerListModelsQueryOptions("kilo"), + staleTime: Infinity, + }); const modelOptionsByProvider = useMemo( () => mergeDiscoveredModels(getCustomModelOptionsByProvider(settings), { @@ -5339,7 +5348,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ {row.kind === "message" && row.message.role === "assistant" && (() => { - if (!row.message.text && !row.message.streaming) return null; + const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + if (!row.message.text && !row.message.streaming && !turnSummary) return null; const messageText = row.message.text || ""; return ( <> @@ -5359,7 +5369,6 @@ const MessagesTimeline = memo(function MessagesTimeline({ isStreaming={Boolean(row.message.streaming)} /> {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; @@ -5534,7 +5543,7 @@ function getCustomModelOptionsByProvider(settings: { }; } -type ModelOptionEntry = { slug: string; name: string; pricingTier?: string }; +type ModelOptionEntry = { slug: string; name: string; pricingTier?: string; isCustom?: boolean }; function mergeDiscoveredModels( base: Record>, @@ -5558,7 +5567,7 @@ function mergeDiscoveredModels( return tier ? { ...m, pricingTier: tier } : m; }); const customOnly = (base[provider] ?? []).filter( - (m) => !models.some((d) => d.slug === m.slug), + (m) => m.isCustom && !models.some((d) => d.slug === m.slug), ); result[provider] = [...enriched, ...customOnly]; continue; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8999c830aa..e6016e6cd0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -25,7 +25,11 @@ import { MAX_THREAD_TERMINAL_COUNT, type ThreadTerminalGroup, } from "../types"; -import { normalizeAccentColor, resolveAccentColorRgba } from "../accentColor"; +import { + contrastSafeTerminalColor, + normalizeAccentColor, + resolveAccentColorRgba, +} from "../accentColor"; import { readNativeApi } from "~/nativeApi"; const MIN_DRAWER_HEIGHT = 180; @@ -46,6 +50,25 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } +/** Fallback hex backgrounds used when the computed style cannot be parsed. */ +const DARK_BG_HEX = "#0e1218"; +const LIGHT_BG_HEX = "#ffffff"; + +function clampByte(v: number): number { + return Math.min(255, Math.max(0, Math.round(v))); +} + +/** Mix a hex color toward white by a given ratio (0 = original, 1 = white). */ +function mixHexWithWhite(hex: string, ratio: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + const mr = clampByte(r + (255 - r) * ratio); + const mg = clampByte(g + (255 - g) * ratio); + const mb = clampByte(b + (255 - b) * ratio); + return `#${mr.toString(16).padStart(2, "0")}${mg.toString(16).padStart(2, "0")}${mb.toString(16).padStart(2, "0")}`; +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -54,10 +77,12 @@ function terminalThemeFromApp(): ITheme { bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); const accentColor = normalizeAccentColor(rootStyles.getPropertyValue("--accent-color")); - const terminalBlue = accentColor; - const terminalBrightBlue = isDark - ? `color-mix(in srgb, ${accentColor} 70%, white)` - : `color-mix(in srgb, ${accentColor} 82%, white)`; + const bgHex = isDark ? DARK_BG_HEX : LIGHT_BG_HEX; + const terminalBlue = contrastSafeTerminalColor(accentColor, bgHex); + // Brighten the accent (mix toward white) then ensure it still has + // sufficient contrast against the terminal background. + const brightMix = isDark ? 0.3 : 0.18; + const terminalBrightBlue = contrastSafeTerminalColor(mixHexWithWhite(accentColor, brightMix), bgHex); const selectionBackground = resolveAccentColorRgba(accentColor, isDark ? 0.3 : 0.22); if (isDark) { diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs index db367dd1ab..940ec72b1a 100644 --- a/scripts/sync-upstream-pr-tracks.mjs +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -25,6 +25,20 @@ function tryRunGit(args) { } } +function deriveRepoUrl(remoteName) { + const remoteUrl = tryRunGit(["remote", "get-url", remoteName]); + if (!remoteUrl) return null; + + // Handle SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) + const sshMatch = remoteUrl.match(/git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`; + + const httpsMatch = remoteUrl.match(/^https?:\/\/(.+?)(?:\.git)?$/); + if (httpsMatch) return `https://${httpsMatch[1]}`; + + return null; +} + function loadConfig() { const raw = fs.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw); @@ -34,6 +48,8 @@ function loadConfig() { if (!Array.isArray(parsed.trackedPrs) || parsed.trackedPrs.length === 0) { throw new Error("No tracked PRs configured."); } + // Derive the repo URL from the upstream remote instead of hardcoding it. + parsed.repoUrl = deriveRepoUrl(parsed.upstreamRemote) ?? deriveRepoUrl(parsed.forkRemote); return parsed; } @@ -94,7 +110,7 @@ function main() { const integrationSummary = getComparisonSummary(integrationBranch, pr.localBranch); formatSection(`PR #${pr.number}: ${pr.title}`); - console.log(`URL: https://github.com/pingdotgg/t3code/pull/${pr.number}`); + console.log(`URL: ${config.repoUrl ?? "https://github.com/pingdotgg/t3code"}/pull/${pr.number}`); console.log(`Tracking branch: ${pr.localBranch}`); console.log(`Branch SHA: ${branchSha}`); console.log(`Merge base with ${baseBranch}: ${baseSummary.mergeBase ?? "(missing)"}`); From d200780619ad0de0b8c8ee38dbdfdf7b2709685c Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 8 Mar 2026 21:07:36 +0530 Subject: [PATCH 18/23] fix: address round-2 PR review feedback ampServerManager: - Emit turn.completed when AMP process dies with active turn - Reject concurrent turns in sendTurn - Consistent tool item types between start/completion events - Sanitize raw provider output in JSONL parse logging - Guard against duplicate turn.completed emissions geminiCliServerManager: - Reject overlapping turns in sendTurn - Always emit terminal turn.completed on child close - Respect input.model override, reject unsupported attachments - Fix test to simulate closed-but-present session path - Add tests for concurrent turn rejection and attachment rejection kiloServerManager: - Reject unsupported attachments in sendTurn - Clean up listeners on server start promise resolve/reject - Remove double-cast by using ProviderRuntimeEvent directly - Remove no-op readJsonData pass-through function CopilotAdapter: - Reorder reconfigureSession to create new before destroying old - Guard against NaN percentUsed when limit is 0 - Clean up client on createSession/resumeSession failure - Update stale runtimeMode on session reuse early return Types and registry: - Replace any with unknown in SDKMessage for stricter typing - Use Effect.die for idiomatic duplicate registration error - Add comment explaining global usage accumulator intent - Fix test to use different thread for cross-thread verification Other: - Merge discovered models by slug preserving metadata in ChatView - Fix deriveRepoUrl .git suffix handling - Expand plan docs: credential security, rollback criteria, chaos testing --- .plans/17-claude-code.md | 14 ++ apps/server/src/ampServerManager.ts | 49 ++++- .../server/src/geminiCliServerManager.test.ts | 57 +++++- apps/server/src/geminiCliServerManager.ts | 36 ++-- apps/server/src/kiloServerManager.ts | 180 ++++++++---------- .../Layers/ProviderRuntimeIngestion.test.ts | 24 ++- .../src/provider/Layers/ClaudeCodeAdapter.ts | 22 ++- .../src/provider/Layers/CopilotAdapter.ts | 37 ++-- .../Layers/ProviderAdapterRegistry.ts | 6 +- .../server/src/provider/claude-agent-sdk.d.ts | 8 +- apps/web/src/components/ChatView.tsx | 19 +- scripts/sync-upstream-pr-tracks.mjs | 11 +- 12 files changed, 313 insertions(+), 150 deletions(-) diff --git a/.plans/17-claude-code.md b/.plans/17-claude-code.md index d8e1d15201..822dbd806b 100644 --- a/.plans/17-claude-code.md +++ b/.plans/17-claude-code.md @@ -122,6 +122,9 @@ Each provider manages its own authentication externally: 1. **Environment variables and CLI auth** -- Credentials are resolved via provider-native mechanisms (e.g. `ANTHROPIC_API_KEY` for Claude, `OPENAI_API_KEY` for Codex, `gh auth` for Copilot). The adapter layer never stores or brokers credentials directly; it relies on the underlying CLI/SDK picking them up from the environment. 2. **Per-provider rate limiting** -- Each server manager (`codexAppServerManager`, `claudeCodeServerManager`, etc.) is responsible for honoring its provider's rate limits. Adapters should surface rate-limit errors as `ProviderAdapterProcessError` so orchestration can report them cleanly. 3. **Concurrent session limits** -- The number of simultaneous provider sessions is bounded by system resources (open processes, file descriptors, memory). `ProviderSessionDirectory` tracks active sessions but does not enforce hard caps; operators should monitor resource usage when running multiple providers concurrently. +4. **Credential leakage prevention** -- Error messages, logs, and serialized `ProviderAdapterProcessError` payloads must never include raw API keys or tokens. Adapters should redact secrets before surfacing diagnostics. +5. **Secure environment propagation** -- When spawning child processes (CLI binaries, SDK sub-processes), pass an explicit environment whitelist rather than forwarding the entire `process.env`. This limits accidental exposure of unrelated secrets to the child. +6. **Secret rotation** -- Rotating a provider API key or token requires restarting all active sessions for that provider. Document this operational requirement; there is no hot-reload path for credentials. ### 2.2 Claude runtime bridge @@ -341,6 +344,14 @@ Whichever option is chosen: 2. checkpoint revert tests must pass under orchestration expectations 3. user-visible activity log should explain failures clearly when provider rollback is impossible +### Decision criteria + +Choose the rollback strategy as follows: + +1. If the Agent SDK exposes a native rewind/rollback API that can truncate conversation history to an arbitrary checkpoint, use **Option A** (provider-native rewind). This gives the cleanest UX and avoids session restart overhead. +2. If no native rewind API exists or it cannot target the exact checkpoint boundary orchestration requires, use **Option B** (session restart + state truncation shim). +3. **Time-box rule:** if investigation into Option A takes longer than 2 working days without a reliable prototype, default to Option B and move on. Option A can be revisited as a follow-up enhancement once the base integration is stable. + --- ## Phase 5: Web integration @@ -434,6 +445,9 @@ Cover cross-provider interactions that single-adapter tests miss: 2. **Concurrent active sessions** -- Run sessions on two or more different providers simultaneously. Verify events from each session are routed to the correct orchestration thread without cross-contamination. 3. **Resume cursor isolation** -- Persist resume cursors from two different providers, then attempt to resume each. Confirm that one provider's cursor cannot accidentally be used to resume another provider's session (adapter parse should reject mismatched cursors). 4. **Provider health monitoring** -- Simulate a provider becoming unavailable (process crash, binary missing). Verify `listProviderStatuses()` reflects the degraded state and that orchestration surfaces a clear error to the client rather than hanging. +5. **Performance under load** -- Run 10+ concurrent provider sessions across mixed adapters. Monitor memory usage, open file descriptors, and event-delivery latency to ensure the server remains responsive and does not leak resources. +6. **Chaos scenarios** -- Forcibly kill provider child processes and inject network timeouts mid-stream. Verify that orchestration detects the failure, emits a clear `runtime.error`, and cleans up session resources without leaving zombie processes. +7. **Resume after ungraceful shutdown** -- Terminate the server (SIGKILL) while sessions are active, then restart. Validate that persisted resume cursors allow sessions to recover and that no corrupted state prevents new sessions from starting. --- diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 087e758ee4..24d1b5b576 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -20,6 +20,7 @@ import { } from "@t3tools/contracts"; import type { ProviderSessionUsage, ProviderUsageResult } from "@t3tools/contracts"; import type { ProviderThreadSnapshot } from "./provider/Services/ProviderAdapter.ts"; +import { createLogger } from "./logger.ts"; // ── Constants ─────────────────────────────────────────────────────── @@ -78,6 +79,8 @@ interface AmpSession { activeAssistantItemId: RuntimeItemId | undefined; /** Maps parent_tool_use_id → RuntimeTaskId for tracking subagent tasks. */ readonly subagentTasks: Map; + /** Maps tool_use_id → classified item type for consistent start/completion typing. */ + readonly toolItemTypes: Map>; readonly createdAt: string; updatedAt: string; } @@ -167,6 +170,7 @@ export class AmpServerManager extends EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); + private readonly logger = createLogger("amp"); // ── Session lifecycle ─────────────────────────────────────────── @@ -211,6 +215,7 @@ export class AmpServerManager extends EventEmitter<{ activeTurnId: undefined, activeAssistantItemId: undefined, subagentTasks: new Map(), + toolItemTypes: new Map(), createdAt: now, updatedAt: now, }; @@ -237,6 +242,17 @@ export class AmpServerManager extends EventEmitter<{ child.on("close", (code) => { const s = this.sessions.get(threadId); if (s) { + if (s.activeTurnId) { + this.emitEvent(threadId, s.activeTurnId, { + type: "turn.completed", + payload: { + state: "failed", + errorMessage: `AMP process exited with code ${code}`, + }, + }); + s.activeTurnId = undefined; + s.activeAssistantItemId = undefined; + } s.status = "closed"; s.updatedAt = new Date().toISOString(); this.emitEvent(threadId, s.activeTurnId, { @@ -252,10 +268,21 @@ export class AmpServerManager extends EventEmitter<{ child.on("error", (error) => { const s = this.sessions.get(threadId); if (s) { + if (s.activeTurnId) { + this.emitEvent(threadId, s.activeTurnId, { + type: "turn.completed", + payload: { + state: "failed", + errorMessage: `AMP process error: ${error.message}`, + }, + }); + s.activeTurnId = undefined; + s.activeAssistantItemId = undefined; + } s.status = "closed"; s.updatedAt = new Date().toISOString(); } - this.emitEvent(threadId, session.activeTurnId, { + this.emitEvent(threadId, s?.activeTurnId, { type: "runtime.error", payload: { message: error.message, class: "transport_error" }, }); @@ -285,6 +312,11 @@ export class AmpServerManager extends EventEmitter<{ if (session.status === "closed") { throw new Error(`AMP session is closed: ${input.threadId}`); } + if (session.status === "running" || session.activeTurnId) { + throw new Error( + `AMP session ${input.threadId} already has a turn in progress (turn ${session.activeTurnId})`, + ); + } const turnId = TurnId.makeUnsafe(randomUUID()); session.activeTurnId = turnId; @@ -419,7 +451,7 @@ export class AmpServerManager extends EventEmitter<{ msg = JSON.parse(trimmed) as AmpJsonlMessage; } catch { // Non-JSON output — treat as raw assistant text. - console.warn(`[amp] Failed to parse JSONL line, treating as text: ${trimmed.slice(0, 120)}`); + this.logger.warn("Failed to parse JSONL line", { length: trimmed.length }); this.emitEvent(threadId, session.activeTurnId, { type: "content.delta", payload: { @@ -533,7 +565,8 @@ export class AmpServerManager extends EventEmitter<{ } // For persistent sessions, a turn completes when stop_reason is "end_turn". - if (inner?.stop_reason === "end_turn") { + // Guard against duplicate turn.completed (handleResultMessage may also emit one). + if (inner?.stop_reason === "end_turn" && session.activeTurnId && session.status !== "ready") { _ampUsageAccumulator.turnCount++; this.closeAllSubagentTasks(threadId, session); this.emitEvent(threadId, session.activeTurnId, { @@ -595,6 +628,7 @@ export class AmpServerManager extends EventEmitter<{ // A tool use starts a new assistant message segment — clear the active item. session.activeAssistantItemId = undefined; const itemType = classifyToolName(block.name); + session.toolItemTypes.set(block.id, itemType); const itemId = RuntimeItemId.makeUnsafe(block.id); this.emitEvent( threadId, @@ -680,13 +714,16 @@ export class AmpServerManager extends EventEmitter<{ if (block.type === "tool_result") { const resultBlock = block as AmpToolResultContentBlock; const itemId = RuntimeItemId.makeUnsafe(resultBlock.tool_use_id); + const itemType = + session.toolItemTypes.get(resultBlock.tool_use_id) ?? "dynamic_tool_call"; + session.toolItemTypes.delete(resultBlock.tool_use_id); this.emitEvent( threadId, session.activeTurnId, { type: "item.completed", payload: { - itemType: "dynamic_tool_call", + itemType, status: resultBlock.is_error ? "failed" : "completed", data: resultBlock.content, }, @@ -705,6 +742,10 @@ export class AmpServerManager extends EventEmitter<{ session: AmpSession, msg: AmpJsonlMessage, ): void { + // Guard: only complete the turn if one is still active (handleAssistantMessage + // may have already completed it via stop_reason === "end_turn"). + if (!session.activeTurnId || session.status === "ready") return; + // Close all open subagent tasks before completing the turn. this.closeAllSubagentTasks(threadId, session); diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index e10a8bda98..d9e727d8e5 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -133,14 +133,67 @@ describe("GeminiCliServerManager", () => { provider: "geminiCli", runtimeMode: "full-access", }); - manager.stopSession(asThreadId("thread-1")); + + // Directly mark the session as closed without removing it from the map, + // so we exercise the "closed session" branch (not the "unknown session" branch). + const sessions = ( + manager as unknown as { sessions: Map } + ).sessions; + const session = sessions.get("thread-1"); + expect(session).toBeDefined(); + session!.status = "closed"; expect(() => manager.sendTurn({ threadId: asThreadId("thread-1"), input: "hello", }), - ).toThrow("Unknown Gemini CLI session"); + ).toThrow("Gemini CLI session is closed"); + }); + + it("rejects when session is already running", async () => { + const manager = new GeminiCliServerManager(); + await manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "geminiCli", + runtimeMode: "full-access", + }); + + // Mark the session as running to simulate an in-progress turn. + const sessions = ( + manager as unknown as { sessions: Map } + ).sessions; + const session = sessions.get("thread-1"); + expect(session).toBeDefined(); + session!.status = "running"; + + expect(() => + manager.sendTurn({ + threadId: asThreadId("thread-1"), + input: "hello", + }), + ).toThrow("Gemini CLI session already running"); + }); + + it("rejects when attachments are provided", async () => { + const manager = new GeminiCliServerManager(); + try { + await manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "geminiCli", + runtimeMode: "full-access", + }); + + expect(() => + manager.sendTurn({ + threadId: asThreadId("thread-1"), + input: "hello", + attachments: [{ type: "image", url: "https://example.com/img.png" }] as never, + }), + ).toThrow("does not support attachments"); + } finally { + manager.stopAll(); + } }); }); diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 5451fe5703..897ea426cd 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -239,6 +239,14 @@ export class GeminiCliServerManager extends EventEmitter<{ if (session.status === "closed") { throw new Error(`Gemini CLI session is closed: ${input.threadId}`); } + if (session.status === "running") { + throw new Error(`Gemini CLI session already running: ${input.threadId}`); + } + + // Reject attachments — Gemini CLI doesn't support them. + if (input.attachments && input.attachments.length > 0) { + throw new Error("Gemini CLI does not support attachments"); + } const turnId = TurnId.makeUnsafe(randomUUID()); session.activeTurnId = turnId; @@ -249,6 +257,9 @@ export class GeminiCliServerManager extends EventEmitter<{ const prompt = input.input ?? ""; + // Use per-turn model override if provided, otherwise fall back to session model. + const effectiveModel = input.model ?? session.model; + // Build args for headless mode with stream-json output. const args: string[] = [ "-p", @@ -259,8 +270,8 @@ export class GeminiCliServerManager extends EventEmitter<{ resolveApprovalMode(session.runtimeMode), ]; - if (session.model) { - args.push("-m", session.model); + if (effectiveModel) { + args.push("-m", effectiveModel); } // Resume previous Gemini session for follow-up turns. @@ -296,20 +307,21 @@ export class GeminiCliServerManager extends EventEmitter<{ s.activeProcess = undefined; - // If the turn wasn't already completed by a "result" event, mark it. + // If the turn wasn't already completed by a "result" event, emit a terminal turn.completed. if (s.status === "running" && s.activeTurnId === turnId) { s.status = "ready"; s.updatedAt = new Date().toISOString(); - if (code !== 0) { - this.emitEvent(input.threadId, turnId, { - type: "turn.completed", - payload: { - state: "failed", - errorMessage: `Gemini CLI exited with code ${code}`, - }, - }); - } + this.emitEvent(input.threadId, turnId, { + type: "turn.completed", + payload: + code === 0 + ? { state: "completed" } + : { + state: "failed", + errorMessage: `Gemini CLI exited with code ${code}`, + }, + }); } }); diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 74cb0b1abd..6d79a2b602 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -56,21 +56,6 @@ type KiloSendTurnInput = ProviderSendTurnInput & { }; }; -type KiloRuntimeRawSource = - | "kilo.server.event" - | "kilo.server.permission" - | "kilo.server.question"; - -type KiloProviderRuntimeEvent = Omit & { - readonly provider: ProviderRuntimeEvent["provider"] | "kilo"; - readonly raw?: { - readonly source: KiloRuntimeRawSource; - readonly method?: string; - readonly messageType?: string; - readonly payload: unknown; - }; -}; - type KiloProviderSession = Omit & { readonly provider: ProviderSession["provider"] | "kilo"; }; @@ -698,10 +683,6 @@ function toToolLifecycleEventType( return previous?.kind === "tool" ? "item.updated" : "item.started"; } -async function readJsonData(promise: Promise): Promise { - return promise; -} - function readProviderListResponse( value: | ProviderListResponse @@ -777,22 +758,20 @@ export class KiloServerManager extends EventEmitter { const resumedSessionId = readResumeSessionId(kiloInput.resumeCursor); const resumedSession = resumedSessionId - ? await readJsonData( - client.session.get({ + ? await client.session + .get({ sessionID: resumedSessionId, ...(workspace ? { workspace } : {}), - }), - ).catch(() => undefined) + }) + .catch(() => undefined) : undefined; const createdSession = resumedSession ?? - (await readJsonData( - client.session.create({ - ...(workspace ? { workspace } : {}), - title: `T3 thread ${input.threadId}`, - }), - )); + (await client.session.create({ + ...(workspace ? { workspace } : {}), + title: `T3 thread ${input.threadId}`, + })); const createdAt = nowIso(); const providerSessionId = asString(asRecord(createdSession)?.id); @@ -875,6 +854,9 @@ export class KiloServerManager extends EventEmitter { } async sendTurn(input: ProviderSendTurnInput): Promise { + if (input.attachments && input.attachments.length > 0) { + throw new Error("Attachments are not supported by Kilo"); + } const kiloInput = input as KiloSendTurnInput; const context = this.requireSession(input.threadId); const turnId = createTurnId(); @@ -924,23 +906,21 @@ export class KiloServerManager extends EventEmitter { }); try { - await readJsonData( - context.client.session.promptAsync({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - ...(providerId && modelId - ? { - model: { - providerID: providerId, - modelID: modelId, - }, - } - : {}), - ...(agent ? { agent } : {}), - ...(variant ? { variant } : {}), - parts: [textPart(kiloInput.input ?? "")], - }), - ); + await context.client.session.promptAsync({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + ...(providerId && modelId + ? { + model: { + providerID: providerId, + modelID: modelId, + }, + } + : {}), + ...(agent ? { agent } : {}), + ...(variant ? { variant } : {}), + parts: [textPart(kiloInput.input ?? "")], + }); } catch (cause) { const message = cause instanceof Error ? cause.message : "Kilo failed to start turn"; context.activeTurnId = undefined; @@ -999,12 +979,10 @@ export class KiloServerManager extends EventEmitter { async interruptTurn(threadId: ThreadId): Promise { const context = this.requireSession(threadId); - await readJsonData( - context.client.session.abort({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - }), - ); + await context.client.session.abort({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }); context.activeTurnId = undefined; context.session = { ...stripTransientSessionFields(context.session), @@ -1019,13 +997,11 @@ export class KiloServerManager extends EventEmitter { decision: ProviderApprovalDecision, ): Promise { const context = this.requireSession(threadId); - await readJsonData( - context.client.permission.reply({ - requestID: requestId, - ...(context.workspace ? { workspace: context.workspace } : {}), - reply: toPermissionReply(decision), - }), - ); + await context.client.permission.reply({ + requestID: requestId, + ...(context.workspace ? { workspace: context.workspace } : {}), + reply: toPermissionReply(decision), + }); } async respondToUserInput( @@ -1055,23 +1031,19 @@ export class KiloServerManager extends EventEmitter { } } - await readJsonData( - context.client.question.reply({ - requestID: requestId, - ...(context.workspace ? { workspace: context.workspace } : {}), - answers: orderedAnswers, - }), - ); + await context.client.question.reply({ + requestID: requestId, + ...(context.workspace ? { workspace: context.workspace } : {}), + answers: orderedAnswers, + }); } async readThread(threadId: ThreadId): Promise { const context = this.requireSession(threadId); - const messages = await readJsonData( - context.client.session.messages({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - }), - ); + const messages = await context.client.session.messages({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }); const turns = (Array.isArray(messages) ? messages : []).map((entry) => { const info = asRecord(asRecord(entry)?.info); @@ -1110,7 +1082,7 @@ export class KiloServerManager extends EventEmitter { : {}), }); const payload = readProviderListResponse( - await readJsonData(client.provider.list(options?.workspace ? { workspace: options.workspace } : {})), + await client.provider.list(options?.workspace ? { workspace: options.workspace } : {}), ); // Show models from all configured providers, not just connected ones. // Connection status is a runtime concern — users want to pick from @@ -1121,7 +1093,7 @@ export class KiloServerManager extends EventEmitter { return listed; } const configured = readConfigProvidersResponse( - await readJsonData(client.config.providers(options?.workspace ? { workspace: options.workspace } : {})), + await client.config.providers(options?.workspace ? { workspace: options.workspace } : {}), ); return parseProviderModels(configured.providers); } @@ -1200,18 +1172,6 @@ export class KiloServerManager extends EventEmitter { }); const startedBaseUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // Process may already be dead. - } - reject( - new Error( - `Timed out waiting for Kilo server to start after ${SERVER_START_TIMEOUT_MS}ms`, - ), - ); - }, SERVER_START_TIMEOUT_MS); let output = ""; const onChunk = (chunk: Buffer) => { @@ -1220,18 +1180,17 @@ export class KiloServerManager extends EventEmitter { if (!url) { return; } - clearTimeout(timeout); + cleanup(); resolve(url); }; - child.stdout.on("data", onChunk); - child.stderr.on("data", onChunk); - child.once("error", (error) => { - clearTimeout(timeout); + const onError = (error: Error) => { + cleanup(); reject(error); - }); - child.once("exit", (code) => { - clearTimeout(timeout); + }; + + const onExit = (code: number | null) => { + cleanup(); void probeServer(baseUrl, authHeader).then((reuse) => { if (reuse) { resolve(baseUrl); @@ -1246,7 +1205,34 @@ export class KiloServerManager extends EventEmitter { ), ); }); - }); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onChunk); + child.stderr.off("data", onChunk); + child.off("error", onError); + child.off("exit", onExit); + }; + + const timeout = setTimeout(() => { + cleanup(); + try { + child.kill(); + } catch { + // Process may already be dead. + } + reject( + new Error( + `Timed out waiting for Kilo server to start after ${SERVER_START_TIMEOUT_MS}ms`, + ), + ); + }, SERVER_START_TIMEOUT_MS); + + child.stdout.on("data", onChunk); + child.stderr.on("data", onChunk); + child.once("error", onError); + child.once("exit", onExit); }); const shared = { @@ -1810,8 +1796,8 @@ export class KiloServerManager extends EventEmitter { }); } - private emitRuntimeEvent(event: KiloProviderRuntimeEvent): void { - this.emit("event", event as unknown as ProviderRuntimeEvent); + private emitRuntimeEvent(event: ProviderRuntimeEvent): void { + this.emit("event", event); } } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index ec0a5596ab..d7d423e076 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -457,6 +457,27 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness(); const now = new Date().toISOString(); + // Seed thread-2 so the auxiliary completion targets a real but different thread + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-thread2-seed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-2"), + turnId: asTurnId("turn-thread2-seed"), + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-thread2-seed"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-2"), + turnId: asTurnId("turn-thread2-seed"), + status: "completed", + }); + + // Start primary turn on thread-1 harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-primary"), @@ -472,12 +493,13 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", ); + // Emit auxiliary turn.completed on thread-2 — should not affect thread-1 harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-aux"), provider: "codex", createdAt: new Date().toISOString(), - threadId: asThreadId("thread-1"), + threadId: asThreadId("thread-2"), turnId: asTurnId("turn-aux"), status: "completed", }); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 69333efb11..1726691c4c 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -50,6 +50,14 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg const PROVIDER = "claudeCode" as const; +/** + * Loose accessor type for SDKMessage dynamic properties that arrive via + * the SDK's index signature. Using this instead of `any` keeps the + * declaration file strict (`[key: string]: unknown`) while giving + * adapter code ergonomic access to SDK-emitted fields. + */ +type SDKMessageLoose = SDKMessage & Record; // oxlint-ignore-next-line -- intentional `any` for SDK index access + // ── Module-level usage tracking ────────────────────────────────────── interface ClaudeCodeUsageAccumulator { @@ -61,6 +69,8 @@ interface ClaudeCodeUsageAccumulator { lastRateLimits: Record | null; } +// Intentionally module-level: aggregates usage across all Claude Code sessions +// for the global usage display shown in the UI sidebar. let _claudeUsageAccumulator: ClaudeCodeUsageAccumulator = { totalCostUsd: 0, inputTokens: 0, @@ -546,7 +556,7 @@ function sdkMessageSubtype(value: unknown): string | undefined { return typeof record.subtype === "string" ? record.subtype : undefined; } -function sdkNativeMethod(message: SDKMessage): string { +function sdkNativeMethod(message: SDKMessageLoose): string { const subtype = sdkMessageSubtype(message); if (subtype) { return `claude/${message.type}/${subtype}`; @@ -569,7 +579,7 @@ function sdkNativeMethod(message: SDKMessage): string { return `claude/${message.type}`; } -function sdkNativeItemId(message: SDKMessage): string | undefined { +function sdkNativeItemId(message: SDKMessageLoose): string | undefined { if (message.type === "assistant") { const maybeId = (message.message as { id?: unknown }).id; if (typeof maybeId === "string") { @@ -915,7 +925,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleStreamEvent = ( context: ClaudeSessionContext, - message: SDKMessage, + message: SDKMessageLoose, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "stream_event") { @@ -1138,7 +1148,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSystemMessage = ( context: ClaudeSessionContext, - message: SDKMessage, + message: SDKMessageLoose, ): Effect.Effect => Effect.gen(function* () { if (message.type !== "system") { @@ -1301,7 +1311,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSdkTelemetryMessage = ( context: ClaudeSessionContext, - message: SDKMessage, + message: SDKMessageLoose, ): Effect.Effect => Effect.gen(function* () { const stamp = yield* makeEventStamp(); @@ -1379,7 +1389,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const handleSdkMessage = ( context: ClaudeSessionContext, - message: SDKMessage, + message: SDKMessageLoose, ): Effect.Effect => Effect.gen(function* () { yield* logNativeSdkMessage(context, message); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 50673f2b1b..124faa9e63 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -87,7 +87,7 @@ interface ActiveCopilotSession { session: CopilotSessionHandle; readonly threadId: ThreadId; readonly createdAt: string; - readonly runtimeMode: ProviderSession["runtimeMode"]; + runtimeMode: ProviderSession["runtimeMode"]; cwd: string | undefined; configDir: string | undefined; model: string | undefined; @@ -1013,8 +1013,6 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const sessionId = record.session.sessionId; const previousSession = record.session; const previousUnsubscribe = record.unsubscribe; - previousUnsubscribe(); - await previousSession.destroy(); const handlers = createInteractionHandlers( record.threadId, @@ -1032,6 +1030,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => streaming: true, }); + // Only destroy the old session after the new one has been created successfully + previousUnsubscribe(); + await previousSession.destroy(); + record.session = nextSession; record.model = input.model; record.reasoningEffort = input.reasoningEffort; @@ -1171,6 +1173,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const existing = sessions.get(input.threadId); if (existing) { + existing.runtimeMode = input.runtimeMode; + existing.updatedAt = new Date().toISOString(); return { provider: PROVIDER, status: "ready", @@ -1217,8 +1221,18 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const session = yield* Effect.tryPromise({ try: async () => { - if (resumeSessionId) { - return client.resumeSession(resumeSessionId, { + try { + if (resumeSessionId) { + return await client.resumeSession(resumeSessionId, { + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); + } + return await client.createSession({ ...handlers, ...(input.model ? { model: input.model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), @@ -1226,15 +1240,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...(configDir ? { configDir } : {}), streaming: true, }); + } catch (err) { + await client.stop().catch(() => {}); + throw err; } - return client.createSession({ - ...handlers, - ...(input.model ? { model: input.model } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - ...(input.cwd ? { workingDirectory: input.cwd } : {}), - ...(configDir ? { configDir } : {}), - streaming: true, - }); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1754,7 +1763,7 @@ function parseCopilotInternalResponse(data: Record): ProviderUs ...(used !== undefined ? { used } : {}), ...(limit !== undefined ? { limit } : {}), ...(resetDate ? { resetDate } : {}), - ...(limit !== undefined && used !== undefined + ...(limit !== undefined && limit > 0 && used !== undefined ? { percentUsed: Math.round((used / limit) * 100) } : {}), }; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 7c6fc711aa..e0bf8f92f9 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -47,8 +47,10 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const byProvider = new Map>(); for (const adapter of adapters) { if (byProvider.has(adapter.provider)) { - throw new Error( - `Duplicate provider adapter registration for provider "${adapter.provider}"`, + return yield* Effect.die( + new Error( + `Duplicate provider adapter registration for provider "${adapter.provider}"`, + ), ); } byProvider.set(adapter.provider, adapter); diff --git a/apps/server/src/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts index 68bb69015e..3c3e03a4c0 100644 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ b/apps/server/src/provider/claude-agent-sdk.d.ts @@ -69,22 +69,22 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly content?: ReadonlyArray; readonly [key: string]: unknown; }; - readonly content?: ReadonlyArray>; + readonly content?: ReadonlyArray>; readonly uuid?: string; readonly session_id?: string; readonly parent_tool_use_id?: string; readonly tool_use_id?: string; readonly tool_name?: string; - readonly input?: Record; + readonly input?: Record; readonly result?: string; readonly error?: string; readonly errors?: ReadonlyArray; - readonly content_block?: Record; + readonly content_block?: Record; readonly index?: number; readonly preceding_tool_use_ids?: ReadonlyArray; readonly is_error?: boolean; readonly suggestions?: ReadonlyArray; - readonly [key: string]: any; + readonly [key: string]: unknown; } export interface Options { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e16cb7fdc4..493289fd76 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5572,10 +5572,16 @@ function mergeDiscoveredModels( result[provider] = [...enriched, ...customOnly]; continue; } + // Build a lookup of discovered models by slug so we can merge metadata + // (e.g. pricingTier) into base entries and also add truly-new models. + const discoveredBySlug = new Map(models.map((m) => [m.slug, m])); + const merged = (base[provider] ?? []).map((m) => { + const discovered = discoveredBySlug.get(m.slug); + return discovered ? { ...m, ...discovered } : m; + }); + // Append any discovered models that weren't already in the base list. const additions = models.filter((m) => !existing.has(m.slug)); - if (additions.length > 0) { - result[provider] = [...additions, ...(base[provider] ?? [])]; - } + result[provider] = [...additions, ...merged]; } return result; } @@ -5606,7 +5612,12 @@ function groupModelsBySubProvider( groupMap.set(subProviderId, group); groupOrder.push(subProviderId); } - group.models.push({ slug: model.slug, name: modelName }); + group.models.push({ + slug: model.slug, + name: modelName, + ...(model.pricingTier != null && { pricingTier: model.pricingTier }), + ...(model.isCustom != null && { isCustom: model.isCustom }), + }); } else { ungrouped.push(model); } diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs index 940ec72b1a..c2913abc67 100644 --- a/scripts/sync-upstream-pr-tracks.mjs +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -26,14 +26,17 @@ function tryRunGit(args) { } function deriveRepoUrl(remoteName) { - const remoteUrl = tryRunGit(["remote", "get-url", remoteName]); + let remoteUrl = tryRunGit(["remote", "get-url", remoteName]); if (!remoteUrl) return null; - // Handle SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) - const sshMatch = remoteUrl.match(/git@([^:]+):(.+?)(?:\.git)?$/); + // Strip trailing .git before matching so it never leaks into the result. + remoteUrl = remoteUrl.replace(/\.git$/, ""); + + // Handle SSH (git@github.com:owner/repo) and HTTPS (https://github.com/owner/repo) + const sshMatch = remoteUrl.match(/git@([^:]+):(.+)$/); if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`; - const httpsMatch = remoteUrl.match(/^https?:\/\/(.+?)(?:\.git)?$/); + const httpsMatch = remoteUrl.match(/^https?:\/\/(.+)$/); if (httpsMatch) return `https://${httpsMatch[1]}`; return null; From b17fef46382bf60a3722a7aff54d54401e6694c3 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 8 Mar 2026 21:37:24 +0530 Subject: [PATCH 19/23] fix: address round-3 PR review feedback ampServerManager: - Reject unsupported attachments before mutating session state - Don't mark interrupted turn as ready; let close/error handlers finalize - Defer session deletion until child exit handlers emit terminal events geminiCliServerManager: - Assert stored geminiSessionId in init event test - Emit effectiveModel (input.model ?? session.model) in turn.started - Use signal param in close handler; emit "interrupted" state on SIGINT - Remove premature ready status in interruptTurn - listSessions exposes actual session status instead of coercing to "ready" kiloServerManager: - Preserve workspace in resume state alongside sessionId - Reject overlapping turns before overwriting activeTurnId - Emit terminal turn events on interrupt and stream failure paths ClaudeCodeAdapter: - classifyRequestType properly routes MCP/dynamic tools via switch - Clear lastError on successful turn completion and turn start CopilotAdapter: - Emit failed turn.completed (not completed) when session.idle follows error - Store tool item type on start, reuse on complete for lifecycle stability - Widen client leak catch to cover validateSessionConfiguration failures ChatView: - Remove staleTime: Infinity so model discovery respects shared cache TTL - Render pricing tier badges in grouped provider submenus sync-upstream-pr-tracks: - Add ssh:// URL format support in deriveRepoUrl - Throw on failure instead of falling back to hardcoded URL --- apps/server/src/ampServerManager.ts | 21 ++++++-- .../server/src/geminiCliServerManager.test.ts | 5 +- apps/server/src/geminiCliServerManager.ts | 22 ++++---- apps/server/src/kiloServerManager.ts | 51 +++++++++++++++++-- .../src/provider/Layers/ClaudeCodeAdapter.ts | 14 +++-- .../src/provider/Layers/CopilotAdapter.ts | 44 +++++++++++----- apps/web/src/components/ChatView.tsx | 24 ++++----- scripts/sync-upstream-pr-tracks.mjs | 17 +++++-- 8 files changed, 144 insertions(+), 54 deletions(-) diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 24d1b5b576..568d0b8dd3 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -81,6 +81,8 @@ interface AmpSession { readonly subagentTasks: Map; /** Maps tool_use_id → classified item type for consistent start/completion typing. */ readonly toolItemTypes: Map>; + /** Set to true when stopSession is called so close/error handlers know to delete the session. */ + closing: boolean; readonly createdAt: string; updatedAt: string; } @@ -216,6 +218,7 @@ export class AmpServerManager extends EventEmitter<{ activeAssistantItemId: undefined, subagentTasks: new Map(), toolItemTypes: new Map(), + closing: false, createdAt: now, updatedAt: now, }; @@ -262,6 +265,9 @@ export class AmpServerManager extends EventEmitter<{ exitKind: code === 0 ? "graceful" : "error", }, }); + if (s.closing) { + this.sessions.delete(threadId); + } } }); @@ -286,6 +292,9 @@ export class AmpServerManager extends EventEmitter<{ type: "runtime.error", payload: { message: error.message, class: "transport_error" }, }); + if (s?.closing) { + this.sessions.delete(threadId); + } }); const providerSession: ProviderSession = { @@ -317,6 +326,9 @@ export class AmpServerManager extends EventEmitter<{ `AMP session ${input.threadId} already has a turn in progress (turn ${session.activeTurnId})`, ); } + if (input.attachments && input.attachments.length > 0) { + throw new Error("Attachments are not supported by AMP"); + } const turnId = TurnId.makeUnsafe(randomUUID()); session.activeTurnId = turnId; @@ -356,8 +368,6 @@ export class AmpServerManager extends EventEmitter<{ } if (session.status === "running") { session.process.kill("SIGINT"); - session.status = "ready"; - session.updatedAt = new Date().toISOString(); } return Promise.resolve(); } @@ -385,13 +395,14 @@ export class AmpServerManager extends EventEmitter<{ stopSession(threadId: ThreadId): void { const session = this.sessions.get(threadId); if (!session) return; + session.closing = true; try { session.process.kill(); } catch { - // Process may already be dead. + // Process may already be dead — clean up immediately since handlers won't fire. + session.status = "closed"; + this.sessions.delete(threadId); } - session.status = "closed"; - this.sessions.delete(threadId); } // ── Listing / introspection ─────────────────────────────────────── diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index d9e727d8e5..8b9f594b20 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -323,8 +323,11 @@ describe("GeminiCliServerManager JSON event mapping", () => { // init doesn't emit a provider event, but we can verify the session ID // was captured by checking that a subsequent sendTurn would use --resume. - // For now just verify no event was emitted (init is internal bookkeeping). expect(events).toHaveLength(0); + + // Verify the session_id was actually stored for --resume on subsequent turns. + const sessions = (manager as unknown as { sessions: Map }).sessions; + expect(sessions.get("thread-json")?.geminiSessionId).toBe("gemini-sess-abc"); }); it("maps assistant message deltas to content.delta events with stable itemId", () => { diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 897ea426cd..2fe343ab5b 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -290,7 +290,7 @@ export class GeminiCliServerManager extends EventEmitter<{ // Emit turn.started immediately. this.emitEvent(input.threadId, turnId, { type: "turn.started", - payload: { model: session.model }, + payload: { model: effectiveModel }, }); const rl = readline.createInterface({ input: child.stdout }); @@ -301,7 +301,7 @@ export class GeminiCliServerManager extends EventEmitter<{ // Ignore stderr (skill conflict warnings, YOLO notices, etc.) - child.on("close", (code) => { + child.on("close", (code, signal) => { const s = this.sessions.get(input.threadId); if (!s) return; @@ -315,12 +315,14 @@ export class GeminiCliServerManager extends EventEmitter<{ this.emitEvent(input.threadId, turnId, { type: "turn.completed", payload: - code === 0 - ? { state: "completed" } - : { - state: "failed", - errorMessage: `Gemini CLI exited with code ${code}`, - }, + signal === "SIGINT" + ? { state: "interrupted" } + : code === 0 + ? { state: "completed" } + : { + state: "failed", + errorMessage: `Gemini CLI exited with code ${code}`, + }, }); } }); @@ -358,8 +360,6 @@ export class GeminiCliServerManager extends EventEmitter<{ } if (session.status === "running" && session.activeProcess) { session.activeProcess.kill("SIGINT"); - session.status = "ready"; - session.updatedAt = new Date().toISOString(); } return Promise.resolve(); } @@ -399,7 +399,7 @@ export class GeminiCliServerManager extends EventEmitter<{ for (const session of this.sessions.values()) { sessions.push({ provider: PROVIDER, - status: session.status === "closed" ? "closed" : "ready", + status: session.status, runtimeMode: session.runtimeMode as ProviderSession["runtimeMode"], threadId: session.threadId, cwd: session.cwd, diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 6d79a2b602..790ccfae76 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -463,9 +463,14 @@ async function probeServer(baseUrl: string, authHeader?: string): Promise { const directory = kiloInput.cwd ?? process.cwd(); const options = kiloInput.providerOptions?.kilo; - const workspace = options?.workspace; + const resumeState = readResumeState(kiloInput.resumeCursor); + const workspace = options?.workspace ?? resumeState?.workspace; const sharedServer = await this.ensureServer(options); const client = await this.createClient({ baseUrl: sharedServer.baseUrl, @@ -756,7 +762,7 @@ export class KiloServerManager extends EventEmitter { : {}), }); - const resumedSessionId = readResumeSessionId(kiloInput.resumeCursor); + const resumedSessionId = resumeState?.sessionId; const resumedSession = resumedSessionId ? await client.session .get({ @@ -859,6 +865,11 @@ export class KiloServerManager extends EventEmitter { } const kiloInput = input as KiloSendTurnInput; const context = this.requireSession(input.threadId); + if (context.activeTurnId) { + throw new Error( + `Kilo thread '${input.threadId}' already has an active turn '${context.activeTurnId}'`, + ); + } const turnId = createTurnId(); const agent = kiloInput.modelOptions?.kilo?.agent ?? @@ -983,6 +994,20 @@ export class KiloServerManager extends EventEmitter { sessionID: context.providerSessionId, ...(context.workspace ? { workspace: context.workspace } : {}), }); + const interruptedTurnId = context.activeTurnId; + if (interruptedTurnId) { + this.emitRuntimeEvent({ + type: "turn.completed", + eventId: eventId("kilo-turn-interrupted"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + turnId: interruptedTurnId, + payload: { + state: "interrupted", + }, + }); + } context.activeTurnId = undefined; context.session = { ...stripTransientSessionFields(context.session), @@ -1288,18 +1313,34 @@ export class KiloServerManager extends EventEmitter { updatedAt: nowIso(), lastError: message, }; + const failedTurnId = context.activeTurnId; this.emitRuntimeEvent({ type: "runtime.error", eventId: eventId("kilo-stream-error"), provider: PROVIDER, threadId: context.threadId, createdAt: nowIso(), - ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + ...(failedTurnId ? { turnId: failedTurnId } : {}), payload: { message, class: "transport_error", }, }); + if (failedTurnId) { + this.emitRuntimeEvent({ + type: "turn.completed", + eventId: eventId("kilo-stream-error-turn-completed"), + provider: PROVIDER, + threadId: context.threadId, + createdAt: nowIso(), + turnId: failedTurnId, + payload: { + state: "failed", + errorMessage: message, + }, + }); + context.activeTurnId = undefined; + } } } diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 1726691c4c..e8d02ca331 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -359,9 +359,14 @@ function classifyRequestType(toolName: string): CanonicalRequestType { if (normalized === "read" || normalized.includes("read file") || normalized.includes("view")) { return "file_read_approval"; } - return classifyToolItemType(toolName) === "command_execution" - ? "command_execution_approval" - : "file_change_approval"; + switch (classifyToolItemType(toolName)) { + case "command_execution": + return "command_execution_approval"; + case "file_change": + return "file_change_approval"; + default: + return "dynamic_tool_call"; + } } function summarizeToolRequest(toolName: string, input: Record): string { @@ -918,7 +923,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { status: "ready", activeTurnId: undefined, updatedAt, - ...(status === "failed" && errorMessage ? { lastError: errorMessage } : {}), + lastError: status === "failed" && errorMessage ? errorMessage : undefined, }; yield* updateResumeCursor(context); }); @@ -1859,6 +1864,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { status: "running", activeTurnId: turnId, updatedAt, + lastError: undefined, }; const turnStartedStamp = yield* makeEventStamp(); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 124faa9e63..1dbe56a2b1 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { + type CanonicalItemType, type CodexReasoningEffort, EventId, type ProviderApprovalDecision, @@ -98,6 +99,7 @@ interface ActiveCopilotSession { currentProviderTurnId: TurnId | undefined; pendingTurnIds: Array; toolTitlesByCallId: Map; + toolItemTypeByCallId: Map; pendingApprovalResolvers: Map; pendingUserInputResolvers: Map; unsubscribe: () => void; @@ -131,6 +133,7 @@ function createSessionRecord(input: { currentProviderTurnId: undefined, pendingTurnIds: [], toolTitlesByCallId: new Map(), + toolItemTypeByCallId: new Map(), pendingApprovalResolvers: input.pendingApprovalResolvers, pendingUserInputResolvers: input.pendingUserInputResolvers, unsubscribe: () => undefined, @@ -284,7 +287,7 @@ function requestDetailFromPermissionRequest(request: PermissionRequest): string } } -function itemTypeFromToolEvent(event: Extract) { +function itemTypeFromToolEvent(event: Extract): CanonicalItemType { return event.data.mcpToolName ? "mcp_tool_call" : "dynamic_tool_call"; } @@ -529,15 +532,22 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => // signal — it fires after assistant.turn_end and // assistant.usage have completed. Emit turn.completed here // so the orchestration layer settles the turn cleanly. + // If a session.error preceded idle, finalize the turn as + // failed so the UI does not incorrectly show success. return [ ...(currentTurnId ? [ { ...base({ providerTurnId: currentProviderTurnId }), type: "turn.completed" as const, - payload: { - state: "completed" as const, - }, + payload: record.lastError + ? { + state: "failed" as const, + errorMessage: record.lastError, + } + : { + state: "completed" as const, + }, }, ] : []), @@ -774,11 +784,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...base({ itemId: event.data.toolCallId }), type: "item.completed", payload: { - itemType: event.data.result?.contents?.some( - (content: { type?: string }) => content.type === "terminal", - ) - ? "command_execution" - : "dynamic_tool_call", + itemType: record.toolItemTypeByCallId.get(event.data.toolCallId) ?? "dynamic_tool_call", status: event.data.success ? "completed" : "failed", title: record.toolTitlesByCallId.get(event.data.toolCallId) ?? "Tool call", ...(trimToUndefined(event.data.result?.content) ? { detail: event.data.result?.content } : {}), @@ -1064,8 +1070,11 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => if (event.type === "session.model_change") { record.model = event.data.newModel; } - if (event.type === "tool.execution_start" && trimToUndefined(event.data.toolName)) { - record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); + if (event.type === "tool.execution_start") { + if (trimToUndefined(event.data.toolName)) { + record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); + } + record.toolItemTypeByCallId.set(event.data.toolCallId, itemTypeFromToolEvent(event)); } void writeNativeEvent(record.threadId, event); @@ -1075,6 +1084,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } if (event.type === "tool.execution_complete") { record.toolTitlesByCallId.delete(event.data.toolCallId); + record.toolItemTypeByCallId.delete(event.data.toolCallId); } if (event.type === "abort" || event.type === "session.idle") { // If the turn terminates before assistant.turn_start consumed the @@ -1087,6 +1097,11 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } record.currentTurnId = undefined; record.currentProviderTurnId = undefined; + // Clear the error after the idle handler has consumed it for + // turn.completed so it doesn't leak into subsequent turns. + if (event.type === "session.idle") { + record.lastError = undefined; + } } }; @@ -1217,7 +1232,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => threadId: input.threadId, model: input.model, reasoningEffort, - }); + }).pipe( + // validateSessionConfiguration may call client.start() internally. + // If validation fails after that, stop the client to avoid leaking + // a running process. + Effect.tapError(() => Effect.promise(() => client.stop().catch(() => {}))), + ); const session = yield* Effect.tryPromise({ try: async () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 493289fd76..c27e4d9030 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -883,18 +883,9 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; - const copilotModelsQuery = useQuery({ - ...providerListModelsQueryOptions("copilot"), - staleTime: Infinity, - }); - const opencodeModelsQuery = useQuery({ - ...providerListModelsQueryOptions("opencode"), - staleTime: Infinity, - }); - const kiloModelsQuery = useQuery({ - ...providerListModelsQueryOptions("kilo"), - staleTime: Infinity, - }); + const copilotModelsQuery = useQuery(providerListModelsQueryOptions("copilot")); + const opencodeModelsQuery = useQuery(providerListModelsQueryOptions("opencode")); + const kiloModelsQuery = useQuery(providerListModelsQueryOptions("kilo")); const modelOptionsByProvider = useMemo( () => mergeDiscoveredModels(getCustomModelOptionsByProvider(settings), { @@ -5794,7 +5785,14 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { value={modelOption.slug} onClick={() => setIsMenuOpen(false)} > - {modelOption.name} + + {modelOption.name} + {modelOption.pricingTier ? ( + + {formatPricingTier(modelOption.pricingTier)} + + ) : null} + ))} diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs index c2913abc67..8f7e215270 100644 --- a/scripts/sync-upstream-pr-tracks.mjs +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -32,10 +32,15 @@ function deriveRepoUrl(remoteName) { // Strip trailing .git before matching so it never leaks into the result. remoteUrl = remoteUrl.replace(/\.git$/, ""); - // Handle SSH (git@github.com:owner/repo) and HTTPS (https://github.com/owner/repo) - const sshMatch = remoteUrl.match(/git@([^:]+):(.+)$/); + // Handle scp-style SSH (git@github.com:owner/repo) + const scpMatch = remoteUrl.match(/git@([^:]+):(.+)$/); + if (scpMatch) return `https://${scpMatch[1]}/${scpMatch[2]}`; + + // Handle ssh:// protocol (ssh://git@github.com/owner/repo) + const sshMatch = remoteUrl.match(/^ssh:\/\/[^@]+@([^/]+)\/(.+)$/); if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`; + // Handle HTTPS (https://github.com/owner/repo) const httpsMatch = remoteUrl.match(/^https?:\/\/(.+)$/); if (httpsMatch) return `https://${httpsMatch[1]}`; @@ -53,6 +58,12 @@ function loadConfig() { } // Derive the repo URL from the upstream remote instead of hardcoding it. parsed.repoUrl = deriveRepoUrl(parsed.upstreamRemote) ?? deriveRepoUrl(parsed.forkRemote); + if (!parsed.repoUrl) { + throw new Error( + `Could not derive repo URL from remotes "${parsed.upstreamRemote}" or "${parsed.forkRemote}". ` + + "Ensure at least one remote uses an HTTPS, scp-style SSH, or ssh:// URL.", + ); + } return parsed; } @@ -113,7 +124,7 @@ function main() { const integrationSummary = getComparisonSummary(integrationBranch, pr.localBranch); formatSection(`PR #${pr.number}: ${pr.title}`); - console.log(`URL: ${config.repoUrl ?? "https://github.com/pingdotgg/t3code"}/pull/${pr.number}`); + console.log(`URL: ${config.repoUrl}/pull/${pr.number}`); console.log(`Tracking branch: ${pr.localBranch}`); console.log(`Branch SHA: ${branchSha}`); console.log(`Merge base with ${baseBranch}: ${baseSummary.mergeBase ?? "(missing)"}`); From 60dc4a86b40a9868ea413c714027d120f7743916 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 8 Mar 2026 22:13:11 +0530 Subject: [PATCH 20/23] fix: address round-4 PR review feedback ampServerManager: - Clean up dead/closed sessions in startSession instead of rejecting - Always delete session from map on terminal state - Finalize subagent tasks and drain tool items on close/error paths - Move state mutations after stdin.write with try/catch rollback - readThread/rollbackThread throw unsupported errors instead of fake snapshots geminiCliServerManager: - Return normalized runtimeMode from startSession (not raw input) - Finalize open assistant/tool items on all terminal paths ClaudeCodeAdapter: - Defer existing session teardown until replacement runtime is ready - Validate buildUserMessage before opening turn state/emitting turn.started CopilotAdapter: - Install new session before destroying old in reconfigureSession - Wrap previousSession.destroy in try/catch to keep replacement live - Reject overlapping sendTurn calls with ProviderAdapterValidationError ChatView: - Add serviceTier to plan refine/implement dispatch payloads - Add geminiCli and amp model discovery queries - Include both in mergeDiscoveredModels and useMemo deps kiloServerManager: - Accept optional existing manager in fetchKiloModels to avoid respawning --- apps/server/src/ampServerManager.ts | 71 +++++++++++++------ apps/server/src/geminiCliServerManager.ts | 36 +++++++++- apps/server/src/kiloServerManager.ts | 11 ++- .../src/provider/Layers/ClaudeCodeAdapter.ts | 19 ++--- .../src/provider/Layers/CopilotAdapter.ts | 22 ++++-- apps/web/src/components/ChatView.tsx | 10 ++- 6 files changed, 131 insertions(+), 38 deletions(-) diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 568d0b8dd3..f406fb9b13 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -178,8 +178,13 @@ export class AmpServerManager extends EventEmitter<{ startSession(input: ProviderSessionStartInput): Promise { const threadId = input.threadId; - if (this.sessions.has(threadId)) { - throw new Error(`AMP session already exists for thread ${threadId}`); + const existing = this.sessions.get(threadId); + if (existing) { + if (existing.status === "closed") { + this.sessions.delete(threadId); + } else { + throw new Error(`AMP session already exists for thread ${threadId}`); + } } const ampOpts = input.providerOptions?.amp as AmpProviderOptions | undefined; @@ -246,6 +251,8 @@ export class AmpServerManager extends EventEmitter<{ const s = this.sessions.get(threadId); if (s) { if (s.activeTurnId) { + this.closeAllSubagentTasks(threadId, s); + this.drainToolItems(threadId, s); this.emitEvent(threadId, s.activeTurnId, { type: "turn.completed", payload: { @@ -265,9 +272,7 @@ export class AmpServerManager extends EventEmitter<{ exitKind: code === 0 ? "graceful" : "error", }, }); - if (s.closing) { - this.sessions.delete(threadId); - } + this.sessions.delete(threadId); } }); @@ -275,6 +280,8 @@ export class AmpServerManager extends EventEmitter<{ const s = this.sessions.get(threadId); if (s) { if (s.activeTurnId) { + this.closeAllSubagentTasks(threadId, s); + this.drainToolItems(threadId, s); this.emitEvent(threadId, s.activeTurnId, { type: "turn.completed", payload: { @@ -292,7 +299,7 @@ export class AmpServerManager extends EventEmitter<{ type: "runtime.error", payload: { message: error.message, class: "transport_error" }, }); - if (s?.closing) { + if (s) { this.sessions.delete(threadId); } }); @@ -331,10 +338,6 @@ export class AmpServerManager extends EventEmitter<{ } const turnId = TurnId.makeUnsafe(randomUUID()); - session.activeTurnId = turnId; - session.status = "running"; - session.updatedAt = new Date().toISOString(); - const prompt = input.input ?? ""; // Write a JSONL user message to stdin for the persistent AMP process. @@ -346,7 +349,19 @@ export class AmpServerManager extends EventEmitter<{ content: [{ type: "text", text: prompt }], }, }); - session.process.stdin.write(userMessage + "\n"); + + try { + session.process.stdin.write(userMessage + "\n"); + } catch (err) { + throw new Error( + `Failed to write to AMP stdin for session ${input.threadId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Mutations happen only after successful write. + session.activeTurnId = turnId; + session.status = "running"; + session.updatedAt = new Date().toISOString(); this.emitEvent(input.threadId, turnId, { type: "turn.started", @@ -428,18 +443,12 @@ export class AmpServerManager extends EventEmitter<{ return this.sessions.has(threadId); } - readThread(threadId: ThreadId): Promise { - if (!this.sessions.has(threadId)) { - throw new Error(`Unknown AMP session: ${threadId}`); - } - return Promise.resolve({ threadId, turns: [] }); + readThread(_threadId: ThreadId): Promise { + throw new Error("readThread is not supported for AMP provider"); } - rollbackThread(threadId: ThreadId): Promise { - if (!this.sessions.has(threadId)) { - throw new Error(`Unknown AMP session: ${threadId}`); - } - return Promise.resolve({ threadId, turns: [] }); + rollbackThread(_threadId: ThreadId): Promise { + throw new Error("rollbackThread is not supported for AMP provider"); } stopAll(): void { @@ -711,6 +720,26 @@ export class AmpServerManager extends EventEmitter<{ session.subagentTasks.clear(); } + /** Emit item.completed for every remaining open tool item and clear the map. */ + private drainToolItems(threadId: ThreadId, session: AmpSession): void { + for (const [toolUseId, itemType] of session.toolItemTypes) { + this.emitEvent( + threadId, + session.activeTurnId, + { + type: "item.completed", + payload: { + itemType, + status: "failed", + data: null, + }, + }, + RuntimeItemId.makeUnsafe(toolUseId), + ); + } + session.toolItemTypes.clear(); + } + // ── type: "user" ────────────────────────────────────────────────── private handleUserMessage( diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 2fe343ab5b..15278a0bc9 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -220,7 +220,7 @@ export class GeminiCliServerManager extends EventEmitter<{ const providerSession: ProviderSession = { provider: PROVIDER, status: "ready", - runtimeMode: input.runtimeMode, + runtimeMode: session.runtimeMode as ProviderSession["runtimeMode"], threadId, cwd, model: input.model, @@ -312,6 +312,9 @@ export class GeminiCliServerManager extends EventEmitter<{ s.status = "ready"; s.updatedAt = new Date().toISOString(); + // Flush any open assistant message or tool items that never received completion. + this.finalizeOpenItems(input.threadId, turnId, s); + this.emitEvent(input.threadId, turnId, { type: "turn.completed", payload: @@ -333,6 +336,9 @@ export class GeminiCliServerManager extends EventEmitter<{ s.activeProcess = undefined; s.status = "ready"; s.updatedAt = new Date().toISOString(); + + // Flush any open assistant message or tool items that never received completion. + this.finalizeOpenItems(input.threadId, turnId, s); } this.emitEvent(input.threadId, turnId, { type: "runtime.error", @@ -590,6 +596,34 @@ export class GeminiCliServerManager extends EventEmitter<{ } } + /** Flush any open assistant message and tool items that never received a matching completed event. */ + private finalizeOpenItems(threadId: ThreadId, turnId: TurnId, session: GeminiCliSession): void { + if (session.activeAssistantItemId) { + this.emitEvent(threadId, turnId, { + type: "item.completed", + itemId: session.activeAssistantItemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + session.activeAssistantItemId = undefined; + } + + for (const [toolId, tool] of session.activeToolItems) { + this.emitEvent(threadId, turnId, { + type: "item.completed", + itemId: tool.itemId, + payload: { + itemType: "command_execution", + status: "failed", + title: tool.toolName, + }, + }); + session.activeToolItems.delete(toolId); + } + } + private emitEvent( threadId: ThreadId, turnId: TurnId | undefined, diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index 790ccfae76..f2682d8560 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -1842,11 +1842,16 @@ export class KiloServerManager extends EventEmitter { } } -export async function fetchKiloModels(options?: KiloModelDiscoveryOptions) { - const manager = new KiloServerManager(); +export async function fetchKiloModels( + options?: KiloModelDiscoveryOptions, + existingManager?: KiloServerManager, +) { + const manager = existingManager ?? new KiloServerManager(); try { return await manager.listModels(options); } finally { - manager.stopAll(); + if (!existingManager) { + manager.stopAll(); + } } } diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index e8d02ca331..4032a4ca35 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1552,12 +1552,6 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = resumeState?.threadId ?? input.threadId; - // Guard against duplicate threadId: stop/cleanup any existing session before creating a new one. - const existingContext = sessions.get(threadId); - if (existingContext) { - yield* stopSessionInternal(existingContext, { emitExitEvent: true }); - } - const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( Stream.filter((item) => item.type === "message"), @@ -1740,6 +1734,13 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }), }); + // Guard against duplicate threadId: stop/cleanup any existing session AFTER the + // replacement runtime is successfully created so the thread is never left unusable. + const existingContext = sessions.get(threadId); + if (existingContext) { + yield* stopSessionInternal(existingContext, { emitExitEvent: true }); + } + const session: ProviderSession = { threadId, provider: PROVIDER, @@ -1846,6 +1847,10 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }); } + // Validate the user message BEFORE mutating any session state so that + // a buildUserMessage failure leaves the session unchanged. + const message = yield* buildUserMessage(input); + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); const turnState: ClaudeTurnState = { turnId, @@ -1881,8 +1886,6 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }, }); - const message = yield* buildUserMessage(input); - yield* Queue.offer(context.promptQueue, { type: "message", message, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 1dbe56a2b1..284d2c329f 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1036,10 +1036,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => streaming: true, }); - // Only destroy the old session after the new one has been created successfully - previousUnsubscribe(); - await previousSession.destroy(); - + // Install the new session immediately so the record is live record.session = nextSession; record.model = input.model; record.reasoningEffort = input.reasoningEffort; @@ -1047,6 +1044,14 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => record.unsubscribe = nextSession.on((event) => { handleSessionEvent(record, event); }); + + // Clean up the old session – failures here must not affect the new session + previousUnsubscribe(); + try { + await previousSession.destroy(); + } catch { + // Swallow destroy errors; the new session is already installed + } }, catch: (cause) => new ProviderAdapterRequestError({ @@ -1334,6 +1339,15 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const record = yield* getSessionRecord(input.threadId); + + if (record.currentTurnId || record.pendingTurnIds.length > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Thread '${input.threadId}' already has an active turn '${record.currentTurnId ?? record.pendingTurnIds[0]}'.`, + }); + } + const explicitReasoningEffort = getCopilotReasoningEffort(input.modelOptions); const nextModel = input.model ?? record.model; const nextReasoningEffort = diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c27e4d9030..eea531b033 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -886,14 +886,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const copilotModelsQuery = useQuery(providerListModelsQueryOptions("copilot")); const opencodeModelsQuery = useQuery(providerListModelsQueryOptions("opencode")); const kiloModelsQuery = useQuery(providerListModelsQueryOptions("kilo")); + const geminiCliModelsQuery = useQuery(providerListModelsQueryOptions("geminiCli")); + const ampModelsQuery = useQuery(providerListModelsQueryOptions("amp")); const modelOptionsByProvider = useMemo( () => mergeDiscoveredModels(getCustomModelOptionsByProvider(settings), { copilot: copilotModelsQuery.data, opencode: opencodeModelsQuery.data, kilo: kiloModelsQuery.data, + geminiCli: geminiCliModelsQuery.data, + amp: ampModelsQuery.data, }), - [settings, copilotModelsQuery.data, opencodeModelsQuery.data, kiloModelsQuery.data], + [settings, copilotModelsQuery.data, opencodeModelsQuery.data, kiloModelsQuery.data, geminiCliModelsQuery.data, ampModelsQuery.data], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { if (selectedProvider !== "cursor") { @@ -3002,6 +3006,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, + serviceTier: selectedServiceTier, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), @@ -3036,6 +3041,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel, selectedModelOptionsForDispatch, selectedProvider, + selectedServiceTier, setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, @@ -3102,6 +3108,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, + serviceTier: selectedServiceTier, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), @@ -3155,6 +3162,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel, selectedModelOptionsForDispatch, selectedProvider, + selectedServiceTier, settings.enableAssistantStreaming, syncServerReadModel, ]); From 34b1e34de4efccb0df82ec2c4d9b66cfb855edfa Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 9 Mar 2026 13:17:09 +0530 Subject: [PATCH 21/23] feat: new features with bug fixes from code review - Add Copilot turn tracking, cursor usage, command palette, plan sidebar, ghostty terminal split view, project thread navigation, and session text generation - Fix ProviderStartOptions schema: restore opencode/kilo fields and value re-export - Fix CopilotAdapter: restore concurrent-turn guard, session cleanup on failure, lastError lifecycle, teardown event emission, pending resolver cleanup, reconfigure error guard, attachment validation Effect channel - Fix orchestration: interrupt re-raise in approval/user-input handlers, threadProviderOptions cleanup on stop and delete - Fix frontend: CommandPalette render mutation, stale drag closure, assistantDeliveryMode dedup, timeout cleanups, onInterrupt memoization, PlanSidebar duplicate keys, settings navigation, onDragLeave handler - Fix server: satisfies vs as type safety, stopSession error logging, os.homedir() for macOS paths, editor app detection/launch mismatch --- .github/VOUCHED.td | 27 + .github/pull_request_template.md | 33 + .github/workflows/pr-vouch.yml | 175 ++++ .github/workflows/release.yml | 47 +- CONTRIBUTING.md | 66 ++ README.md | 83 +- apps/desktop/src/main.ts | 24 + apps/server/scripts/cli.ts | 4 + .../Layers/CheckpointDiffQuery.test.ts | 4 +- .../Layers/CheckpointDiffQuery.ts | 6 +- apps/server/src/checkpointing/Utils.ts | 19 + apps/server/src/git/Layers/GitManager.test.ts | 137 ++- apps/server/src/git/Layers/GitManager.ts | 57 +- .../git/Layers/SessionTextGeneration.test.ts | 9 + .../src/git/Layers/SessionTextGeneration.ts | 427 +++++++++ .../src/git/Services/SessionTextGeneration.ts | 10 + .../server/src/git/Services/TextGeneration.ts | 8 +- apps/server/src/keybindings.ts | 1 + apps/server/src/main.test.ts | 63 +- apps/server/src/open.test.ts | 111 ++- apps/server/src/open.ts | 82 +- apps/server/src/opencodeServerManager.ts | 44 +- .../Layers/ProviderCommandReactor.ts | 83 +- .../Layers/ProviderRuntimeIngestion.test.ts | 257 ++++++ .../Layers/ProviderRuntimeIngestion.ts | 319 ++++++- apps/server/src/orchestration/decider.ts | 1 + .../provider/Layers/ClaudeCodeAdapter.test.ts | 62 +- .../src/provider/Layers/ClaudeCodeAdapter.ts | 138 +-- .../src/provider/Layers/CodexAdapter.ts | 15 +- .../src/provider/Layers/CopilotAdapter.ts | 695 +++++---------- .../src/provider/Layers/CursorAdapter.test.ts | 147 +++- .../src/provider/Layers/CursorAdapter.ts | 303 ++++++- .../src/provider/Layers/CursorUsage.test.ts | 71 ++ .../server/src/provider/Layers/CursorUsage.ts | 274 ++++++ .../src/provider/Layers/ProviderHealth.ts | 135 ++- .../src/provider/Layers/ProviderService.ts | 28 +- .../src/provider/Layers/copilotCliPath.ts | 35 +- .../Layers/copilotTurnTracking.test.ts | 59 ++ .../provider/Layers/copilotTurnTracking.ts | 74 ++ .../provider/Services/CursorAdapter.test.ts | 47 + .../src/provider/Services/CursorAdapter.ts | 15 +- .../server/src/provider/claude-agent-sdk.d.ts | 11 + apps/server/src/serverLayers.ts | 3 + apps/server/src/wsServer.test.ts | 4 +- apps/server/src/wsServer.ts | 25 + apps/web/index.html | 2 +- apps/web/package.json | 1 + apps/web/src/appSettings.ts | 5 + apps/web/src/components/ChatView.tsx | 810 +++++++++++++++--- apps/web/src/components/CommandPalette.tsx | 664 ++++++++++++++ apps/web/src/components/DiffPanel.tsx | 1 + .../components/GhosttyTerminalSplitView.tsx | 708 +++++++++++++++ .../GitActionsControl.logic.test.ts | 8 +- .../src/components/GitActionsControl.logic.ts | 6 +- apps/web/src/components/GitActionsControl.tsx | 17 +- apps/web/src/components/Icons.tsx | 45 + apps/web/src/components/PlanSidebar.tsx | 282 ++++++ .../src/components/ProjectScriptsControl.tsx | 46 +- apps/web/src/components/Sidebar.tsx | 526 ++++++++---- .../src/components/ThreadTerminalDrawer.tsx | 2 +- apps/web/src/components/ui/input.tsx | 3 + apps/web/src/composerDraftStore.ts | 64 +- .../src/hooks/useProjectThreadNavigation.ts | 130 +++ apps/web/src/index.css | 3 +- apps/web/src/keybindings.test.ts | 19 + apps/web/src/keybindings.ts | 8 + apps/web/src/lib/gitReactQuery.ts | 8 +- apps/web/src/proposedPlan.ts | 16 + apps/web/src/routes/__root.tsx | 25 +- apps/web/src/routes/_chat.settings.tsx | 7 +- apps/web/src/session-logic.test.ts | 97 +++ apps/web/src/session-logic.ts | 65 +- apps/web/src/store.test.ts | 97 ++- apps/web/src/store.ts | 72 +- apps/web/src/wsTransport.ts | 16 +- apps/web/vite.config.ts | 2 +- bun.lock | 11 +- package.json | 3 +- packages/contracts/src/editor.ts | 7 + packages/contracts/src/git.ts | 3 + packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/model.ts | 136 ++- packages/contracts/src/orchestration.ts | 70 ++ packages/contracts/src/provider.test.ts | 22 +- packages/contracts/src/provider.ts | 61 +- packages/contracts/src/server.ts | 30 +- packages/shared/src/model.test.ts | 47 +- packages/shared/src/model.ts | 170 +++- scripts/build-desktop-artifact.ts | 37 + scripts/dev-runner.ts | 2 + scripts/install.sh | 529 ++++++++++++ scripts/merge-mac-update-manifests.test.ts | 108 +++ scripts/merge-mac-update-manifests.ts | 287 +++++++ scripts/release-smoke.ts | 113 +++ scripts/update-release-package-versions.ts | 111 +++ update | 0 97 files changed, 8569 insertions(+), 1178 deletions(-) create mode 100644 .github/VOUCHED.td create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-vouch.yml create mode 100644 CONTRIBUTING.md create mode 100644 apps/server/src/git/Layers/SessionTextGeneration.test.ts create mode 100644 apps/server/src/git/Layers/SessionTextGeneration.ts create mode 100644 apps/server/src/git/Services/SessionTextGeneration.ts create mode 100644 apps/server/src/provider/Layers/CursorUsage.test.ts create mode 100644 apps/server/src/provider/Layers/CursorUsage.ts create mode 100644 apps/server/src/provider/Layers/copilotTurnTracking.test.ts create mode 100644 apps/server/src/provider/Layers/copilotTurnTracking.ts create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/components/GhosttyTerminalSplitView.tsx create mode 100644 apps/web/src/components/PlanSidebar.tsx create mode 100644 apps/web/src/hooks/useProjectThreadNavigation.ts create mode 100755 scripts/install.sh create mode 100644 scripts/merge-mac-update-manifests.test.ts create mode 100644 scripts/merge-mac-update-manifests.ts create mode 100644 scripts/release-smoke.ts create mode 100644 scripts/update-release-package-versions.ts create mode 100644 update diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 0000000000..cb9f78b92c --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,27 @@ +# Trust list for this repository. +# +# External contributors listed here are treated as trusted by the vouch +# workflow. Collaborators with write access are automatically trusted and +# do not need to be duplicated in this file. +# +# Syntax: +# github:username +# -github:username reason for denouncement +# +# Keep entries sorted alphabetically. +github:adityavardhansharma +github:binbandit +github:chuks-qua +github:cursoragent +github:gbarros-dev +github:github-actions[bot] +github:hwanseoc +github:jamesx0416 +github:jasonLaster +github:JoeEverest +github:nmggithub +github:notkainoa +github:PatrickBauer +github:realAhmedRoach +github:shiroyasha9 +github:Yash-Singh1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..76aac7e4d8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ + + +## What Changed + + + +## Why + + + +## UI Changes + + + +## Checklist + +- [ ] This PR is small and focused +- [ ] I explained what changed and why +- [ ] I included before/after screenshots for any UI changes +- [ ] I included a video for animation/interaction changes diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml new file mode 100644 index 0000000000..f38c93d44f --- /dev/null +++ b/.github/workflows/pr-vouch.yml @@ -0,0 +1,175 @@ +name: PR Vouch + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + issue_comment: + types: [created] + push: + branches: + - main + paths: + - .github/VOUCHED.td + - .github/workflows/pr-vouch.yml + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + collect-targets: + name: Collect PR targets + runs-on: ubuntu-24.04 + outputs: + targets: ${{ steps.collect.outputs.targets }} + steps: + - id: collect + uses: actions/github-script@v7 + with: + script: | + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); + return; + } + + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + const body = context.payload.comment?.body ?? ""; + if (!issue?.pull_request || !body.includes("/recheck-vouch")) { + core.setOutput("targets", "[]"); + return; + } + + core.setOutput( + "targets", + JSON.stringify([{ number: issue.number, user: issue.user.login }]), + ); + return; + } + + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }); + + const targets = pulls.map((pull) => ({ + number: pull.number, + user: pull.user.login, + })); + core.setOutput("targets", JSON.stringify(targets)); + + label: + name: Label PR ${{ matrix.target.number }} + needs: collect-targets + if: ${{ needs.collect-targets.outputs.targets != '[]' }} + runs-on: ubuntu-24.04 + concurrency: + group: pr-vouch-${{ matrix.target.number }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.collect-targets.outputs.targets) }} + steps: + - id: vouch + name: Check PR author trust + uses: mitchellh/vouch/action/check-user@v1 + with: + user: ${{ matrix.target.user }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Sync PR labels + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ matrix.target.number }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + with: + script: | + const issueNumber = Number(process.env.PR_NUMBER); + const status = process.env.VOUCH_STATUS; + const managedLabels = [ + { + name: "vouch:trusted", + color: "1f883d", + description: "PR author is trusted by repo permissions or the VOUCHED list.", + }, + { + name: "vouch:unvouched", + color: "fbca04", + description: "PR author is not yet trusted in the VOUCHED list.", + }, + { + name: "vouch:denounced", + color: "d1242f", + description: "PR author is explicitly blocked by the VOUCHED list.", + }, + ]; + + const managedLabelNames = managedLabels.map((label) => label.name); + + for (const label of managedLabels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + + if ( + existing.color !== label.color || + (existing.description ?? "") !== label.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } + + const nextLabelName = + status === "denounced" + ? "vouch:denounced" + : ["bot", "collaborator", "vouched"].includes(status) + ? "vouch:trusted" + : "vouch:unvouched"; + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + const preservedLabels = currentLabels + .map((label) => label.name) + .filter((name) => !managedLabelNames.includes(name)); + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [...preservedLabels, nextLabelName], + }); + + core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8d1410825..1524675138 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,6 +255,16 @@ jobs: needs: [preflight, build, publish_cli] runs-on: ubuntu-24.04 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: package.json + - name: Download all desktop artifacts uses: actions/download-artifact@v4 with: @@ -262,6 +272,13 @@ jobs: merge-multiple: true path: release-assets + - name: Merge macOS updater manifests + run: | + node scripts/merge-mac-update-manifests.ts \ + release-assets/latest-mac.yml \ + release-assets/latest-mac-x64.yml + rm -f release-assets/latest-mac-x64.yml + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -305,33 +322,7 @@ jobs: name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: | - node --input-type=module -e ' - import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; - - const files = [ - "apps/server/package.json", - "apps/desktop/package.json", - "apps/web/package.json", - "packages/contracts/package.json", - ]; - - let changed = false; - for (const file of files) { - const packageJson = JSON.parse(readFileSync(file, "utf8")); - if (packageJson.version !== process.env.RELEASE_VERSION) { - packageJson.version = process.env.RELEASE_VERSION; - writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`); - changed = true; - } - } - - if (!changed) { - console.log("All package.json versions already match release version."); - } - - appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); - ' + run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' @@ -339,7 +330,7 @@ jobs: - name: Refresh lockfile if: steps.update_versions.outputs.changed == 'true' - run: bun install + run: bun install --lockfile-only --ignore-scripts - name: Commit and push version bump if: steps.update_versions.outputs.changed == 'true' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..132f4026fe --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to T3 Code + +Thanks for your interest in contributing! This project is still early, so things move fast. + +## Prerequisites + +- [Bun](https://bun.sh) >= 1.3.9 +- [Node.js](https://nodejs.org) >= 24.13.1 +- At least one supported coding agent installed and authorized (see [README](README.md#supported-agents)) + +## Setup + +```bash +# This fork +git clone https://github.com/aaditagrawal/t3code.git + +# Or upstream +git clone https://github.com/pingdotgg/t3code.git + +cd t3code +bun install +``` + +## Development + +```bash +bun run dev # Start everything (server + web) +bun run dev:server # Server only +bun run dev:web # Web UI only +bun run dev:desktop # Desktop app +``` + +## Quality checks + +Both of these must pass before submitting a PR: + +```bash +bun lint # Lint with oxlint +bun typecheck # TypeScript type checking +``` + +## Testing + +```bash +bun run test # Run all tests (Vitest) +``` + +## Project structure + +| Package | Role | +| --- | --- | +| `apps/server` | Node.js WebSocket server. Wraps Codex app-server, serves the React web app, and manages provider sessions. | +| `apps/web` | React/Vite UI. Session UX, conversation/event rendering, and client-side state. | +| `packages/contracts` | Shared Effect/Schema schemas and TypeScript contracts. Schema-only — no runtime logic. | +| `packages/shared` | Shared runtime utilities. Uses explicit subpath exports (e.g. `@t3tools/shared/git`). | + +## Guidelines + +- **Performance and reliability first.** If a tradeoff is needed, choose correctness over convenience. +- **Avoid duplication.** Extract shared logic into `packages/shared` or `packages/contracts` rather than duplicating across files. +- **Keep it simple.** Don't over-engineer or add features beyond what's needed for the task. +- **Use ES modules.** `import/export`, not `require`. + +## Need help? + +Join the [Discord](https://discord.gg/jn4EGJjrvv) for support. diff --git a/README.md b/README.md index 9fccf5e9ce..e1894ec737 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,85 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents. It supports Codex, Claude Code, Cursor, Copilot, Gemini CLI, Amp, Kilo, and OpenCode. +T3 Code is a minimal web GUI for coding agents made by [Pingdotgg](https://github.com/pingdotgg). This project is a downstream fork of [T3 Code](https://github.com/pingdotgg/t3code) customised to my utility and includes various PRs/feature additions from the upstream repo. Thanks to the team and its maintainers for keeping it OSS and an upstream to look up to. + +It supports Codex, Claude Code, Cursor, Copilot, Gemini CLI, Amp, Kilo, and OpenCode. + +(NOTE: Amp /mode free is not supported, as Amp Code doesn't support it in headless mode - since they need to show ads for that business model to work.) + +## Why the fork? +This fork is designed to keep up a faster rate of development customised to my needs (and if you want, _yours_ as well -> Submit an issue and I'll make a PR for it). There's certain features which will (rightly) remain out of scope/priority for the project at its scale, but might be required for someone like me. + +### Multi-provider support +Adds full provider adapters (server managers, service layers, runtime layers) for agents that are not yet on the upstream roadmap: + +| Provider | What's included | +| --- | --- | +| Amp | Adapter + `ampServerManager` for headless Amp sessions | +| Copilot | Adapter + CLI binary resolution + text generation layer | +| Cursor | Adapter + ACP probe integration + usage tracking | +| Gemini CLI | Adapter + `geminiCliServerManager` with full test coverage | +| Kilo | Adapter + `kiloServerManager` + OpenCode-style server URL config | +| OpenCode | Adapter + `opencodeServerManager` with hostname/port/workspace config | +| Claude Code | Full adapter with permission mode, thinking token limits, and SDK typings | + +### UX enhancements + +| Feature | Description | +| --- | --- | +| Settings page | Dedicated route (`/settings`) for theme, accent color, and custom model slug configuration | +| Accent color system | Preset palette with contrast-safe terminal color injection across the entire UI | +| Theme support | Light / dark / system modes with transition suppression | +| Command palette | `Cmd+K` / `Ctrl+K` palette for quick actions, script running, and thread navigation | +| Sidebar search | Normalized thread title search with instant filtering | +| Plan sidebar | Dedicated panel for reviewing, downloading, or saving proposed agent plans | +| Terminal drawer | Theme-aware integrated terminal with accent color styling | + +### Branding & build +- Custom abstract-mark app icon with macOS icon composer support +- Centralized branding constants for easy identity swaps +- Desktop icon asset generation pipeline from SVG source + +### Developer tooling +- `sync-upstream-pr-tracks` script for tracking cherry-picked upstream PRs +- `cursor-acp-probe` for testing Cursor Agent Communication Protocol +- Custom alpha workflow playbook (`docs/custom-alpha-workflow.md`) +- Upstream PR tracking config (`config/upstream-pr-tracks.json`) ## Getting started -### CLI +### Quick install (recommended) + +Run the interactive installer — it detects your OS, checks prerequisites (git, Node.js ≥ 24, bun ≥ 1.3.9), installs missing tools, and lets you choose between development/production and desktop/web builds: + +```bash +# macOS / Linux / WSL +bash <(curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scripts/install.sh) +``` + +```powershell +# Windows (Git Bash, MSYS2, or WSL) +bash <(curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scripts/install.sh) +``` + +The installer supports **npm, yarn, pnpm, bun, and deno** detection, and will auto-install bun if no suitable package manager is found. It provides OS-specific install instructions for any missing prerequisites (Homebrew on macOS, apt/dnf/pacman on Linux, winget on Windows). + +### Manual build > [!WARNING] > You need at least one supported coding agent installed and authorized. See the supported agents list below. ```bash - npx t3 + # Prerequisites: Bun >=1.3.9, Node >=24.13.1 + git clone https://github.com/aaditagrawal/t3code.git + cd t3code + bun install + bun run dev ``` -### Desktop app - - You can also just install the desktop app. It's cooler. Install it from the [Releases page](https://github.com/pingdotgg/t3code/releases). - ## Supported agents - [Codex CLI](https://github.com/openai/codex) (requires v0.37.0 or later) - - [Claude Code](https://github.com/anthropics/claude-code) + - [Claude Code](https://github.com/anthropics/claude-code) — **not yet working in the desktop app** - [Cursor](https://cursor.sh) - [Copilot](https://github.com/features/copilot) - [Gemini CLI](https://github.com/google-gemini/gemini-cli) @@ -30,10 +89,6 @@ T3 Code is a minimal web GUI for coding agents. It supports Codex, Claude Code, ## Notes - - This project is very early in development. Expect bugs. - - We are not accepting contributions yet. + - This project is very early in development. Expect bugs. (Especially with my fork) + - Interested in contributing? See [CONTRIBUTING.md](CONTRIBUTING.md). - Maintaining a custom fork or alpha branch? See [docs/custom-alpha-workflow.md](docs/custom-alpha-workflow.md). - -## Need help? - - Join the [Discord](https://discord.gg/jn4EGJjrvv) if you need support. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 945f1d2790..04612ecc8e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1114,6 +1114,30 @@ function createWindow(): BrowserWindow { }, }); + window.webContents.on("context-menu", (event, params) => { + event.preventDefault(); + const menuTemplate: MenuItemConstructorOptions[] = []; + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { + menuTemplate.push({ + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion), + }); + } + if (params.dictionarySuggestions.length === 0) { + menuTemplate.push({ label: "No suggestions", enabled: false }); + } + menuTemplate.push({ type: "separator" }); + } + menuTemplate.push( + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + Menu.buildFromTemplate(menuTemplate).popup({ window }); + }); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.on("page-title-updated", (event) => { event.preventDefault(); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index ccf1f5a91a..5ac660081b 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -132,6 +132,8 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + shell: process.platform === "win32", })`bun tsdown`, ); @@ -225,6 +227,8 @@ const publishCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + // Windows needs shell mode to resolve .cmd shims (e.g. npm.cmd). + shell: process.platform === "win32", }), ); }), diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 2f79ea9d5a..4e3176adb0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -94,7 +94,7 @@ describe("CheckpointDiffQueryLive", () => { const snapshot = makeSnapshot({ projectId, threadId, - workspaceRoot: "/tmp/workspace", + workspaceRoot: process.cwd(), worktreePath: null, checkpointTurnCount: 1, checkpointRef: toCheckpointRef, @@ -141,7 +141,7 @@ describe("CheckpointDiffQueryLive", () => { expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); expect(diffCheckpointsCalls).toEqual([ { - cwd: "/tmp/workspace", + cwd: process.cwd(), fromCheckpointRef: expectedFromRef, toCheckpointRef, }, diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 5cf26c86da..5a853dcea2 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -8,7 +8,7 @@ import { Effect, Layer, Schema } from "effect"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; -import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts"; +import { checkpointRefForThreadTurn, resolveExistingThreadWorkspaceCwd } from "../Utils.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import { CheckpointDiffQuery, @@ -62,14 +62,14 @@ const make = Effect.gen(function* () { }); } - const workspaceCwd = resolveThreadWorkspaceCwd({ + const workspaceCwd = resolveExistingThreadWorkspaceCwd({ thread, projects: snapshot.projects, }); if (!workspaceCwd) { return yield* new CheckpointInvariantError({ operation, - detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, + detail: `Workspace path missing or unavailable for thread '${input.threadId}' when computing turn diff.`, }); } diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 3cd92f8510..1dea8823f8 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; + import { Encoding } from "effect"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; @@ -26,3 +28,20 @@ export function resolveThreadWorkspaceCwd(input: { return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; } + +export function resolveExistingThreadWorkspaceCwd(input: { + readonly thread: { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + }; + readonly projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly workspaceRoot: string; + }>; +}): string | undefined { + const resolvedCwd = resolveThreadWorkspaceCwd(input); + if (!resolvedCwd) { + return undefined; + } + return existsSync(resolvedCwd) ? resolvedCwd : undefined; +} diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index d8a3753bb0..7b706a9dd0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -13,7 +13,20 @@ import { type GitHubPullRequestSummary, GitHubCli, } from "../Services/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + type BranchNameGenerationInput, + type BranchNameGenerationResult, + type CommitMessageGenerationInput, + type CommitMessageGenerationResult, + type PrContentGenerationInput, + type PrContentGenerationResult, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; +import { + SessionTextGeneration, + type SessionTextGenerationShape, +} from "../Services/SessionTextGeneration.ts"; import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; @@ -27,28 +40,15 @@ interface FakeGhScenario { } interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - }) => Effect.Effect< - { subject: string; body: string; branch?: string | undefined }, - TextGenerationError - >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; - generateBranchName: (input: { - cwd: string; - message: string; - }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; } function makeTempDir( @@ -291,17 +291,49 @@ function runStackedAction( action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; featureBranch?: boolean; + provider?: "codex" | "copilot" | "claudeCode" | "cursor" | "opencode" | "geminiCli" | "amp" | "kilo"; + model?: string; }, ) { return manager.runStackedAction(input); } +function createSessionTextGeneration( + overrides: Partial = {}, +): SessionTextGenerationShape { + const implementation: FakeGitTextGeneration = { + generateCommitMessage: () => + Effect.succeed({ + subject: "Session: implement stacked git actions", + body: "", + }), + generatePrContent: () => + Effect.succeed({ + title: "Session: add stacked git actions", + body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", + }), + generateBranchName: () => + Effect.succeed({ + branch: "session-generated-branch", + }), + ...overrides, + }; + + return { + generateCommitMessage: implementation.generateCommitMessage, + generatePrContent: implementation.generatePrContent, + generateBranchName: implementation.generateBranchName, + }; +} + function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + sessionTextGeneration?: Partial; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); + const sessionTextGeneration = createSessionTextGeneration(input?.sessionTextGeneration); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), @@ -311,6 +343,7 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed(SessionTextGeneration, sessionTextGeneration), gitCoreLayer, NodeServices.layer, ); @@ -495,6 +528,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses session provider/model for generated git content when provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsession-model\n"); + + let defaultGenerationCount = 0; + let sessionGenerationCount = 0; + let receivedProvider: string | undefined; + let receivedModel: string | undefined; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.sync(() => { + defaultGenerationCount += 1; + return { + subject: "Default generator should not be used", + body: "", + }; + }), + }, + sessionTextGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + sessionGenerationCount += 1; + receivedProvider = input.provider; + receivedModel = input.model; + return { + subject: "Session generator commit subject", + body: "", + ...(input.includeBranch ? { branch: "feature/session-generator" } : {}), + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + provider: "cursor", + model: "opus-4.6-thinking", + }); + + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("Session generator commit subject"); + expect(defaultGenerationCount).toBe(0); + expect(sessionGenerationCount).toBe(1); + expect(receivedProvider).toBe("cursor"); + expect(receivedModel).toBe("opus-4.6-thinking"); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((commitResult) => commitResult.stdout.trim()), + ), + ).toBe("Session generator commit subject"); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index c4a29e15dd..dbf10be3db 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -3,11 +3,14 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Path } from "effect"; import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import type { ProviderKind } from "@t3tools/contracts"; + import { GitManagerError } from "../Errors.ts"; import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { SessionTextGeneration } from "../Services/SessionTextGeneration.ts"; interface OpenPrInfo { number: number; @@ -179,9 +182,20 @@ export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const sessionTextGeneration = yield* SessionTextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const selectTextGeneration = ( + provider: ProviderKind | undefined, + model: string | undefined, + ): TextGenerationShape => { + if (provider !== undefined || model !== undefined) { + return sessionTextGeneration; + } + return textGeneration; + }; + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const findOpenPr = (cwd: string, branch: string) => @@ -281,6 +295,10 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string; /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; + /** Provider to use for text generation. */ + provider?: ProviderKind | undefined; + /** Provider model to use for text generation. */ + model?: string | undefined; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd); @@ -300,12 +318,15 @@ export const makeGitManager = Effect.gen(function* () { }; } - const generated = yield* textGeneration + const textGen = selectTextGeneration(input.provider, input.model); + const generated = yield* textGen .generateCommitMessage({ cwd: input.cwd, branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), ...(input.includeBranch ? { includeBranch: true } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -323,6 +344,8 @@ export const makeGitManager = Effect.gen(function* () { branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, + provider?: ProviderKind | undefined, + model?: string | undefined, ) => Effect.gen(function* () { const suggestion = @@ -331,6 +354,8 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + provider, + model, })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -344,7 +369,12 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null) => + const runPrStep = ( + cwd: string, + fallbackBranch: string | null, + provider?: ProviderKind | undefined, + model?: string | undefined, + ) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -376,13 +406,16 @@ export const makeGitManager = Effect.gen(function* () { const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); - const generated = yield* textGeneration.generatePrContent({ + const textGen = selectTextGeneration(provider, model); + const generated = yield* textGen.generatePrContent({ cwd, baseBranch, headBranch: branch, commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -445,13 +478,21 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => + const runFeatureBranchStep = ( + cwd: string, + branch: string | null, + commitMessage?: string, + provider?: ProviderKind | undefined, + model?: string | undefined, + ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), includeBranch: true, + provider, + model, }); if (!suggestion) { return yield* gitManagerError( @@ -499,6 +540,8 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, initialStatus.branch, input.commitMessage, + input.provider, + input.model, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -514,6 +557,8 @@ export const makeGitManager = Effect.gen(function* () { currentBranch, commitMessageForStep, preResolvedCommitSuggestion, + input.provider, + input.model, ); const push = wantsPush @@ -521,7 +566,7 @@ export const makeGitManager = Effect.gen(function* () { : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, currentBranch) + ? yield* runPrStep(input.cwd, currentBranch, input.provider, input.model) : { status: "skipped_not_requested" as const }; return { diff --git a/apps/server/src/git/Layers/SessionTextGeneration.test.ts b/apps/server/src/git/Layers/SessionTextGeneration.test.ts new file mode 100644 index 0000000000..b5dd292448 --- /dev/null +++ b/apps/server/src/git/Layers/SessionTextGeneration.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { SessionTextGenerationLive } from "./SessionTextGeneration.ts"; + +describe("SessionTextGeneration", () => { + it("exports a valid Layer", () => { + expect(SessionTextGenerationLive).toBeDefined(); + }); +}); diff --git a/apps/server/src/git/Layers/SessionTextGeneration.ts b/apps/server/src/git/Layers/SessionTextGeneration.ts new file mode 100644 index 0000000000..d621571ce7 --- /dev/null +++ b/apps/server/src/git/Layers/SessionTextGeneration.ts @@ -0,0 +1,427 @@ +import { randomUUID } from "node:crypto"; + +import type { ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Option, Queue, Schema, SchemaIssue, Stream } from "effect"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveModelSlugForProvider } from "@t3tools/shared/model"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { TextGenerationError } from "../Errors.ts"; +import { + type BranchNameGenerationInput, + type BranchNameGenerationResult, + type CommitMessageGenerationResult, + type PrContentGenerationResult, +} from "../Services/TextGeneration.ts"; +import { + SessionTextGeneration, + type SessionTextGenerationShape, +} from "../Services/SessionTextGeneration.ts"; + +const PROVIDER_TEXT_GENERATION_TIMEOUT_MS = 180_000; + +function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + return singleLine.length > 0 ? singleLine : "Update project changes"; +} + +function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("```")) { + const fenced = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, ""); + return fenced.trim(); + } + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start !== -1 && end !== -1 && end > start) { + return trimmed.slice(start, end + 1); + } + return trimmed; +} + +function toThreadId(value: string): ThreadId { + return value as ThreadId; +} + +function normalizeProviderTextGenerationError( + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} + +function decodeJsonResponse( + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + raw: string, + schema: S, +): Effect.Effect { + return Effect.gen(function* () { + const jsonText = extractJsonObject(raw); + if (jsonText.length === 0) { + return yield* new TextGenerationError({ + operation, + detail: "Provider returned an empty response.", + }); + } + + const parsed = yield* Effect.try({ + try: () => JSON.parse(jsonText) as unknown, + catch: (cause) => + normalizeProviderTextGenerationError( + operation, + cause, + "Provider returned invalid JSON", + ), + }); + + return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Provider returned an unexpected payload: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + }); +} + +function assistantMessageFromEvent(event: ProviderRuntimeEvent): string | null { + if ( + event.type !== "item.completed" || + event.payload.itemType !== "assistant_message" || + typeof event.payload.detail !== "string" + ) { + return null; + } + const trimmed = event.payload.detail.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +const makeSessionTextGeneration = Effect.gen(function* () { + const providerService = yield* ProviderService; + + const runProviderJson = ({ + operation, + cwd, + provider, + model, + prompt, + attachments, + schema, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + provider: BranchNameGenerationInput["provider"]; + model: BranchNameGenerationInput["model"]; + prompt: string; + attachments?: BranchNameGenerationInput["attachments"]; + schema: S; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedProvider = provider ?? "codex"; + const resolvedModel = resolveModelSlugForProvider(resolvedProvider, model); + const threadId = toThreadId(`git-textgen-${operation}-${randomUUID()}`); + const eventQueue = yield* Queue.unbounded(); + + yield* Stream.runForEach(providerService.streamEvents, (event) => { + if (event.threadId !== threadId) { + return Effect.void; + } + return Queue.offer(eventQueue, event).pipe(Effect.asVoid); + }).pipe(Effect.forkScoped); + + const cleanup = providerService + .stopSession({ threadId }) + .pipe( + Effect.tapError((e) => + Effect.logWarning("Failed to stop text generation session", e), + ), + Effect.orElseSucceed(() => undefined), + Effect.asVoid, + ); + + return yield* Effect.gen(function* () { + yield* providerService.startSession(threadId, { + threadId, + provider: resolvedProvider, + cwd, + model: resolvedModel, + runtimeMode: "approval-required", + }); + + const turn = yield* providerService.sendTurn({ + threadId, + input: prompt, + model: resolvedModel, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + interactionMode: "default", + }); + + let assistantText = ""; + let fallbackAssistantMessage: string | null = null; + + while (true) { + const event = yield* Queue.take(eventQueue); + if (event.turnId !== undefined && event.turnId !== turn.turnId) { + continue; + } + + if ( + event.type === "content.delta" && + event.payload.streamKind === "assistant_text" + ) { + assistantText += event.payload.delta; + continue; + } + + const assistantMessage = assistantMessageFromEvent(event); + if (assistantMessage && fallbackAssistantMessage === null) { + fallbackAssistantMessage = assistantMessage; + continue; + } + + if (event.type === "request.opened") { + return yield* new TextGenerationError({ + operation, + detail: `The ${resolvedProvider} provider requested '${event.payload.requestType}' while generating git text. Git text generation must run without tools or approvals.`, + }); + } + + if (event.type === "user-input.requested") { + return yield* new TextGenerationError({ + operation, + detail: `The ${resolvedProvider} provider requested interactive input while generating git text.`, + }); + } + + if (event.type === "runtime.error") { + return yield* new TextGenerationError({ + operation, + detail: `${resolvedProvider} provider runtime error: ${event.payload.message}`, + }); + } + + if (event.type === "turn.completed") { + if (event.payload.state !== "completed") { + return yield* new TextGenerationError({ + operation, + detail: + event.payload.errorMessage ?? + `${resolvedProvider} provider turn ended with state '${event.payload.state}'.`, + }); + } + + const responseText = assistantText.trim() || fallbackAssistantMessage?.trim() || ""; + return yield* decodeJsonResponse(operation, responseText, schema); + } + } + }).pipe( + Effect.timeoutOption(PROVIDER_TEXT_GENERATION_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: `${resolvedProvider} provider request timed out.`, + }), + ), + onSome: (result) => Effect.succeed(result), + }), + ), + Effect.ensuring(cleanup), + Effect.scoped, + ); + }).pipe( + Effect.mapError((cause) => + normalizeProviderTextGenerationError( + operation, + cause, + "Provider git text generation failed", + ), + ), + ); + + const generateCommitMessage: SessionTextGenerationShape["generateCommitMessage"] = (input) => { + const wantsBranch = input.includeBranch === true; + const prompt = [ + "You write concise git commit messages.", + "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", + wantsBranch + ? 'Return a JSON object with keys: "subject", "body", "branch".' + : 'Return a JSON object with keys: "subject", "body".', + "Rules:", + "- subject must be imperative, <= 72 chars, and have no trailing period", + "- body can be an empty string or short bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + const schema = wantsBranch + ? Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }) + : Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }); + + return runProviderJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + provider: input.provider, + model: input.model, + prompt, + schema, + }).pipe( + Effect.map( + (generated) => + ({ + subject: generated.subject, + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }) satisfies CommitMessageGenerationResult, + ), + ); + }; + + const generatePrContent: SessionTextGenerationShape["generatePrContent"] = (input) => { + const prompt = [ + "You write GitHub pull request content.", + "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", + 'Return a JSON object with keys: "title", "body".', + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + return runProviderJson({ + operation: "generatePrContent", + cwd: input.cwd, + provider: input.provider, + model: input.model, + prompt, + schema: Schema.Struct({ + title: Schema.String, + body: Schema.String, + }), + }).pipe( + Effect.map( + (generated) => + ({ + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }) satisfies PrContentGenerationResult, + ), + ); + }; + + const generateBranchName: SessionTextGenerationShape["generateBranchName"] = (input) => { + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + const promptSections = [ + "You generate concise git branch names.", + "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", + 'Return a JSON object with key: "branch".', + "Rules:", + "- Branch should describe the requested work from the user message.", + "- Keep it short and specific (2-6 words).", + "- Use plain words only, no issue prefixes and no punctuation-heavy text.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + return runProviderJson({ + operation: "generateBranchName", + cwd: input.cwd, + provider: input.provider, + model: input.model, + prompt: promptSections.join("\n"), + attachments: input.attachments, + schema: Schema.Struct({ + branch: Schema.String, + }), + }).pipe( + Effect.map( + (generated) => + ({ + branch: sanitizeBranchFragment(generated.branch), + }) satisfies BranchNameGenerationResult, + ), + ); + }; + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + } satisfies SessionTextGenerationShape; +}); + +export const SessionTextGenerationLive = Layer.effect( + SessionTextGeneration, + makeSessionTextGeneration, +); diff --git a/apps/server/src/git/Services/SessionTextGeneration.ts b/apps/server/src/git/Services/SessionTextGeneration.ts new file mode 100644 index 0000000000..cc2e6ce962 --- /dev/null +++ b/apps/server/src/git/Services/SessionTextGeneration.ts @@ -0,0 +1,10 @@ +import { ServiceMap } from "effect"; + +import type { TextGenerationShape } from "./TextGeneration.ts"; + +export interface SessionTextGenerationShape extends TextGenerationShape {} + +export class SessionTextGeneration extends ServiceMap.Service< + SessionTextGeneration, + SessionTextGenerationShape +>()("t3/git/Services/SessionTextGeneration") {} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe66..22942ff79a 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,7 +8,7 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { ChatAttachment, ProviderKind } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; @@ -17,6 +17,8 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + provider?: ProviderKind | undefined; + model?: string | undefined; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; } @@ -35,6 +37,8 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + provider?: ProviderKind | undefined; + model?: string | undefined; } export interface PrContentGenerationResult { @@ -45,6 +49,8 @@ export interface PrContentGenerationResult { export interface BranchNameGenerationInput { cwd: string; message: string; + provider?: ProviderKind | undefined; + model?: string | undefined; attachments?: ReadonlyArray | undefined; } diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 28fa757c2c..ca118c7a34 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -61,6 +61,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 83976e3d4c..c13a9ba80e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -84,36 +84,39 @@ beforeEach(() => { }); it.layer(testLayer)("server CLI command", (it) => { - it.effect("parses all CLI flags and wires scoped start/stop", () => - Effect.gen(function* () { - yield* runCli([ - "--mode", - "desktop", - "--port", - "4010", - "--host", - "0.0.0.0", - "--state-dir", - "/tmp/t3-cli-state", - "--dev-url", - "http://127.0.0.1:5173", - "--no-browser", - "--auth-token", - "auth-secret", - ]); - - assert.equal(start.mock.calls.length, 1); - assert.equal(resolvedConfig?.mode, "desktop"); - assert.equal(resolvedConfig?.port, 4010); - assert.equal(resolvedConfig?.host, "0.0.0.0"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); - assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); - assert.equal(resolvedConfig?.noBrowser, true); - assert.equal(resolvedConfig?.authToken, "auth-secret"); - assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); - assert.equal(resolvedConfig?.logWebSocketEvents, true); - assert.equal(stop.mock.calls.length, 1); - }), + it.effect( + "parses all CLI flags and wires scoped start/stop", + () => + Effect.gen(function* () { + yield* runCli([ + "--mode", + "desktop", + "--port", + "4010", + "--host", + "0.0.0.0", + "--state-dir", + "/tmp/t3-cli-state", + "--dev-url", + "http://127.0.0.1:5173", + "--no-browser", + "--auth-token", + "auth-secret", + ]); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); + assert.equal(resolvedConfig?.port, 4010); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "auth-secret"); + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); + assert.equal(resolvedConfig?.logWebSocketEvents, true); + assert.equal(stop.mock.calls.length, 1); + }), + 15_000, ); it.effect("supports --token as an alias for --auth-token", () => diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 0f864554e9..dfa26938f8 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -15,10 +15,12 @@ import { assertSuccess } from "@effect/vitest/utils"; describe("resolveEditorLaunch", () => { it.effect("returns commands for command-based editors", () => + // Use "linux" to avoid macOS .app fallback logic, which depends on + // whether the .app bundle happens to be installed on the test host. Effect.gen(function* () { const cursorLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", + "linux", ); assert.deepEqual(cursorLaunch, { command: "cursor", @@ -27,7 +29,7 @@ describe("resolveEditorLaunch", () => { const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", + "linux", ); assert.deepEqual(vscodeLaunch, { command: "code", @@ -36,20 +38,97 @@ describe("resolveEditorLaunch", () => { const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", + "linux", ); assert.deepEqual(zedLaunch, { command: "zed", args: ["/tmp/workspace"], }); + + const windsurfLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "windsurf" }, + "linux", + ); + assert.deepEqual(windsurfLaunch, { + command: "windsurf", + args: ["/tmp/workspace"], + }); + + const sublimeLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "sublime" }, + "linux", + ); + assert.deepEqual(sublimeLaunch, { + command: "subl", + args: ["/tmp/workspace"], + }); + + const webstormLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "webstorm" }, + "linux", + ); + assert.deepEqual(webstormLaunch, { + command: "webstorm", + args: ["/tmp/workspace"], + }); + + const intellijLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "intellij" }, + "linux", + ); + assert.deepEqual(intellijLaunch, { + command: "idea", + args: ["/tmp/workspace"], + }); + + const fleetLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "fleet" }, + "linux", + ); + assert.deepEqual(fleetLaunch, { + command: "fleet", + args: ["/tmp/workspace"], + }); + + const positronLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "positron" }, + "linux", + ); + assert.deepEqual(positronLaunch, { + command: "positron", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("uses open -a on macOS for terminal editors like Ghostty", () => + Effect.gen(function* () { + const ghosttyMac = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "ghostty" }, + "darwin", + ); + assert.deepEqual(ghosttyMac, { + command: "open", + args: ["-a", "Ghostty", "--args", "--working-directory=/tmp/workspace"], + }); + + const ghosttyLinux = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "ghostty" }, + "linux", + ); + assert.deepEqual(ghosttyLinux, { + command: "ghostty", + args: ["--working-directory=/tmp/workspace"], + }); }), ); it.effect("uses --goto when editor supports line/column suffixes", () => + // Use "linux" to avoid macOS .app fallback logic for deterministic results. Effect.gen(function* () { const lineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", + "linux", ); assert.deepEqual(lineOnly, { command: "cursor", @@ -58,7 +137,7 @@ describe("resolveEditorLaunch", () => { const lineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, - "darwin", + "linux", ); assert.deepEqual(lineAndColumn, { command: "cursor", @@ -67,7 +146,7 @@ describe("resolveEditorLaunch", () => { const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, - "darwin", + "linux", ); assert.deepEqual(vscodeLineAndColumn, { command: "code", @@ -76,12 +155,30 @@ describe("resolveEditorLaunch", () => { const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, - "darwin", + "linux", ); assert.deepEqual(zedLineAndColumn, { command: "zed", args: ["/tmp/workspace/src/open.ts:71:5"], }); + + const windsurfLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "windsurf" }, + "linux", + ); + assert.deepEqual(windsurfLineAndColumn, { + command: "windsurf", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + + const positronLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "positron" }, + "linux", + ); + assert.deepEqual(positronLineAndColumn, { + command: "positron", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); }), ); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 5c742fba9d..e6d5a9260c 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,7 +7,8 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; +import { accessSync, constants, existsSync, statSync } from "node:fs"; +import os from "node:os"; import { extname, join } from "node:path"; import { EDITORS, type EditorId } from "@t3tools/contracts"; @@ -39,8 +40,39 @@ interface CommandAvailabilityOptions { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +/** Editors that accept `--goto file:line:column` for jump-to-line support. */ +const GOTO_FLAG_EDITORS = new Set(["cursor", "windsurf", "vscode", "positron"]); + function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target); + return GOTO_FLAG_EDITORS.has(editorId) && LINE_COLUMN_SUFFIX_PATTERN.test(target); +} + +/** Editors that are terminals requiring --working-directory instead of a positional path arg. */ +const WORKING_DIRECTORY_EDITORS = new Set(["ghostty"]); + +/** + * Map of editor IDs to their macOS application names. + * Used both for `open -a ` launching and for detecting availability + * when the CLI tool isn't in PATH but the `.app` bundle is installed. + */ +const MAC_APP_NAMES: Partial> = { + cursor: "Cursor", + windsurf: "Windsurf", + vscode: "Visual Studio Code", + zed: "Zed", + positron: "Positron", + sublime: "Sublime Text", + webstorm: "WebStorm", + intellij: "IntelliJ IDEA", + fleet: "Fleet", + ghostty: "Ghostty", +}; + +function isMacAppInstalled(appName: string): boolean { + return ( + existsSync(`/Applications/${appName}.app`) || + existsSync(`${os.homedir()}/Applications/${appName}.app`) + ); } function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { @@ -169,6 +201,15 @@ export function resolveAvailableEditors( const command = editor.command ?? fileManagerCommandForPlatform(platform); if (isCommandAvailable(command, { platform, env })) { available.push(editor.id); + continue; + } + // On macOS, also check for installed .app bundles when the CLI tool is + // not in PATH (e.g. Ghostty installed via DMG without shell integration). + if (platform === "darwin") { + const macApp = MAC_APP_NAMES[editor.id]; + if (macApp && isMacAppInstalled(macApp)) { + available.push(editor.id); + } } } @@ -211,9 +252,40 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (editorDef.command) { - return shouldUseGotoFlag(editorDef.id, input.cwd) - ? { command: editorDef.command, args: ["--goto", input.cwd] } - : { command: editorDef.command, args: [input.cwd] }; + if (shouldUseGotoFlag(editorDef.id, input.cwd)) { + if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { + const macApp = MAC_APP_NAMES[editorDef.id]; + if (macApp && isMacAppInstalled(macApp)) { + return { command: "open", args: ["-a", macApp, "--args", "--goto", input.cwd] }; + } + } + return { command: editorDef.command, args: ["--goto", input.cwd] }; + } + if (WORKING_DIRECTORY_EDITORS.has(editorDef.id)) { + // On macOS, use `open -a ` so the running .app instance receives + // the new-window request properly (the bare CLI spawned detached often + // fails to communicate with the single-instance app). + if (platform === "darwin") { + const macApp = MAC_APP_NAMES[editorDef.id]; + if (macApp) { + return { + command: "open", + args: ["-a", macApp, "--args", `--working-directory=${input.cwd}`], + }; + } + } + return { command: editorDef.command, args: [`--working-directory=${input.cwd}`] }; + } + // On macOS, fall back to `open -a ` when the CLI tool is not in + // PATH but the .app bundle is installed (e.g. app installed via DMG + // without shell integration). + if (platform === "darwin" && !isCommandAvailable(editorDef.command)) { + const macApp = MAC_APP_NAMES[editorDef.id]; + if (macApp && isMacAppInstalled(macApp)) { + return { command: "open", args: ["-a", macApp, input.cwd] }; + } + } + return { command: editorDef.command, args: [input.cwd] }; } if (editorDef.id !== "file-manager") { diff --git a/apps/server/src/opencodeServerManager.ts b/apps/server/src/opencodeServerManager.ts index 37276d42c3..a613bad443 100644 --- a/apps/server/src/opencodeServerManager.ts +++ b/apps/server/src/opencodeServerManager.ts @@ -1022,12 +1022,44 @@ export class OpenCodeServerManager extends EventEmitter { async interruptTurn(threadId: ThreadId): Promise { const context = this.requireSession(threadId); - await readJsonData( - context.client.session.abort({ - sessionID: context.providerSessionId, - ...(context.workspace ? { workspace: context.workspace } : {}), - }), - ); + try { + await readJsonData( + context.client.session.abort({ + sessionID: context.providerSessionId, + ...(context.workspace ? { workspace: context.workspace } : {}), + }), + ); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "OpenCode session abort failed"; + this.emitRuntimeEvent({ + type: "runtime.error", + eventId: eventId("opencode-interrupt-error"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}), + payload: { + message, + class: "transport_error", + }, + }); + // Still clean up local state even if the abort RPC failed so the UI + // does not stay stuck in a "running" state. + } + const interruptedTurnId = context.activeTurnId; + if (interruptedTurnId) { + this.emitRuntimeEvent({ + type: "turn.completed", + eventId: eventId("opencode-turn-interrupted"), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + turnId: interruptedTurnId, + payload: { + state: "interrupted", + }, + }); + } context.activeTurnId = undefined; context.session = { ...stripTransientSessionFields(context.session), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0cc771a0ff..cc4ad56a11 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,8 +4,9 @@ import { EventId, type OrchestrationEvent, type ProviderModelOptions, - type ProviderKind, + ProviderKind, type ProviderServiceTier, + type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -29,6 +30,7 @@ type ProviderIntentEvent = Extract< OrchestrationEvent, { type: + | "thread.deleted" | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" @@ -137,6 +139,8 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(true), }); + const threadProviderOptions = new Map(); + const hasHandledTurnStartRecently = (key: string) => Cache.getOption(handledTurnStartKeys, key).pipe( Effect.flatMap((cached) => @@ -203,6 +207,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly modelOptions?: ProviderModelOptions; readonly serviceTier?: ProviderServiceTier | null; + readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -212,15 +217,8 @@ const make = Effect.gen(function* () { } const desiredRuntimeMode = thread.runtimeMode; - const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" || - thread.session?.providerName === "copilot" || - thread.session?.providerName === "claudeCode" || - thread.session?.providerName === "cursor" || - thread.session?.providerName === "opencode" || - thread.session?.providerName === "geminiCli" || - thread.session?.providerName === "amp" || - thread.session?.providerName === "kilo" + const currentProvider = + thread.session?.providerName && Schema.is(ProviderKind)(thread.session.providerName) ? thread.session.providerName : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; @@ -235,6 +233,12 @@ const make = Effect.gen(function* () { Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)), ); + const effectiveProviderOptions = + options?.providerOptions ?? threadProviderOptions.get(threadId); + if (options?.providerOptions !== undefined) { + threadProviderOptions.set(threadId, options.providerOptions); + } + const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderKind; @@ -248,6 +252,7 @@ const make = Effect.gen(function* () { ...(desiredModel ? { model: desiredModel } : {}), ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(effectiveProviderOptions !== undefined ? { providerOptions: effectiveProviderOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -334,6 +339,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -346,6 +352,7 @@ const make = Effect.gen(function* () { ...(input.model !== undefined ? { model: input.model } : {}), ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -481,6 +488,7 @@ const make = Effect.gen(function* () { ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); @@ -506,7 +514,26 @@ const make = Effect.gen(function* () { } // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + yield* providerService + .interruptTurn({ threadId: event.payload.threadId }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + const error = Cause.squash(cause); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail: toErrorMessage(error), + turnId: event.payload.turnId ?? null, + createdAt: event.payload.createdAt, + }); + }), + ), + ); }); const processApprovalResponseRequested = Effect.fnUntraced(function* ( @@ -538,6 +565,9 @@ const make = Effect.gen(function* () { .pipe( Effect.catchCause((cause) => Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } const error = Cause.squash(cause); const detail = toErrorMessage(error); yield* appendProviderFailureActivity({ @@ -585,6 +615,9 @@ const make = Effect.gen(function* () { .pipe( Effect.catchCause((cause) => Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } const error = Cause.squash(cause); yield* appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -610,9 +643,30 @@ const make = Effect.gen(function* () { const now = event.payload.createdAt; if (thread.session && thread.session.status !== "stopped") { - yield* providerService.stopSession({ threadId: thread.id }); + yield* providerService + .stopSession({ threadId: thread.id }) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + const error = Cause.squash(cause); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.session.stop.failed", + summary: "Provider session stop failed", + detail: toErrorMessage(error), + turnId: null, + createdAt: event.payload.createdAt, + }); + }), + ), + ); } + threadProviderOptions.delete(thread.id); + yield* setThreadSession({ threadId: thread.id, session: { @@ -631,6 +685,10 @@ const make = Effect.gen(function* () { const processDomainEvent = (event: ProviderIntentEvent) => Effect.gen(function* () { switch (event.type) { + case "thread.deleted": { + threadProviderOptions.delete(event.payload.threadId); + return; + } case "thread.runtime-mode-set": { const thread = yield* resolveThread(event.payload.threadId); if (!thread?.session || thread.session.status === "stopped") { @@ -681,6 +739,7 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( + event.type !== "thread.deleted" && event.type !== "thread.runtime-mode-set" && event.type !== "thread.turn-start-requested" && event.type !== "thread.turn-interrupt-requested" && diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d7d423e076..9c9ceb4ece 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -910,6 +910,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "assistant_message", status: "completed", + detail: "hello live", }, }); @@ -926,6 +927,262 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("completes streaming assistant messages even when read model lookup lags completion", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-streaming-lag"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("message-streaming-lag"), + role: "user", + text: "stream with lag", + attachments: [], + }, + assistantDeliveryMode: "streaming", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await Effect.runPromise(Effect.sleep("30 millis")); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-streaming-lag"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-lag"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-streaming-lag", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-streaming-lag"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-lag"), + itemId: asItemId("item-streaming-lag"), + payload: { + streamKind: "assistant_text", + delta: "hello lagged", + }, + }); + + const liveThread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-lag" && + message.streaming && + message.text === "hello lagged", + ), + ); + const liveMessage = liveThread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-lag", + ); + expect(liveMessage?.streaming).toBe(true); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-streaming-lag"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-streaming-lag"), + itemId: asItemId("item-streaming-lag"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const finalThread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-streaming-lag" && !message.streaming, + ), + ); + const finalMessage = finalThread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-lag", + ); + expect(finalMessage?.text).toBe("hello lagged"); + expect(finalMessage?.streaming).toBe(false); + }); + + it("splits streaming assistant text into separate messages around tool activity", async () => { + const harness = await createHarness(); + const turnStartedAt = "2026-03-09T10:00:00.000Z"; + const beforeToolAt = "2026-03-09T10:00:01.000Z"; + const toolAt = "2026-03-09T10:00:02.000Z"; + const afterToolAt = "2026-03-09T10:00:03.000Z"; + const completedAt = "2026-03-09T10:00:04.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-interleaved-streaming"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("message-interleaved-streaming"), + role: "user", + text: "show interleaving", + attachments: [], + }, + assistantDeliveryMode: "streaming", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: turnStartedAt, + }), + ); + await Effect.runPromise(Effect.sleep("30 millis")); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-interleaved-streaming"), + provider: "codex", + createdAt: turnStartedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-interleaved-streaming"), + }); + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-interleaved-streaming", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-before-tool"), + provider: "codex", + createdAt: beforeToolAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-interleaved-streaming"), + itemId: asItemId("item-interleaved-streaming"), + payload: { + streamKind: "assistant_text", + delta: "Before tool.", + }, + }); + await waitForThread( + harness.engine, + (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-interleaved-streaming" && + message.streaming && + message.text === "Before tool.", + ), + ); + + harness.emit({ + type: "item.updated", + eventId: asEventId("evt-tool-updated-interleaved-streaming"), + provider: "codex", + createdAt: toolAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-interleaved-streaming"), + itemId: asItemId("tool-interleaved-streaming"), + payload: { + itemType: "command_execution", + status: "in_progress", + title: "Run command", + detail: "pwd", + }, + }); + await waitForThread( + harness.engine, + (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-interleaved-streaming" && + !message.streaming && + message.text === "Before tool.", + ) && + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-tool-updated-interleaved-streaming" && + activity.kind === "tool.updated", + ), + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-after-tool"), + provider: "codex", + createdAt: afterToolAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-interleaved-streaming"), + itemId: asItemId("item-interleaved-streaming"), + payload: { + streamKind: "assistant_text", + delta: "After tool.", + }, + }); + await waitForThread( + harness.engine, + (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-interleaved-streaming:segment:1" && + message.streaming && + message.text === "After tool.", + ), + ); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-interleaved-streaming"), + provider: "codex", + createdAt: completedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-interleaved-streaming"), + itemId: asItemId("item-interleaved-streaming"), + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-interleaved-streaming:segment:1" && !message.streaming, + ), + ); + expect( + thread.messages.map((message: ProviderRuntimeTestMessage) => ({ + id: message.id, + text: message.text, + streaming: message.streaming, + })), + ).toEqual( + expect.arrayContaining([ + { + id: "assistant:item-interleaved-streaming", + text: "Before tool.", + streaming: false, + }, + { + id: "assistant:item-interleaved-streaming:segment:1", + text: "After tool.", + streaming: false, + }, + ]), + ); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index cf3e319bad..c9c4c3658c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -50,6 +50,20 @@ type RuntimeIngestionInput = event: TurnStartRequestedDomainEvent; }; +type AssistantSegmentState = { + baseMessageId: MessageId; + currentSegmentIndex: number | null; + nextSegmentIndex: number; +}; + +const assistantSegmentStateKey = (threadId: ThreadId, baseMessageId: MessageId) => + `${threadId}:${baseMessageId}`; + +const assistantSegmentMessageId = (baseMessageId: MessageId, segmentIndex: number): MessageId => + segmentIndex === 0 + ? baseMessageId + : MessageId.makeUnsafe(`${baseMessageId}:segment:${segmentIndex}`); + function toTurnId(value: TurnId | string | undefined): TurnId | undefined { return value === undefined ? undefined : TurnId.makeUnsafe(String(value)); } @@ -506,12 +520,21 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const assistantMessageSawDeltaByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(false), + }); + const bufferedProposedPlanById = yield* Cache.make({ capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY, timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL, lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const assistantSegmentStateByKey = new Map(); + const assistantSegmentKeysByTurnKey = new Map>(); + const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); @@ -621,6 +644,18 @@ const make = Effect.gen(function* () { const clearBufferedAssistantText = (messageId: MessageId) => Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + const markAssistantMessageSawDelta = (messageId: MessageId) => + Cache.set(assistantMessageSawDeltaByMessageId, messageId, true); + + const takeAssistantMessageSawDelta = (messageId: MessageId) => + Cache.getOption(assistantMessageSawDeltaByMessageId, messageId).pipe( + Effect.flatMap((existing) => + Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId).pipe( + Effect.as(Option.getOrElse(existing, () => false)), + ), + ), + ); + const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( Effect.flatMap((existingEntry) => { @@ -644,7 +679,159 @@ const make = Effect.gen(function* () { const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); - const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); + const rememberAssistantSegmentKeyForTurn = ( + threadId: ThreadId, + turnId: TurnId, + stateKey: string, + ): void => { + const turnKey = providerTurnKey(threadId, turnId); + const existing = assistantSegmentKeysByTurnKey.get(turnKey); + if (existing) { + existing.add(stateKey); + return; + } + assistantSegmentKeysByTurnKey.set(turnKey, new Set([stateKey])); + }; + + const clearAssistantSegmentsForTurn = (threadId: ThreadId, turnId: TurnId): void => { + const turnKey = providerTurnKey(threadId, turnId); + const stateKeys = assistantSegmentKeysByTurnKey.get(turnKey); + if (!stateKeys) { + return; + } + for (const stateKey of stateKeys) { + assistantSegmentStateByKey.delete(stateKey); + } + assistantSegmentKeysByTurnKey.delete(turnKey); + }; + + const clearAssistantSegment = (input: { + threadId: ThreadId; + baseMessageId: MessageId; + turnId?: TurnId; + }): void => { + const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); + assistantSegmentStateByKey.delete(stateKey); + if (!input.turnId) { + return; + } + const turnKey = providerTurnKey(input.threadId, input.turnId); + const stateKeys = assistantSegmentKeysByTurnKey.get(turnKey); + if (!stateKeys) { + return; + } + stateKeys.delete(stateKey); + if (stateKeys.size === 0) { + assistantSegmentKeysByTurnKey.delete(turnKey); + } + }; + + const clearAssistantSegmentsForThread = (threadId: ThreadId): void => { + const prefix = `${threadId}:`; + for (const key of assistantSegmentKeysByTurnKey.keys()) { + if (!key.startsWith(prefix)) { + continue; + } + const stateKeys = assistantSegmentKeysByTurnKey.get(key); + if (stateKeys) { + for (const stateKey of stateKeys) { + assistantSegmentStateByKey.delete(stateKey); + } + } + assistantSegmentKeysByTurnKey.delete(key); + } + }; + + const openAssistantSegment = (input: { + threadId: ThreadId; + baseMessageId: MessageId; + turnId?: TurnId; + }): MessageId => { + const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); + const existingState = assistantSegmentStateByKey.get(stateKey); + if (existingState && existingState.currentSegmentIndex !== null) { + if (input.turnId) { + rememberAssistantSegmentKeyForTurn(input.threadId, input.turnId, stateKey); + } + return assistantSegmentMessageId(existingState.baseMessageId, existingState.currentSegmentIndex); + } + + const segmentIndex = existingState?.nextSegmentIndex ?? 0; + assistantSegmentStateByKey.set(stateKey, { + baseMessageId: input.baseMessageId, + currentSegmentIndex: segmentIndex, + nextSegmentIndex: segmentIndex + 1, + }); + if (input.turnId) { + rememberAssistantSegmentKeyForTurn(input.threadId, input.turnId, stateKey); + } + return assistantSegmentMessageId(input.baseMessageId, segmentIndex); + }; + + const takeOpenAssistantSegmentMessageId = (input: { + threadId: ThreadId; + baseMessageId: MessageId; + }): { messageId: MessageId; hadAnySegment: boolean } | null => { + const stateKey = assistantSegmentStateKey(input.threadId, input.baseMessageId); + const state = assistantSegmentStateByKey.get(stateKey); + if (!state) { + return { messageId: input.baseMessageId, hadAnySegment: false }; + } + if (state.currentSegmentIndex === null) { + return state.nextSegmentIndex > 0 ? null : { messageId: input.baseMessageId, hadAnySegment: false }; + } + return { + messageId: assistantSegmentMessageId(state.baseMessageId, state.currentSegmentIndex), + hadAnySegment: state.nextSegmentIndex > 0, + }; + }; + + const closeOpenAssistantSegmentsForTurn = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + turnId: TurnId; + createdAt: string; + existingAssistantMessageById: ReadonlyMap< + MessageId, + { readonly id: MessageId; readonly text: string; readonly streaming: boolean } + >; + }) => + Effect.gen(function* () { + const turnKey = providerTurnKey(input.threadId, input.turnId); + const stateKeys = Array.from(assistantSegmentKeysByTurnKey.get(turnKey) ?? []); + yield* Effect.forEach( + stateKeys, + (stateKey) => + Effect.gen(function* () { + const state = assistantSegmentStateByKey.get(stateKey); + if (!state || state.currentSegmentIndex === null) { + return; + } + const messageId = assistantSegmentMessageId(state.baseMessageId, state.currentSegmentIndex); + assistantSegmentStateByKey.set(stateKey, { + ...state, + currentSegmentIndex: null, + }); + yield* finalizeAssistantMessage({ + event: input.event, + threadId: input.threadId, + messageId, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: "assistant-complete-tool-boundary", + finalDeltaCommandTag: "assistant-delta-tool-boundary", + existingMessage: input.existingAssistantMessageById.get(messageId), + }); + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + }); + + const clearAssistantMessageState = (messageId: MessageId) => + Effect.all([ + clearBufferedAssistantText(messageId), + Cache.invalidate(assistantMessageSawDeltaByMessageId, messageId), + ]).pipe(Effect.asVoid); const finalizeAssistantMessage = (input: { event: ProviderRuntimeEvent; @@ -655,16 +842,34 @@ const make = Effect.gen(function* () { commandTag: string; finalDeltaCommandTag: string; fallbackText?: string; + existingMessage?: { + readonly id: MessageId; + readonly text: string; + readonly streaming: boolean; + } | undefined; }) => Effect.gen(function* () { + if (input.existingMessage && !input.existingMessage.streaming) { + yield* clearAssistantMessageState(input.messageId); + return; + } + const buffered = yield* takeBufferedAssistantText(input.messageId); + const bufferedText = buffered.text; + + const sawDelta = yield* takeAssistantMessageSawDelta(input.messageId); const text = - buffered.text.length > 0 - ? buffered.text - : (input.fallbackText?.trim().length ?? 0) > 0 + bufferedText.length > 0 + ? bufferedText + : !sawDelta && (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; + if (text.length === 0 && !input.existingMessage) { + yield* clearAssistantMessageState(input.messageId); + return; + } + // Use the original timestamp from when the first delta arrived, not the // finalization time. This ensures assistant text messages are positioned // chronologically relative to tool activities in the timeline instead of @@ -800,6 +1005,7 @@ const make = Effect.gen(function* () { : Effect.void, { concurrency: 1 }, ).pipe(Effect.asVoid); + clearAssistantSegmentsForThread(threadId); }); // Accumulate token usage from thread.token-usage.updated events so @@ -842,6 +1048,16 @@ const make = Effect.gen(function* () { const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; + const existingAssistantMessageById = new Map( + thread.messages.map((message) => [message.id, message] as const), + ); + + const assistantBaseMessageId = + event.type === "content.delta" || + (event.type === "item.completed" && event.payload.itemType === "assistant_message") + ? MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`) + : undefined; + const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; @@ -954,6 +1170,21 @@ const make = Effect.gen(function* () { } } + const isToolLifecycleEvent = + eventTurnId !== undefined && + ((event.type === "item.started" && isToolLifecycleItemType(event.payload.itemType)) || + (event.type === "item.updated" && isToolLifecycleItemType(event.payload.itemType)) || + (event.type === "item.completed" && isToolLifecycleItemType(event.payload.itemType))); + if (isToolLifecycleEvent) { + yield* closeOpenAssistantSegmentsForTurn({ + event, + threadId: thread.id, + turnId: eventTurnId, + createdAt: now, + existingAssistantMessageById, + }); + } + const assistantDelta = event.type === "content.delta" && event.payload.streamKind === "assistant_text" ? event.payload.delta @@ -962,13 +1193,16 @@ const make = Effect.gen(function* () { event.type === "turn.proposed.delta" ? event.payload.delta : undefined; if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.makeUnsafe( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); + const assistantMessageId = openAssistantSegment({ + threadId: thread.id, + baseMessageId: assistantBaseMessageId!, + ...(eventTurnId ? { turnId: eventTurnId } : {}), + }); const turnId = toTurnId(event.turnId); if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } + yield* markAssistantMessageSawDelta(assistantMessageId); const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { @@ -1008,10 +1242,18 @@ const make = Effect.gen(function* () { const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" - ? { - messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), - fallbackText: event.payload.detail, - } + ? (() => { + const existingAssistantMessage = thread.messages.find( + (entry) => entry.id === assistantBaseMessageId, + ); + const shouldApplyFallbackCompletionText = + !existingAssistantMessage || existingAssistantMessage.text.length === 0; + return { + fallbackText: shouldApplyFallbackCompletionText + ? event.payload.detail + : undefined, + }; + })() : undefined; const proposedPlanCompletion = event.type === "turn.proposed.completed" @@ -1023,26 +1265,49 @@ const make = Effect.gen(function* () { : undefined; if (assistantCompletion) { - const assistantMessageId = assistantCompletion.messageId; const turnId = toTurnId(event.turnId); - if (turnId) { + const assistantMessageId = assistantBaseMessageId + ? takeOpenAssistantSegmentMessageId({ + threadId: thread.id, + baseMessageId: assistantBaseMessageId, + })?.messageId + : undefined; + if (!assistantMessageId) { + if (assistantBaseMessageId) { + clearAssistantSegment({ + threadId: thread.id, + baseMessageId: assistantBaseMessageId, + ...(turnId ? { turnId } : {}), + }); + } + } else if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - yield* finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - ...(turnId ? { turnId } : {}), - createdAt: now, - commandTag: "assistant-complete", - finalDeltaCommandTag: "assistant-delta-finalize", - ...(assistantCompletion.fallbackText !== undefined - ? { fallbackText: assistantCompletion.fallbackText } - : {}), - }); + if (assistantMessageId) { + yield* finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + ...(turnId ? { turnId } : {}), + createdAt: now, + commandTag: "assistant-complete", + finalDeltaCommandTag: "assistant-delta-finalize", + ...(assistantCompletion.fallbackText !== undefined + ? { fallbackText: assistantCompletion.fallbackText } + : {}), + existingMessage: existingAssistantMessageById.get(assistantMessageId), + }); + } - if (turnId) { + if (assistantBaseMessageId) { + clearAssistantSegment({ + threadId: thread.id, + baseMessageId: assistantBaseMessageId, + ...(turnId ? { turnId } : {}), + }); + } + if (turnId && assistantMessageId) { yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); } } @@ -1074,10 +1339,12 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", + existingMessage: existingAssistantMessageById.get(assistantMessageId), }), { concurrency: 1 }, ).pipe(Effect.asVoid); yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + clearAssistantSegmentsForTurn(thread.id, turnId); yield* finalizeBufferedProposedPlan({ event, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2c30157cf4..88e6093c03 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -303,6 +303,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), + ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index 2e396d7156..d514198938 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -5,12 +5,12 @@ import type { SDKMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import * as Path from "node:path"; import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Fiber, Random, Stream } from "effect"; import { - ProviderAdapterRequestError, ProviderAdapterValidationError, } from "../Errors.ts"; import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; @@ -206,6 +206,7 @@ describe("ClaudeCodeAdapterLive", () => { const createInput = harness.getLastCreateQueryInput(); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + assert.equal(Path.basename(createInput?.options.pathToClaudeCodeExecutable ?? ""), "cli.js"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -236,6 +237,65 @@ describe("ClaudeCodeAdapterLive", () => { ); }); + it.effect("strips Electron bootstrap env vars before starting Claude query", () => { + const harness = makeHarness(); + const originalRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const originalRendererPort = process.env.ELECTRON_RENDERER_PORT; + const originalClaudeCode = process.env.CLAUDECODE; + const originalPath = process.env.PATH; + + process.env.ELECTRON_RUN_AS_NODE = "1"; + process.env.ELECTRON_RENDERER_PORT = "54321"; + process.env.CLAUDECODE = "1"; + process.env.PATH = "/usr/bin:/bin"; + + return Effect.gen(function* () { + const adapter = yield* ClaudeCodeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeCode", + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.isDefined(createInput); + assert.equal(createInput?.options.env?.ELECTRON_RUN_AS_NODE, undefined); + assert.equal(createInput?.options.env?.ELECTRON_RENDERER_PORT, undefined); + assert.equal(createInput?.options.env?.CLAUDECODE, undefined); + assert.equal(createInput?.options.env?.PATH, "/usr/bin:/bin"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE; + } else { + process.env.ELECTRON_RUN_AS_NODE = originalRunAsNode; + } + + if (originalRendererPort === undefined) { + delete process.env.ELECTRON_RENDERER_PORT; + } else { + process.env.ELECTRON_RENDERER_PORT = originalRendererPort; + } + + if (originalClaudeCode === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalClaudeCode; + } + + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + }), + ), + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 4032a4ca35..6d1d4a803a 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -6,8 +6,12 @@ * * @module ClaudeCodeAdapterLive */ +import { createRequire } from "node:module"; +import * as Path from "node:path"; + import { type CanUseTool, + type EffortLevel, query, type Options as ClaudeQueryOptions, type PermissionMode, @@ -36,7 +40,7 @@ import { } from "@t3tools/contracts"; import { Cause, DateTime, Deferred, Effect, Layer, Queue, Random, Ref, Stream } from "effect"; -import type { ProviderSessionUsage, ProviderUsageQuota, ProviderUsageResult } from "@t3tools/contracts"; +import type { ProviderUsageQuota, ProviderUsageResult } from "@t3tools/contracts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -50,6 +54,41 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg const PROVIDER = "claudeCode" as const; +/** + * Environment variables that must be stripped before spawning the Claude Code + * subprocess. These are set by the Electron desktop shell and leak into the + * server's `process.env` when T3 Code runs in desktop mode. Passing them + * through to the Claude CLI can cause it to exit immediately with code 1. + */ +const SPAWN_ENV_BLOCKLIST = new Set([ + "ELECTRON_RUN_AS_NODE", + "ELECTRON_RENDERER_PORT", + "CLAUDECODE", +]); + +const DESKTOP_DIAGNOSTIC_ENV_PREFIXES = ["CLAUDE", "ELECTRON", "CODEX", "T3CODE"] as const; + +function sanitizedEnv(): Record { + const env = { ...process.env }; + for (const key of SPAWN_ENV_BLOCKLIST) { + delete env[key]; + } + return env; +} + +const require = createRequire(import.meta.url); +let resolvedClaudeSdkCliPath: string | undefined; + +function defaultClaudeSdkCliPath(): string { + if (resolvedClaudeSdkCliPath) { + return resolvedClaudeSdkCliPath; + } + + const sdkEntry = require.resolve("@anthropic-ai/claude-agent-sdk"); + resolvedClaudeSdkCliPath = Path.join(Path.dirname(sdkEntry), "cli.js"); + return resolvedClaudeSdkCliPath; +} + /** * Loose accessor type for SDKMessage dynamic properties that arrive via * the SDK's index signature. Using this instead of `any` keeps the @@ -61,49 +100,15 @@ type SDKMessageLoose = SDKMessage & Record; // oxlint-ignore-next-l // ── Module-level usage tracking ────────────────────────────────────── interface ClaudeCodeUsageAccumulator { - totalCostUsd: number; - inputTokens: number; - outputTokens: number; - cachedTokens: number; - turnCount: number; lastRateLimits: Record | null; } // Intentionally module-level: aggregates usage across all Claude Code sessions // for the global usage display shown in the UI sidebar. let _claudeUsageAccumulator: ClaudeCodeUsageAccumulator = { - totalCostUsd: 0, - inputTokens: 0, - outputTokens: 0, - cachedTokens: 0, - turnCount: 0, lastRateLimits: null, }; -function accumulateClaudeUsage(result: SDKResultMessage | undefined): void { - if (!result) return; - _claudeUsageAccumulator.turnCount++; - if (typeof result.total_cost_usd === "number") { - _claudeUsageAccumulator.totalCostUsd = result.total_cost_usd; - } - if (result.usage) { - if (typeof result.usage.input_tokens === "number") { - _claudeUsageAccumulator.inputTokens += result.usage.input_tokens; - } - if (typeof result.usage.output_tokens === "number") { - _claudeUsageAccumulator.outputTokens += result.usage.output_tokens; - } - const cached = - (typeof result.usage.cache_read_input_tokens === "number" - ? result.usage.cache_read_input_tokens - : 0) + - (typeof result.usage.cache_creation_input_tokens === "number" - ? result.usage.cache_creation_input_tokens - : 0); - if (cached > 0) _claudeUsageAccumulator.cachedTokens += cached; - } -} - function storeClaudeRateLimits(message: Record): void { _claudeUsageAccumulator.lastRateLimits = message; } @@ -149,23 +154,9 @@ export function fetchClaudeCodeUsage(): ProviderUsageResult { if (tokensQuota) quotas.push(tokensQuota); } - // Build session usage from accumulated data - let sessionUsage: ProviderSessionUsage | undefined; - if (acc.turnCount > 0) { - sessionUsage = { - ...(acc.totalCostUsd > 0 ? { totalCostUsd: acc.totalCostUsd } : {}), - inputTokens: acc.inputTokens, - outputTokens: acc.outputTokens, - ...(acc.cachedTokens > 0 ? { cachedTokens: acc.cachedTokens } : {}), - totalTokens: acc.inputTokens + acc.outputTokens, - turnCount: acc.turnCount, - }; - } - return { provider: PROVIDER, ...(quotas.length > 0 ? { quota: quotas[0], quotas } : {}), - ...(sessionUsage ? { sessionUsage } : {}), }; } @@ -268,6 +259,25 @@ function toUnknownRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" ? (value as Record) : undefined; } +function isDesktopRuntime(): boolean { + return process.env.T3CODE_MODE === "desktop"; +} + +function diagnosticEnvKeys(env: Readonly>): ReadonlyArray { + return Object.keys(env) + .filter((key) => DESKTOP_DIAGNOSTIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) + .sort(); +} + +function logDesktopClaudeDiagnostic(message: string, data?: Record): void { + if (!isDesktopRuntime()) return; + if (data) { + console.warn("[claudeCode][desktop]", message, data); + return; + } + console.warn("[claudeCode][desktop]", message); +} + function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.makeUnsafe(value); } @@ -815,7 +825,6 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { result?: SDKResultMessage, ): Effect.Effect => Effect.gen(function* () { - accumulateClaudeUsage(result); const turnState = context.turnState; if (!turnState) { const stamp = yield* makeEventStamp(); @@ -1694,31 +1703,50 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeCode; + const claudeCodeModelOptions = input.modelOptions?.claudeCode; const permissionMode = toPermissionMode(providerOptions?.permissionMode) ?? (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + const effort = claudeCodeModelOptions?.effort as EffortLevel | undefined; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), - ...(providerOptions?.binaryPath - ? { pathToClaudeCodeExecutable: providerOptions.binaryPath } - : {}), + pathToClaudeCodeExecutable: (providerOptions?.binaryPath as string | undefined) ?? defaultClaudeSdkCliPath(), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + ? { maxThinkingTokens: providerOptions.maxThinkingTokens as number } : {}), + ...(effort ? { effort } : {}), ...(resumeState?.resume ? { resume: resumeState.resume } : {}), ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), includePartialMessages: true, canUseTool, - env: process.env, + env: sanitizedEnv(), + ...(isDesktopRuntime() + ? { + stderr: (message: string) => { + const trimmed = message.trimEnd(); + if (trimmed.length > 0) { + console.warn("[claudeCode][stderr]", trimmed); + } + }, + } + : {}), ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), }; + logDesktopClaudeDiagnostic("starting Claude query", { + blockedEnvKeys: [...SPAWN_ENV_BLOCKLIST].sort(), + inheritedDiagnosticEnvKeys: diagnosticEnvKeys(process.env), + forwardedDiagnosticEnvKeys: diagnosticEnvKeys(queryOptions.env ?? {}), + model: input.model, + cwd: input.cwd, + }); + const queryRuntime = yield* Effect.try({ try: () => createQuery({ diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 306440315d..19d38d8dbd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1531,9 +1531,7 @@ function codexBucketToQuota( label: string, ): ProviderUsageQuota | undefined { if (!bucket || bucket.usedPercent == null) return undefined; - const resetsAt = bucket.resetsAt - ? new Date(bucket.resetsAt * 1000).toISOString().slice(0, 10) - : undefined; + const resetsAt = bucket.resetsAt ? new Date(bucket.resetsAt * 1000).toISOString() : undefined; return { plan: label, percentUsed: bucket.usedPercent, @@ -1541,6 +1539,15 @@ function codexBucketToQuota( }; } +function formatCodexSessionWindowLabel(windowDurationMins: number): string { + if (windowDurationMins > 0 && windowDurationMins % 60 === 0) { + const hours = windowDurationMins / 60; + return `Session (${hours} hr${hours === 1 ? "" : "s"})`; + } + + return `Session (${windowDurationMins} min)`; +} + /** * Fetch Codex rate limit usage from the active app-server session. * Returns a minimal stub if no active session exists. @@ -1556,7 +1563,7 @@ export async function fetchCodexUsage(): Promise { } const sessionLabel = limits.primary?.windowDurationMins - ? `Session (${limits.primary.windowDurationMins}min)` + ? formatCodexSessionWindowLabel(limits.primary.windowDurationMins) : "Session"; const quotas: ProviderUsageQuota[] = []; const sessionQuota = codexBucketToQuota(limits.primary, sessionLabel); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 284d2c329f..6db5f8f572 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,19 +1,13 @@ import { randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; - import { - type CanonicalItemType, type CodexReasoningEffort, EventId, type ProviderApprovalDecision, - type ProviderModelMultiplier, ProviderItemId, type ProviderRuntimeEvent, type ProviderSession, type ProviderTurnStartResult, - type ProviderUsageQuota, - type ProviderUsageResult, type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, @@ -40,6 +34,16 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + assistantUsageFields, + beginCopilotTurn, + clearTurnTracking, + completionTurnRefs, + isCopilotTurnTerminalEvent, + markTurnAwaitingCompletion, + recordTurnUsage, + type CopilotTurnTrackingState, +} from "./copilotTurnTracking.ts"; import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import type { @@ -49,6 +53,7 @@ import type { const PROVIDER = "copilot" as const; const USER_INPUT_QUESTION_ID = "answer"; +const USER_INPUT_QUESTION_HEADER = "Question"; export interface CopilotAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; @@ -83,63 +88,24 @@ interface PendingUserInputRequest { readonly resolve: (result: CopilotUserInputResponse) => void; } -interface ActiveCopilotSession { +interface ActiveCopilotSession extends CopilotTurnTrackingState { readonly client: CopilotClientHandle; session: CopilotSessionHandle; readonly threadId: ThreadId; readonly createdAt: string; - runtimeMode: ProviderSession["runtimeMode"]; + readonly runtimeMode: ProviderSession["runtimeMode"]; cwd: string | undefined; configDir: string | undefined; model: string | undefined; reasoningEffort: CodexReasoningEffort | undefined; updatedAt: string; lastError: string | undefined; - currentTurnId: TurnId | undefined; - currentProviderTurnId: TurnId | undefined; - pendingTurnIds: Array; toolTitlesByCallId: Map; - toolItemTypeByCallId: Map; pendingApprovalResolvers: Map; pendingUserInputResolvers: Map; unsubscribe: () => void; } -function createSessionRecord(input: { - readonly threadId: ThreadId; - readonly client: CopilotClientHandle; - readonly session: CopilotSessionHandle; - readonly runtimeMode: ProviderSession["runtimeMode"]; - readonly pendingApprovalResolvers: Map; - readonly pendingUserInputResolvers: Map; - readonly cwd: string | undefined; - readonly configDir: string | undefined; - readonly model: string | undefined; - readonly reasoningEffort: CodexReasoningEffort | undefined; -}): ActiveCopilotSession { - return { - client: input.client, - session: input.session, - threadId: input.threadId, - createdAt: new Date().toISOString(), - runtimeMode: input.runtimeMode, - cwd: input.cwd, - configDir: input.configDir, - model: input.model, - reasoningEffort: input.reasoningEffort, - updatedAt: new Date().toISOString(), - lastError: undefined, - currentTurnId: undefined, - currentProviderTurnId: undefined, - pendingTurnIds: [], - toolTitlesByCallId: new Map(), - toolItemTypeByCallId: new Map(), - pendingApprovalResolvers: input.pendingApprovalResolvers, - pendingUserInputResolvers: input.pendingUserInputResolvers, - unsubscribe: () => undefined, - }; -} - interface CopilotSessionHandle { readonly sessionId: string; destroy(): Promise; @@ -287,7 +253,7 @@ function requestDetailFromPermissionRequest(request: PermissionRequest): string } } -function itemTypeFromToolEvent(event: Extract): CanonicalItemType { +function itemTypeFromToolEvent(event: Extract) { return event.data.mcpToolName ? "mcp_tool_call" : "dynamic_tool_call"; } @@ -359,7 +325,7 @@ function mapHistoryToTurns(threadId: ThreadId, events: ReadonlyArray; + readonly pendingUserInputResolvers: Map; + readonly cwd: string | undefined; + readonly configDir: string | undefined; + readonly model: string | undefined; + readonly reasoningEffort: CodexReasoningEffort | undefined; +}): ActiveCopilotSession { + return { + client: input.client, + session: input.session, + threadId: input.threadId, + createdAt: new Date().toISOString(), + runtimeMode: input.runtimeMode, + cwd: input.cwd, + configDir: input.configDir, + model: input.model, + reasoningEffort: input.reasoningEffort, + updatedAt: new Date().toISOString(), + lastError: undefined, + currentTurnId: undefined, + currentProviderTurnId: undefined, + pendingCompletionTurnId: undefined, + pendingCompletionProviderTurnId: undefined, + pendingTurnIds: [], + pendingTurnUsage: undefined, + toolTitlesByCallId: new Map(), + pendingApprovalResolvers: input.pendingApprovalResolvers, + pendingUserInputResolvers: input.pendingUserInputResolvers, + unsubscribe: () => undefined, + }; +} + const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig; @@ -527,47 +530,41 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, }, ]; - case "session.idle": - // session.idle is the Copilot SDK's authoritative "done" - // signal — it fires after assistant.turn_end and - // assistant.usage have completed. Emit turn.completed here - // so the orchestration layer settles the turn cleanly. - // If a session.error preceded idle, finalize the turn as - // failed so the UI does not incorrectly show success. - return [ - ...(currentTurnId + case "session.idle": { + const idleCompletionRefs = completionTurnRefs(record); + const idleCompletionEvents: ProviderRuntimeEvent[] = + idleCompletionRefs.turnId || idleCompletionRefs.providerTurnId ? [ { - ...base({ providerTurnId: currentProviderTurnId }), - type: "turn.completed" as const, - payload: record.lastError - ? { - state: "failed" as const, - errorMessage: record.lastError, - } - : { - state: "completed" as const, - }, - }, + ...base(idleCompletionRefs), + type: "turn.completed", + payload: { + state: record.lastError ? "failed" : "completed", + ...assistantUsageFields(record.pendingTurnUsage), + }, + } satisfies ProviderRuntimeEvent, ] - : []), + : []; + return [ + ...idleCompletionEvents, { ...base(), - type: "session.state.changed" as const, + type: "session.state.changed", payload: { - state: "ready" as const, + state: "ready", reason: "session.idle", }, }, { ...base(), - type: "thread.state.changed" as const, + type: "thread.state.changed", payload: { state: "idle", detail: event.data, }, }, ]; + } case "session.title_changed": return [ { @@ -716,32 +713,35 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => }, ]; case "assistant.turn_end": - // Do not emit turn.completed here — the Copilot SDK fires - // assistant.usage and session.idle after turn_end. Emitting - // completion prematurely flips the UI to "ready" while work - // events are still arriving. The real "done" signal is - // session.idle (handled below). return []; - case "assistant.usage": + case "assistant.usage": { + const completionRefs = completionTurnRefs(record); + const completionBase = + completionRefs.turnId || completionRefs.providerTurnId ? base(completionRefs) : base(); return [ { - ...base(), + ...completionBase, type: "thread.token-usage.updated", payload: { usage: event.data, }, }, ]; - case "abort": + } + case "abort": { + const abortedTurnRefs = completionTurnRefs(record); + const abortedBase = + abortedTurnRefs.turnId || abortedTurnRefs.providerTurnId ? base(abortedTurnRefs) : base(); return [ { - ...base(), + ...abortedBase, type: "turn.aborted", payload: { reason: event.data.reason, }, }, ]; + } case "tool.execution_start": return [ { @@ -784,7 +784,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...base({ itemId: event.data.toolCallId }), type: "item.completed", payload: { - itemType: record.toolItemTypeByCallId.get(event.data.toolCallId) ?? "dynamic_tool_call", + itemType: event.data.result?.contents?.some((content: { type: string }) => content.type === "terminal") + ? "command_execution" + : "dynamic_tool_call", status: event.data.success ? "completed" : "failed", title: record.toolTitlesByCallId.get(event.data.toolCallId) ?? "Tool call", ...(trimToUndefined(event.data.result?.content) ? { detail: event.data.result?.content } : {}), @@ -913,7 +915,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => questions: [ { id: USER_INPUT_QUESTION_ID, - header: "GitHub Copilot", + header: USER_INPUT_QUESTION_HEADER, question: request.question, options: (request.choices ?? []).map((choice: string) => ({ label: choice, @@ -1019,6 +1021,13 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const sessionId = record.session.sessionId; const previousSession = record.session; const previousUnsubscribe = record.unsubscribe; + previousUnsubscribe(); + // Best-effort teardown -- must not block new session creation + try { + await previousSession.destroy(); + } catch { + // ignored + } const handlers = createInteractionHandlers( record.threadId, @@ -1036,7 +1045,6 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => streaming: true, }); - // Install the new session immediately so the record is live record.session = nextSession; record.model = input.model; record.reasoningEffort = input.reasoningEffort; @@ -1044,14 +1052,6 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => record.unsubscribe = nextSession.on((event) => { handleSessionEvent(record, event); }); - - // Clean up the old session – failures here must not affect the new session - previousUnsubscribe(); - try { - await previousSession.destroy(); - } catch { - // Swallow destroy errors; the new session is already installed - } }, catch: (cause) => new ProviderAdapterRequestError({ @@ -1065,9 +1065,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const handleSessionEvent = (record: ActiveCopilotSession, event: SessionEvent) => { record.updatedAt = event.timestamp; if (event.type === "assistant.turn_start") { - const providerTurnId = TurnId.makeUnsafe(event.data.turnId); - record.currentProviderTurnId = providerTurnId; - record.currentTurnId = record.pendingTurnIds.shift() ?? record.currentTurnId ?? providerTurnId; + beginCopilotTurn(record, TurnId.makeUnsafe(event.data.turnId)); + } + if (event.type === "assistant.usage") { + recordTurnUsage(record, event.data); } if (event.type === "session.error") { record.lastError = event.data.message; @@ -1075,11 +1076,8 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => if (event.type === "session.model_change") { record.model = event.data.newModel; } - if (event.type === "tool.execution_start") { - if (trimToUndefined(event.data.toolName)) { - record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); - } - record.toolItemTypeByCallId.set(event.data.toolCallId, itemTypeFromToolEvent(event)); + if (event.type === "tool.execution_start" && trimToUndefined(event.data.toolName)) { + record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); } void writeNativeEvent(record.threadId, event); @@ -1089,21 +1087,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } if (event.type === "tool.execution_complete") { record.toolTitlesByCallId.delete(event.data.toolCallId); - record.toolItemTypeByCallId.delete(event.data.toolCallId); + } + if (event.type === "assistant.turn_end") { + markTurnAwaitingCompletion(record); } if (event.type === "abort" || event.type === "session.idle") { - // If the turn terminates before assistant.turn_start consumed the - // pending ID, remove the stale entry so it never leaks into a future - // turn. - if (record.currentTurnId) { - record.pendingTurnIds = record.pendingTurnIds.filter( - (id) => id !== record.currentTurnId, - ); - } - record.currentTurnId = undefined; - record.currentProviderTurnId = undefined; - // Clear the error after the idle handler has consumed it for - // turn.completed so it doesn't leak into subsequent turns. + clearTurnTracking(record); if (event.type === "session.idle") { record.lastError = undefined; } @@ -1118,10 +1107,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => return Effect.succeed(record); }; - const stopRecord = async ( - record: ActiveCopilotSession, - options?: { readonly emitExitEvent?: boolean }, - ) => { + const stopRecord = async (record: ActiveCopilotSession) => { record.unsubscribe(); try { await record.session.destroy(); @@ -1133,51 +1119,20 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => } catch { // best effort } - - const teardownEvents: ProviderRuntimeEvent[] = []; - - for (const [requestId, pending] of record.pendingApprovalResolvers) { + void emitRuntimeEvents([ + makeSyntheticEvent(record.threadId, "session.exited", { + reason: "Session stopped", + exitKind: "graceful", + }), + ]); + for (const pending of record.pendingApprovalResolvers.values()) { pending.resolve({ kind: "denied-interactively-by-user" }); - teardownEvents.push( - makeSyntheticEvent( - record.threadId, - "request.resolved", - { - requestType: pending.requestType, - decision: "cancel", - resolution: { kind: "denied-interactively-by-user" }, - }, - { requestId, turnId: pending.turnId }, - ), - ); } record.pendingApprovalResolvers.clear(); - - for (const [requestId, pending] of record.pendingUserInputResolvers) { + for (const pending of record.pendingUserInputResolvers.values()) { pending.resolve({ answer: "", wasFreeform: true }); - teardownEvents.push( - makeSyntheticEvent( - record.threadId, - "user-input.resolved", - { answers: {} }, - { requestId, turnId: pending.turnId }, - ), - ); } record.pendingUserInputResolvers.clear(); - - if (options?.emitExitEvent !== false) { - teardownEvents.push( - makeSyntheticEvent(record.threadId, "session.exited", { - reason: "stopped", - }), - ); - } - - if (teardownEvents.length > 0) { - await emitRuntimeEvents(teardownEvents); - } - sessions.delete(record.threadId); }; @@ -1193,8 +1148,6 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const existing = sessions.get(input.threadId); if (existing) { - existing.runtimeMode = input.runtimeMode; - existing.updatedAt = new Date().toISOString(); return { provider: PROVIDER, status: "ready", @@ -1237,27 +1190,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => threadId: input.threadId, model: input.model, reasoningEffort, - }).pipe( - // validateSessionConfiguration may call client.start() internally. - // If validation fails after that, stop the client to avoid leaking - // a running process. - Effect.tapError(() => Effect.promise(() => client.stop().catch(() => {}))), - ); + }); const session = yield* Effect.tryPromise({ try: async () => { - try { - if (resumeSessionId) { - return await client.resumeSession(resumeSessionId, { - ...handlers, - ...(input.model ? { model: input.model } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - ...(input.cwd ? { workingDirectory: input.cwd } : {}), - ...(configDir ? { configDir } : {}), - streaming: true, - }); - } - return await client.createSession({ + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { ...handlers, ...(input.model ? { model: input.model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), @@ -1265,10 +1203,15 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...(configDir ? { configDir } : {}), streaming: true, }); - } catch (err) { - await client.stop().catch(() => {}); - throw err; } + return client.createSession({ + ...handlers, + ...(input.model ? { model: input.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1277,7 +1220,11 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => detail: toMessage(cause, "Failed to start GitHub Copilot session."), cause, }), - }); + }).pipe( + Effect.tapError(() => + Effect.promise(() => client.stop().catch(() => undefined)), + ), + ); const record = createSessionRecord({ threadId: input.threadId, @@ -1291,7 +1238,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => model: input.model, reasoningEffort, }); - const unsubscribe = session.on((event: SessionEvent) => { + const unsubscribe = session.on((event: unknown) => { handleSessionEvent(record, event); }); record.unsubscribe = unsubscribe; @@ -1356,28 +1303,26 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => : input.model && input.model !== record.model ? undefined : record.reasoningEffort; - const attachments = yield* Effect.forEach( - input.attachments ?? [], - (attachment) => - Effect.gen(function* () { - const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.send", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - return { - type: "file" as const, - path: attachmentPath, - displayName: attachment.name, - }; - }), - ); + const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment, + }); + if (!attachmentPath) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.send", + detail: `Invalid attachment id '${attachment.id}'.`, + }), + ); + } + return Effect.succeed({ + type: "file" as const, + path: attachmentPath, + displayName: attachment.name, + }); + }); yield* validateSessionConfiguration({ client: record.client, @@ -1514,7 +1459,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const record = yield* getSessionRecord(threadId); yield* Effect.tryPromise({ try: async () => { - await stopRecord(record, { emitExitEvent: true }); + await stopRecord(record); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1528,31 +1473,22 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const listSessions: CopilotAdapterShape["listSessions"] = () => Effect.sync(() => - Array.from(sessions.values()).map((record) => { - const status: ProviderSession["status"] = record.currentTurnId ? "running" : "ready"; - const session = { - provider: PROVIDER, - status, - runtimeMode: record.runtimeMode, - threadId: record.threadId, - resumeCursor: record.session.sessionId, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }; - if (record.cwd) { - Object.assign(session, { cwd: record.cwd }); - } - if (record.model) { - Object.assign(session, { model: record.model }); - } - if (record.currentTurnId) { - Object.assign(session, { activeTurnId: record.currentTurnId }); - } - if (record.lastError) { - Object.assign(session, { lastError: record.lastError }); - } - return session satisfies ProviderSession; - }), + Array.from(sessions.values()).map( + (record) => + ({ + provider: PROVIDER, + status: record.currentTurnId ? "running" : "ready", + runtimeMode: record.runtimeMode, + threadId: record.threadId, + resumeCursor: record.session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(record.cwd ? { cwd: record.cwd } : {}), + ...(record.model ? { model: record.model } : {}), + ...(record.currentTurnId ? { activeTurnId: record.currentTurnId } : {}), + ...(record.lastError ? { lastError: record.lastError } : {}), + }) satisfies ProviderSession, + ), ); const hasSession: CopilotAdapterShape["hasSession"] = (threadId) => @@ -1589,9 +1525,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const stopAll: CopilotAdapterShape["stopAll"] = () => Effect.tryPromise({ try: async () => { - await Promise.all( - Array.from(sessions.values()).map((record) => stopRecord(record, { emitExitEvent: true })), - ); + await Promise.all(Array.from(sessions.values()).map((record) => stopRecord(record))); }, catch: (cause) => new ProviderAdapterProcessError({ @@ -1606,7 +1540,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => Effect.forEach( sessions, ([, record]) => - Effect.promise(() => stopRecord(record, { emitExitEvent: false }).catch(() => undefined)), + Effect.promise(() => stopRecord(record).catch(() => undefined)), { discard: true }, ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), ); @@ -1637,241 +1571,84 @@ export function makeCopilotAdapterLive(options?: CopilotAdapterLiveOptions) { return Layer.effect(CopilotAdapter, makeCopilotAdapter(options)); } -// ── Dynamic model discovery with pricing ──────────────────────────── - -function extractPricingTier(model: ModelInfo): string | undefined { - if (!model || typeof model !== "object") return undefined; - // The Copilot SDK exposes pricing as a multiplier string (e.g. "1x", "3x"). - // Try common field names the SDK may use. - const record = model as Record; - for (const key of [ - "pricingTier", - "pricing", - "premiumRequestMultiplier", - "costMultiplier", - "premiumTier", - ]) { - const value = record[key]; - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - if (typeof value === "number" && value > 0) { - return `${value}x`; - } - } - return undefined; -} +// ── Dynamic model discovery & usage (consumed by wsServer) ───────── -function extractModelName(model: ModelInfo): string { - if (!model || typeof model !== "object") return String(model?.id ?? "unknown"); - const record = model as Record; - const name = record.name ?? record.displayName ?? record.label; - if (typeof name === "string" && name.trim().length > 0) return name.trim(); - return String(record.id ?? "unknown"); -} - -export interface CopilotModelDiscoveryOptions { - readonly cliPath?: string; - readonly cwd?: string; -} - -export async function fetchCopilotModels( - options?: CopilotModelDiscoveryOptions, -): Promise> { - const cliPath = - normalizeCopilotCliPathOverride(options?.cliPath) ?? - resolveBundledCopilotCliPath(); - const clientOptions: CopilotClientOptions = { - ...(cliPath ? { cliPath } : {}), - ...(options?.cwd ? { cwd: options.cwd } : {}), - logLevel: "error", - }; - const client = new CopilotClient(clientOptions); +export async function fetchCopilotModels(): Promise< + ReadonlyArray<{ slug: string; name: string; pricingTier?: string }> | null +> { try { - await client.start(); - const models = await client.listModels(); - const result: Array<{ slug: string; name: string; pricingTier?: string }> = []; - for (const model of models) { - const slug = String(model.id ?? ""); - if (slug.length === 0) continue; - const entry: { slug: string; name: string; pricingTier?: string } = { - slug, - name: extractModelName(model), - }; - const tier = extractPricingTier(model); - if (tier) { - entry.pricingTier = tier; - } - result.push(entry); - } - return result; - } finally { - await client.stop().catch(() => undefined); - } -} - -// ── Copilot usage / quota discovery ───────────────────────────────── - -function extractMultiplier(model: ModelInfo): number { - if (!model || typeof model !== "object") return 1; - const record = model as Record; - for (const key of [ - "premiumRequestMultiplier", - "costMultiplier", - "pricingTier", - "pricing", - ]) { - const value = record[key]; - if (typeof value === "number" && value > 0) return value; - if (typeof value === "string") { - const match = /^(\d+(?:\.\d+)?)\s*x?$/i.exec(value.trim()); - if (match) return Number(match[1]); + const { CopilotClient } = await import("@github/copilot-sdk"); + const { resolveBundledCopilotCliPath } = await import("./copilotCliPath.ts"); + const cliPath = resolveBundledCopilotCliPath(); + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }); + try { + await client.start(); + const models = await client.listModels().catch(() => undefined); + if (!models || models.length === 0) return null; + return models.map((m: { id: string; name: string }) => ({ + slug: m.id, + name: m.name, + })); + } finally { + await client.stop().catch(() => {}); } + } catch { + return null; } - return 1; } -/** - * Query the internal Copilot API via the `gh` CLI to get premium interaction - * quota. This uses the same endpoint that VS Code uses: `/copilot_internal/user`. - * Falls back gracefully when `gh` is unavailable. - */ -async function fetchCopilotQuotaViaGh(): Promise { - return new Promise((resolve) => { - execFile( - "gh", - ["api", "/copilot_internal/user", "--jq", "."], - { timeout: 8_000, env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" } }, - (error, stdout) => { - if (error || !stdout.trim()) { - resolve(undefined); - return; - } - try { - const data = JSON.parse(stdout.trim()) as Record; - const quota = parseCopilotInternalResponse(data); - resolve(quota); - } catch { - resolve(undefined); - } - }, - ); - }); -} - -/** - * Parse the /copilot_internal/user response which has shape: - * { - * copilot_plan: "individual" | "business" | ..., - * quota_reset_date: "2026-04-01", - * quota_snapshots: { - * premium_interactions: { - * entitlement: 300, - * remaining: 295, - * percent_remaining: 98.33, - * unlimited: false, - * } - * } - * } - */ -function parseCopilotInternalResponse(data: Record): ProviderUsageQuota | undefined { - const snapshots = data.quota_snapshots as Record | undefined; - const premium = snapshots?.premium_interactions as Record | undefined; - if (!premium) return undefined; - - const entitlement = typeof premium.entitlement === "number" ? premium.entitlement : undefined; - const remaining = typeof premium.remaining === "number" ? premium.remaining : undefined; - const unlimited = premium.unlimited === true; - - if (unlimited) return { plan: toString(data.copilot_plan) ?? "Copilot" }; - - const limit = entitlement; - const used = limit != null && remaining != null ? limit - remaining : undefined; - const resetDate = toDateString(data.quota_reset_date ?? data.quota_reset_date_utc); - const plan = toString(data.copilot_plan); - - if (limit === undefined && used === undefined) return undefined; - - return { - ...(plan ? { plan } : {}), - ...(used !== undefined ? { used } : {}), - ...(limit !== undefined ? { limit } : {}), - ...(resetDate ? { resetDate } : {}), - ...(limit !== undefined && limit > 0 && used !== undefined - ? { percentUsed: Math.round((used / limit) * 100) } - : {}), - }; -} - -function toDateString(value: unknown): string | undefined { - if (!value) return undefined; - const s = String(value).trim(); - if (s.length === 0) return undefined; - // Accept ISO dates or date-only strings - const d = new Date(s); - if (Number.isNaN(d.getTime())) return undefined; - return d.toISOString().slice(0, 10); -} - -function toString(value: unknown): string | undefined { - if (typeof value === "string" && value.trim().length > 0) return value.trim(); - return undefined; -} - -/** - * Fetch full Copilot usage info: account quota + per-model multipliers. - */ -export async function fetchCopilotUsage( - options?: CopilotModelDiscoveryOptions, -): Promise { - // Run quota fetch and model list concurrently - const [quota, models] = await Promise.all([ - fetchCopilotQuotaViaGh().catch(() => undefined), - fetchCopilotModels(options).catch(() => []), - ]); - - const modelMultipliers: ProviderModelMultiplier[] = []; - // Re-parse from raw SDK models to get numeric multipliers - const cliPath = - normalizeCopilotCliPathOverride(options?.cliPath) ?? - resolveBundledCopilotCliPath(); - const clientOptions: CopilotClientOptions = { - ...(cliPath ? { cliPath } : {}), - ...(options?.cwd ? { cwd: options.cwd } : {}), - logLevel: "error", - }; - +export async function fetchCopilotUsage(): Promise<{ + provider: string; + quotas?: ReadonlyArray<{ + key: string; + limit: number; + used: number; + remaining: number; + percentageRemaining: number; + overage: number; + resetDate?: string; + }>; +}> { try { - const client = new CopilotClient(clientOptions); - await client.start(); + const { CopilotClient } = await import("@github/copilot-sdk"); + const { resolveBundledCopilotCliPath } = await import("./copilotCliPath.ts"); + const cliPath = resolveBundledCopilotCliPath(); + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }); try { - const rawModels = await client.listModels(); - for (const model of rawModels) { - const slug = String(model.id ?? ""); - if (slug.length === 0) continue; - const multiplier = extractMultiplier(model); - modelMultipliers.push({ - model: slug, - name: extractModelName(model), - multiplier, - }); - } + await client.start(); + const quota = await (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined); + if (!quota?.quotaSnapshots) return { provider: "copilot" }; + const quotas = Object.entries( + quota.quotaSnapshots as Record< + string, + { + entitlementRequests: number; + usedRequests: number; + remainingPercentage: number; + overage: number; + resetDate?: string; + } + >, + ).map(([key, snap]) => ({ + key, + limit: Math.max(0, Math.trunc(snap.entitlementRequests)), + used: Math.max(0, Math.trunc(snap.usedRequests)), + remaining: Math.max(0, Math.trunc(snap.entitlementRequests) - Math.trunc(snap.usedRequests)), + percentageRemaining: snap.remainingPercentage, + overage: Math.max(0, Math.trunc(snap.overage)), + ...(snap.resetDate ? { resetDate: snap.resetDate } : {}), + })); + return { provider: "copilot", quotas }; } finally { - await client.stop().catch(() => undefined); + await client.stop().catch(() => {}); } } catch { - // Fall back to the already-fetched models with no multiplier info - for (const m of models) { - const multiplier = m.pricingTier - ? (Number(m.pricingTier.replace(/x$/i, "")) || 1) - : 1; - modelMultipliers.push({ model: m.slug, name: m.name, multiplier }); - } + return { provider: "copilot" }; } - - return { - provider: "copilot", - ...(quota ? { quota } : {}), - ...(modelMultipliers.length > 0 ? { modelMultipliers } : {}), - }; } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 76a46d106e..838b249a1a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -8,7 +8,7 @@ import { Effect, Fiber, Stream } from "effect"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { makeCursorAdapterLive } from "./CursorAdapter.ts"; +import { makeCursorAdapterLive, parseCursorModelCommandOutput } from "./CursorAdapter.ts"; const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); @@ -24,9 +24,23 @@ class FakeCursorAcpProcess extends EventEmitter { private readonly input = readline.createInterface({ input: this.stdin }); private permissionRequestId = 700; lastPermissionSelection: string | undefined; + private readonly promptResult: Record; + private readonly messageChunkContent: unknown; - constructor() { + constructor(options?: { promptResult?: Record; messageChunkContent?: unknown }) { super(); + this.promptResult = options?.promptResult ?? { + stopReason: "end_turn", + usage: { + input_tokens: 12, + output_tokens: 34, + total_tokens: 46, + }, + }; + this.messageChunkContent = options?.messageChunkContent ?? { + type: "text", + text: "hello", + }; this.input.on("line", (line) => { const message = JSON.parse(line) as Record; if (typeof message.method === "string") { @@ -173,10 +187,7 @@ class FakeCursorAcpProcess extends EventEmitter { sessionId: "acp-session-1", update: { sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "hello", - }, + content: this.messageChunkContent, }, }, }); @@ -217,9 +228,7 @@ class FakeCursorAcpProcess extends EventEmitter { this.emitServerMessage({ jsonrpc: "2.0", id, - result: { - stopReason: "end_turn", - }, + result: this.promptResult, }); return; } @@ -251,6 +260,23 @@ class FakeCursorAcpProcess extends EventEmitter { } describe("CursorAdapterLive", () => { + it("parses plain-text agent model output", () => { + assert.deepEqual( + parseCursorModelCommandOutput(`\u001b[2K\u001b[GLoading models… +\u001b[2K\u001b[1A\u001b[2K\u001b[GAvailable models + +gpt-5.4-medium - GPT-5.4 +gpt-5.4-high-fast - GPT-5.4 High Fast (current) +opus-4.6-thinking - Claude 4.6 Opus (Thinking) (default) +`), + [ + { slug: "gpt-5.4-medium", name: "GPT-5.4" }, + { slug: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" }, + ], + ); + }); + it.effect("returns validation error for non-cursor provider on startSession", () => { const fake = new FakeCursorAcpProcess(); const layer = makeCursorAdapterLive({ @@ -339,10 +365,63 @@ describe("CursorAdapterLive", () => { assert.equal(completion?.type, "turn.completed"); if (completion?.type === "turn.completed") { assert.equal(completion.payload.state, "completed"); + assert.deepEqual(completion.payload.usage, { + input_tokens: 12, + output_tokens: 34, + total_tokens: 46, + }); } }).pipe(Effect.provide(layer)); }); + it.effect("extracts assistant text from structured Cursor chunk envelopes", () => { + const fake = new FakeCursorAcpProcess({ + messageChunkContent: { + type: "content_block", + parts: [ + { + type: "text", + text: "hello", + }, + ], + }, + }); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const assistantDelta = events.find( + (event) => + event.type === "content.delta" && + event.payload.streamKind === "assistant_text" && + event.payload.delta === "hello", + ); + assert.equal(assistantDelta?.type, "content.delta"); + }).pipe(Effect.provide(layer)); + }); + it.effect("passes requested model to ACP process startup", () => { const fake = new FakeCursorAcpProcess(); let createProcessInput: @@ -652,4 +731,54 @@ describe("CursorAdapterLive", () => { assert.equal(completed.payload.itemType, "command_execution"); }).pipe(Effect.provide(layer)); }); + + it.effect("completes the turn when Cursor prompt completion lacks a stop reason", () => { + const fake = new FakeCursorAcpProcess({ + promptResult: { + usage: { + input_tokens: 12, + output_tokens: 34, + }, + }, + }); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + threadId: THREAD_ID, + runtimeMode: "full-access", + }); + + const result = yield* adapter + .sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Success"); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completion = events.find((event) => event.type === "turn.completed"); + assert.equal(completion?.type, "turn.completed"); + if (completion?.type === "turn.completed") { + assert.equal(completion.payload.state, "completed"); + } + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions[0]?.status, "ready"); + assert.equal(sessions[0]?.activeTurnId, undefined); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 15423b0c7c..9447109dd2 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,7 +7,7 @@ * @module CursorAdapterLive */ import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import { @@ -48,7 +48,11 @@ import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogg const PROVIDER = "cursor" as const; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +// Cursor resolves `session/prompt` only when the turn fully finishes, so it +// needs a much longer timeout than ordinary ACP RPCs. +const CURSOR_PROMPT_TIMEOUT_MS = 60 * 60_000; const CURSOR_ACP_PROTOCOL_VERSION = 1; +const CURSOR_MODEL_DISCOVERY_TIMEOUT_MS = 8_000; interface CursorResumeState { readonly acpSessionId?: string; @@ -92,6 +96,138 @@ interface CursorSessionContext { stopping: boolean; } +export interface CursorModelDiscoveryOptions { + readonly binaryPath?: string; + readonly cwd?: string; + readonly timeoutMs?: number; +} + +function parseCursorModelsFromUnknown(value: unknown): Array<{ slug: string; name: string }> { + if (!value) return []; + if (!Array.isArray(value)) return []; + const models: Array<{ slug: string; name: string }> = []; + for (const entry of value) { + if (typeof entry === "string" && entry.trim().length > 0) { + const slug = entry.trim(); + models.push({ slug, name: slug }); + continue; + } + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const slugCandidate = record.id ?? record.slug ?? record.model ?? record.name; + if (typeof slugCandidate !== "string" || slugCandidate.trim().length === 0) { + continue; + } + const slug = slugCandidate.trim(); + const nameCandidate = record.name ?? record.displayName ?? record.label ?? slug; + const name = typeof nameCandidate === "string" && nameCandidate.trim().length > 0 ? nameCandidate.trim() : slug; + models.push({ slug, name }); + } + return models; +} + +const ANSI_CONTROL_SEQUENCE_PATTERN = new RegExp( + String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, + "g", +); + +function stripAnsiControlSequences(value: string): string { + return value.replace(ANSI_CONTROL_SEQUENCE_PATTERN, ""); +} + +function parseCursorModelsFromPlainText(stdout: string): Array<{ slug: string; name: string }> { + const normalized = stripAnsiControlSequences(stdout); + const models: Array<{ slug: string; name: string }> = []; + for (const line of normalized.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes(" - ")) { + continue; + } + const match = /^(?[a-z0-9./-]+)\s+-\s+(?.+?)(?:\s+\((?:current|default)\))*$/i.exec( + trimmed, + ); + if (!match?.groups) { + continue; + } + const slug = match.groups.slug?.trim(); + const name = match.groups.name?.trim(); + if (!slug || !name) { + continue; + } + models.push({ slug, name }); + } + return models; +} + +export function parseCursorModelCommandOutput(stdout: string): Array<{ slug: string; name: string }> { + const trimmed = stdout.trim(); + if (trimmed.length === 0) return []; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record; + const nested = record.models ?? record.data ?? record.items; + const nestedModels = parseCursorModelsFromUnknown(nested); + if (nestedModels.length > 0) { + return nestedModels; + } + } + return parseCursorModelsFromUnknown(parsed); + } catch { + return parseCursorModelsFromPlainText(stdout); + } +} + +function runCursorModelCommand( + binaryPath: string, + args: ReadonlyArray, + options: CursorModelDiscoveryOptions, +): Promise { + return new Promise((resolve, reject) => { + execFile( + binaryPath, + [...args], + { + ...(options.cwd ? { cwd: options.cwd } : {}), + env: process.env, + timeout: options.timeoutMs ?? CURSOR_MODEL_DISCOVERY_TIMEOUT_MS, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +export async function fetchCursorModels( + options: CursorModelDiscoveryOptions = {}, +): Promise> { + const binaryPath = options.binaryPath ?? "agent"; + const commands: ReadonlyArray> = [ + ["models", "--json"], + ["models", "list", "--json"], + ["models"], + ]; + for (const args of commands) { + try { + const stdout = await runCursorModelCommand(binaryPath, args, options); + const models = parseCursorModelCommandOutput(stdout); + if (models.length > 0) { + return models; + } + } catch { + // Try next command shape. + } + } + return []; +} + export interface CursorAdapterLiveOptions { readonly createProcess?: (input: { readonly binaryPath: string; @@ -172,6 +308,92 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function appendChunkText(fragments: string[], value: unknown): void { + if (typeof value === "string" && value.length > 0) { + fragments.push(value); + } +} + +function extractChunkTextFromPart(value: unknown): string { + if (typeof value === "string") { + return value; + } + const part = asObject(value); + if (!part) { + return ""; + } + const fragments: string[] = []; + appendChunkText(fragments, part.text); + appendChunkText(fragments, part.delta); + appendChunkText(fragments, part.value); + appendChunkText(fragments, part.content); + return fragments.join(""); +} + +function extractCursorChunkText(update: unknown): string { + const updateRecord = asObject(update); + if (!updateRecord) { + return ""; + } + + const fragments: string[] = []; + appendChunkText(fragments, updateRecord.text); + appendChunkText(fragments, updateRecord.delta); + + const content = updateRecord.content; + if (typeof content === "string") { + fragments.push(content); + return fragments.join(""); + } + + if (Array.isArray(content)) { + for (const part of content) { + appendChunkText(fragments, extractChunkTextFromPart(part)); + } + return fragments.join(""); + } + + const contentRecord = asObject(content); + if (!contentRecord) { + return fragments.join(""); + } + + appendChunkText(fragments, contentRecord.text); + appendChunkText(fragments, contentRecord.delta); + appendChunkText(fragments, contentRecord.value); + + const nestedLists = [ + contentRecord.parts, + contentRecord.blocks, + contentRecord.chunks, + contentRecord.items, + contentRecord.content, + contentRecord.messages, + ]; + for (const list of nestedLists) { + if (!Array.isArray(list)) { + continue; + } + for (const part of list) { + appendChunkText(fragments, extractChunkTextFromPart(part)); + } + } + + const nestedMessage = asObject(contentRecord.message); + if (nestedMessage) { + appendChunkText(fragments, nestedMessage.text); + appendChunkText(fragments, nestedMessage.delta); + appendChunkText(fragments, nestedMessage.value); + if (Array.isArray(nestedMessage.content)) { + for (const part of nestedMessage.content) { + appendChunkText(fragments, extractChunkTextFromPart(part)); + } + } + } + + return fragments.join(""); +} + function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType { const normalizedKind = asString(kind)?.toLowerCase(); const normalizedTitle = asString(title)?.toLowerCase(); @@ -433,6 +655,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { state: "completed" | "failed" | "interrupted" | "cancelled", errorMessage?: string, stopReason?: string, + usage?: unknown, ): Effect.Effect => Effect.gen(function* () { const turnState = context.turnState; @@ -476,6 +699,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { payload: { state, ...(stopReason ? { stopReason } : {}), + ...(usage !== undefined ? { usage } : {}), ...(errorMessage ? { errorMessage } : {}), }, providerRefs: { @@ -660,7 +884,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "agent_thought_chunk": { if (!context.turnState) return; - if (update.content.text.length === 0) return; + const text = extractCursorChunkText(update); + if (text.length === 0) return; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -671,7 +896,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { streamKind: "reasoning_text", - delta: update.content.text, + delta: text, }, }); return; @@ -679,7 +904,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "agent_message_chunk": { if (!context.turnState) return; - if (update.content.text.length === 0) return; + const text = extractCursorChunkText(update); + if (text.length === 0) return; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -690,7 +916,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { itemId: asRuntimeItemId(context.turnState.assistantItemId), payload: { streamKind: "assistant_text", - delta: update.content.text, + delta: text, }, }); return; @@ -1063,6 +1289,12 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { } Effect.runFork( Effect.gen(function* () { + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Cursor ACP process exited unexpectedly.")); + } + context.pending.clear(); + if (context.turnState) { yield* completeTurn( context, @@ -1322,38 +1554,47 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }); - const promptResultRaw = yield* Effect.tryPromise({ - try: async () => - sendRequest(context, "session/prompt", { - sessionId: context.acpSessionId, - prompt: [{ type: "text", text: promptText }], - }), - catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), - }); + yield* Effect.gen(function* () { + const promptResultRaw = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/prompt", { + sessionId: context.acpSessionId, + prompt: [{ type: "text", text: promptText }], + }, CURSOR_PROMPT_TIMEOUT_MS), + catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), + }); - const promptResult = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw), - catch: (cause) => - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Cursor session/prompt response did not match expected schema.", - cause, + return yield* Effect.try({ + try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw), + catch: (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor session/prompt response did not match expected schema.", + cause, + }), + }); + }).pipe( + Effect.tap((result) => { + const turnStateValue = mapStopReasonToTurnState(result.stopReason); + return completeTurn( + context, + turnStateValue, + turnStateValue === "failed" ? "Cursor prompt failed." : undefined, + result.stopReason, + result.usage, + ); + }), + Effect.catch((error) => + Effect.gen(function* () { + yield* completeTurn(context, "failed", toMessage(error, "Cursor prompt failed.")); + return yield* error; }), - }); - const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason); - yield* completeTurn( - context, - turnStateValue, - turnStateValue === "failed" ? "Cursor prompt failed." : undefined, - promptResult.stopReason, + ), ); context.session = { ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt: yield* nowIso, resumeCursor: { acpSessionId: context.acpSessionId, }, diff --git a/apps/server/src/provider/Layers/CursorUsage.test.ts b/apps/server/src/provider/Layers/CursorUsage.test.ts new file mode 100644 index 0000000000..b2f6f45fc0 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorUsage.test.ts @@ -0,0 +1,71 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseCursorUsageQuota } from "./CursorUsage.ts"; + +const RESET_DATE = new Date(1772906400000).toISOString(); + +describe("CursorUsage", () => { + it("maps current period usage into a sidebar quota", () => { + const quota = parseCursorUsageQuota({ + planInfo: { + planInfo: { + planName: "Pro", + }, + }, + currentPeriod: { + billingCycleEnd: "1772906400000", + planUsage: { + totalPercentUsed: 29.38888888888889, + }, + }, + billingCycle: { + endDateEpochMillis: "1772906400000", + }, + }); + + assert.deepStrictEqual(quota, { + plan: "Pro", + percentUsed: 29.38888888888889, + resetDate: RESET_DATE, + }); + }); + + it("falls back to billing cycle metadata when the current period omits a reset time", () => { + const quota = parseCursorUsageQuota({ + planInfo: { + planInfo: { + planName: "Pro", + }, + }, + currentPeriod: { + planUsage: { + totalPercentUsed: 58.6, + }, + }, + billingCycle: { + endDateEpochMillis: "1772906400000", + }, + }); + + assert.deepStrictEqual(quota, { + plan: "Pro", + percentUsed: 58.6, + resetDate: RESET_DATE, + }); + }); + + it("returns undefined when Cursor does not expose a usable percent", () => { + const quota = parseCursorUsageQuota({ + planInfo: { + planInfo: { + planName: "Pro", + }, + }, + currentPeriod: { + planUsage: {}, + }, + }); + + assert.strictEqual(quota, undefined); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorUsage.ts b/apps/server/src/provider/Layers/CursorUsage.ts new file mode 100644 index 0000000000..93905be860 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorUsage.ts @@ -0,0 +1,274 @@ +import { execFile } from "node:child_process"; +import { homedir, platform } from "node:os"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import type { ProviderUsageQuota, ProviderUsageResult } from "@t3tools/contracts"; + +// Security note: This module accesses Cursor auth data (including access +// tokens from the macOS Keychain and auth.json). Auth tokens MUST NOT be +// logged or included in error messages. The keychain access is gated behind +// a platform check and gracefully falls back when unavailable. + +const PROVIDER = "cursor" as const; +const CURSOR_AUTH_NAMESPACE = "cursor"; +const CURSOR_API_BASE_URL = process.env.CURSOR_API_BASE_URL?.trim() || "https://api2.cursor.sh"; +const CURSOR_KEYCHAIN_ACCOUNT = "cursor-user"; +const CURSOR_KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token"; +const CURSOR_KEYCHAIN_API_KEY_SERVICE = "cursor-api-key"; +const ACCESS_TOKEN_REFRESH_BUFFER_MS = 5 * 60_000; + +interface CursorAuthData { + readonly accessToken?: string; + readonly apiKey?: string; +} + +interface CursorPlanInfoResponse { + readonly planInfo?: { + readonly planName?: string; + readonly billingCycleEnd?: string; + }; +} + +interface CursorCurrentPeriodUsageResponse { + readonly billingCycleEnd?: string; + readonly planUsage?: { + readonly totalPercentUsed?: number; + readonly apiPercentUsed?: number; + }; +} + +interface CursorBillingCycleResponse { + readonly endDateEpochMillis?: string; +} + +function execFileText(command: string, args: ReadonlyArray): Promise { + return new Promise((resolve, reject) => { + execFile(command, [...args], { env: process.env }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +function normalizeNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function decodeJwtExpirationMs(token: string): number | undefined { + try { + const [, payloadSegment] = token.split("."); + if (!payloadSegment) { + return undefined; + } + const payloadJson = Buffer.from(payloadSegment, "base64url").toString("utf8"); + const payload = JSON.parse(payloadJson) as { exp?: unknown }; + return typeof payload.exp === "number" ? payload.exp * 1000 : undefined; + } catch { + return undefined; + } +} + +function hasFreshAccessToken(token: string | undefined): token is string { + if (!token) { + return false; + } + const expirationMs = decodeJwtExpirationMs(token); + if (expirationMs === undefined) { + return true; + } + return expirationMs - Date.now() > ACCESS_TOKEN_REFRESH_BUFFER_MS; +} + +function cursorAuthFilePath(): string { + switch (platform()) { + case "win32": { + const appData = process.env.APPDATA || path.join(homedir(), "AppData", "Roaming"); + return path.join(appData, "Cursor", "auth.json"); + } + case "darwin": + return path.join(homedir(), `.${CURSOR_AUTH_NAMESPACE}`, "auth.json"); + default: { + const configHome = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"); + return path.join(configHome, CURSOR_AUTH_NAMESPACE, "auth.json"); + } + } +} + +async function readCursorAuthFile(): Promise { + try { + const raw = await readFile(cursorAuthFilePath(), "utf8"); + const parsed = JSON.parse(raw) as { + accessToken?: unknown; + apiKey?: unknown; + }; + const accessToken = normalizeNonEmptyString(parsed.accessToken); + const apiKey = normalizeNonEmptyString(parsed.apiKey); + const auth: { accessToken?: string; apiKey?: string } = {}; + if (accessToken !== undefined) { + auth.accessToken = accessToken; + } + if (apiKey !== undefined) { + auth.apiKey = apiKey; + } + return auth; + } catch { + return {}; + } +} + +async function readMacOsKeychainSecret(service: string): Promise { + if (platform() !== "darwin") { + return undefined; + } + try { + const stdout = await execFileText("security", [ + "find-generic-password", + "-a", + CURSOR_KEYCHAIN_ACCOUNT, + "-s", + service, + "-w", + ]); + return normalizeNonEmptyString(stdout); + } catch { + return undefined; + } +} + +async function readCursorAuthData(): Promise { + const fileAuth = await readCursorAuthFile(); + if (hasFreshAccessToken(fileAuth.accessToken)) { + return fileAuth; + } + const [accessToken, apiKey] = await Promise.all([ + readMacOsKeychainSecret(CURSOR_KEYCHAIN_ACCESS_TOKEN_SERVICE), + readMacOsKeychainSecret(CURSOR_KEYCHAIN_API_KEY_SERVICE), + ]); + return { + ...(fileAuth.accessToken ? { accessToken: fileAuth.accessToken } : {}), + ...(fileAuth.apiKey ? { apiKey: fileAuth.apiKey } : {}), + ...(accessToken ? { accessToken } : {}), + ...(apiKey ? { apiKey } : {}), + }; +} + +async function exchangeCursorApiKey(apiKey: string): Promise { + const response = await fetch(`${CURSOR_API_BASE_URL}/auth/exchange_user_api_key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({}), + }); + if (!response.ok) { + return undefined; + } + const parsed = (await response.json()) as { + accessToken?: unknown; + }; + return normalizeNonEmptyString(parsed.accessToken); +} + +async function resolveCursorAccessToken(): Promise { + const auth = await readCursorAuthData(); + if (hasFreshAccessToken(auth.accessToken)) { + return auth.accessToken; + } + if (auth.apiKey) { + return exchangeCursorApiKey(auth.apiKey); + } + return auth.accessToken; +} + +async function postCursorDashboard( + method: string, + accessToken: string, + body: Record, +): Promise { + const response = await fetch(`${CURSOR_API_BASE_URL}/aiserver.v1.DashboardService/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`Cursor dashboard ${method} failed with status ${response.status}.`); + } + return (await response.json()) as TResponse; +} + +function epochMillisToIsoString(value: unknown): string | undefined { + const raw = + typeof value === "string" + ? Number(value) + : typeof value === "number" + ? value + : undefined; + if (raw === undefined || !Number.isFinite(raw)) { + return undefined; + } + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function normalizePercent(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(0, Math.min(100, value)); +} + +export function parseCursorUsageQuota(input: { + readonly planInfo?: CursorPlanInfoResponse | null; + readonly currentPeriod?: CursorCurrentPeriodUsageResponse | null; + readonly billingCycle?: CursorBillingCycleResponse | null; +}): ProviderUsageQuota | undefined { + const percentUsed = normalizePercent(input.currentPeriod?.planUsage?.totalPercentUsed); + if (percentUsed === undefined) { + return undefined; + } + const plan = normalizeNonEmptyString(input.planInfo?.planInfo?.planName); + const resetDate = + epochMillisToIsoString(input.currentPeriod?.billingCycleEnd) ?? + epochMillisToIsoString(input.planInfo?.planInfo?.billingCycleEnd) ?? + epochMillisToIsoString(input.billingCycle?.endDateEpochMillis); + return { + ...(plan ? { plan } : {}), + percentUsed, + ...(resetDate ? { resetDate } : {}), + }; +} + +export async function fetchCursorUsage(): Promise { + const accessToken = await resolveCursorAccessToken(); + if (!accessToken) { + return { provider: PROVIDER }; + } + + const [planInfo, currentPeriod, billingCycle] = await Promise.all([ + postCursorDashboard("GetPlanInfo", accessToken, {}).catch(() => null), + postCursorDashboard("GetCurrentPeriodUsage", accessToken, {}).catch( + () => null, + ), + postCursorDashboard("GetCurrentBillingCycle", accessToken, {}).catch( + () => null, + ), + ]); + + const quota = parseCursorUsageQuota({ planInfo, currentPeriod, billingCycle }); + return { + provider: PROVIDER, + ...(quota ? { quota } : {}), + }; +} diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 98137179c8..7d179fce9a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -10,12 +10,17 @@ */ import type { ServerProviderAuthStatus, + ServerProviderModel, + ServerProviderQuotaSnapshot, ServerProviderStatus, ServerProviderStatusState, } from "@t3tools/contracts"; +import { CopilotClient, type ModelInfo } from "@github/copilot-sdk"; import { Effect, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; + import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, @@ -26,6 +31,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const GEMINI_CLI_PROVIDER = "geminiCli" as const; +const COPILOT_PROVIDER = "copilot" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -397,17 +403,140 @@ export const checkGeminiCliProviderStatus: Effect.Effect< }; }); +// ── Copilot health check ───────────────────────────────────────────── + +interface CopilotHealthProbeError { + readonly _tag: "CopilotHealthProbeError"; + readonly cause: unknown; +} + +const COPILOT_QUOTA_PRIORITY = ["premium_interactions", "chat", "completions"] as const; + +export function mapCopilotModel(model: ModelInfo): ServerProviderModel { + return { + id: model.id, + name: model.name, + supportsReasoningEffort: (model.supportedReasoningEfforts?.length ?? 0) > 0, + ...(model.supportedReasoningEfforts && model.supportedReasoningEfforts.length > 0 + ? { supportedReasoningEfforts: [...model.supportedReasoningEfforts] } + : {}), + ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), + ...(typeof model.billing?.multiplier === "number" + ? { billingMultiplier: model.billing.multiplier } + : {}), + } satisfies ServerProviderModel; +} + +interface CopilotQuotaSnapshotInfo { + readonly entitlementRequests: number; + readonly usedRequests: number; + readonly remainingPercentage: number; + readonly overage: number; + readonly overageAllowedWithExhaustedQuota: boolean; + readonly resetDate?: string; +} + +function compareCopilotQuotaKeys(left: string, right: string): number { + const leftPriority = COPILOT_QUOTA_PRIORITY.indexOf(left as (typeof COPILOT_QUOTA_PRIORITY)[number]); + const rightPriority = COPILOT_QUOTA_PRIORITY.indexOf(right as (typeof COPILOT_QUOTA_PRIORITY)[number]); + const normalizedLeftPriority = leftPriority === -1 ? Number.POSITIVE_INFINITY : leftPriority; + const normalizedRightPriority = rightPriority === -1 ? Number.POSITIVE_INFINITY : rightPriority; + return normalizedLeftPriority - normalizedRightPriority || left.localeCompare(right); +} + +export function mapCopilotQuotaSnapshots( + quotaSnapshots: Record | undefined, +): ReadonlyArray { + if (!quotaSnapshots) return []; + return Object.entries(quotaSnapshots) + .toSorted(([leftKey], [rightKey]) => compareCopilotQuotaKeys(leftKey, rightKey)) + .map(([key, snapshot]) => { + const entitlementRequests = Math.max(0, Math.trunc(snapshot.entitlementRequests)); + const usedRequests = Math.max(0, Math.trunc(snapshot.usedRequests)); + const base = { + key, + entitlementRequests, + usedRequests, + remainingRequests: Math.max(0, entitlementRequests - usedRequests), + remainingPercentage: Math.max(0, Math.min(100, snapshot.remainingPercentage)), + overage: Math.max(0, Math.trunc(snapshot.overage)), + overageAllowedWithExhaustedQuota: snapshot.overageAllowedWithExhaustedQuota, + }; + return (snapshot.resetDate + ? Object.assign(base, { resetDate: snapshot.resetDate }) + : base) satisfies ServerProviderQuotaSnapshot; + }); +} + +export const checkCopilotProviderStatus: Effect.Effect = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const cliPath = resolveBundledCopilotCliPath(); + + const probeResult = yield* Effect.tryPromise({ + try: async () => { + const client = new CopilotClient({ + ...(cliPath ? { cliPath } : {}), + logLevel: "error", + }); + try { + await client.start(); + const models = await client.listModels(); + const quotaSnapshots = await (async () => { + try { + const clientRecord = client as unknown as Record; + if (typeof clientRecord.getQuotaInfo === "function") { + return (clientRecord.getQuotaInfo as () => Promise | undefined>)(); + } + return undefined; + } catch { + return undefined; + } + })(); + return { models, quotaSnapshots } as { + models: ModelInfo[]; + quotaSnapshots: Record | undefined; + }; + } finally { + await client.stop().catch(() => []); + } + }, + catch: (cause): CopilotHealthProbeError => ({ _tag: "CopilotHealthProbeError", cause }), + }).pipe(Effect.timeout(10_000), Effect.option); + + if (Option.isNone(probeResult)) { + return { + provider: COPILOT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "GitHub Copilot health probe timed out or failed.", + }; + } + + const { models, quotaSnapshots } = probeResult.value; + return { + provider: COPILOT_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + checkedAt, + models: models.map(mapCopilotModel), + quotaSnapshots: mapCopilotQuotaSnapshots(quotaSnapshots), + }; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const [codexStatus, geminiCliStatus] = yield* Effect.all( - [checkCodexProviderStatus, checkGeminiCliProviderStatus], + const [codexStatus, geminiCliStatus, copilotStatus] = yield* Effect.all( + [checkCodexProviderStatus, checkGeminiCliProviderStatus, checkCopilotProviderStatus], { concurrency: "unbounded" }, ); return { - getStatuses: Effect.succeed([codexStatus, geminiCliStatus]), + getStatuses: Effect.succeed([codexStatus, geminiCliStatus, copilotStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 398a26fb7b..78775d8315 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -86,12 +86,16 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st } } -function toRuntimePayloadFromSession(session: ProviderSession): Record { +function toRuntimePayloadFromSession( + session: ProviderSession, + extra?: { readonly providerOptions?: unknown }, +): Record { return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, + ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), }; } @@ -107,6 +111,17 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedProviderOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): Record | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + return raw as Record; +} + const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const analytics = yield* Effect.service(AnalyticsService); @@ -137,6 +152,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, + extra?: { readonly providerOptions?: unknown }, ) => directory.upsert({ threadId, @@ -144,7 +160,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => runtimeMode: session.runtimeMode, status: toRuntimeStatus(session), ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session), + runtimePayload: toRuntimePayloadFromSession(session, extra), }); const providers = yield* registry.listProviders(); @@ -197,12 +213,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + ...(persistedProviderOptions !== undefined ? { providerOptions: persistedProviderOptions } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { @@ -273,7 +291,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding(session, threadId); + yield* upsertSessionBinding( + session, + threadId, + parsed.providerOptions !== undefined ? { providerOptions: parsed.providerOptions } : undefined, + ); yield* analytics.record("provider.session.started", { provider: session.provider, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts index f7b5499fa9..e791827720 100644 --- a/apps/server/src/provider/Layers/copilotCliPath.ts +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); @@ -42,11 +42,7 @@ export function normalizeCopilotCliPathOverride(value: string | null | undefined const trimmed = value.trim(); if (!trimmed) return undefined; - if ( - !trimmed.includes("/") && - !trimmed.includes("\\") && - COPILOT_PATHLESS_COMMAND_PATTERN.test(trimmed) - ) { + if (!trimmed.includes("/") && !trimmed.includes("\\") && COPILOT_PATHLESS_COMMAND_PATTERN.test(trimmed)) { return undefined; } @@ -81,12 +77,25 @@ export function getBundledCopilotPlatformPackages( platform: string = process.platform, arch: string = process.arch, ): ReadonlyArray { - if (platform === "darwin" && arch === "arm64") return ["copilot-darwin-arm64"]; - if (platform === "darwin" && arch === "x64") return ["copilot-darwin-x64"]; - if (platform === "linux" && arch === "arm64") return ["copilot-linux-arm64"]; - if (platform === "linux" && arch === "x64") return ["copilot-linux-x64"]; - if (platform === "win32" && arch === "arm64") return ["copilot-win32-arm64"]; - if (platform === "win32" && arch === "x64") return ["copilot-win32-x64"]; + if (platform === "darwin" && arch === "arm64") { + return ["copilot-darwin-arm64"]; + } + if (platform === "darwin" && arch === "x64") { + return ["copilot-darwin-x64"]; + } + if (platform === "linux" && arch === "arm64") { + return ["copilot-linux-arm64"]; + } + if (platform === "linux" && arch === "x64") { + return ["copilot-linux-x64"]; + } + if (platform === "win32" && arch === "arm64") { + return ["copilot-win32-arm64"]; + } + if (platform === "win32" && arch === "x64") { + return ["copilot-win32-x64"]; + } + return []; } diff --git a/apps/server/src/provider/Layers/copilotTurnTracking.test.ts b/apps/server/src/provider/Layers/copilotTurnTracking.test.ts new file mode 100644 index 0000000000..16f7820e23 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotTurnTracking.test.ts @@ -0,0 +1,59 @@ +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + assistantUsageFields, + beginCopilotTurn, + clearTurnTracking, + isCopilotTurnTerminalEvent, + markTurnAwaitingCompletion, + recordTurnUsage, + type CopilotTurnTrackingState, +} from "./copilotTurnTracking.ts"; + +function makeState(): CopilotTurnTrackingState { + return { + currentTurnId: undefined, + currentProviderTurnId: undefined, + pendingCompletionTurnId: undefined, + pendingCompletionProviderTurnId: undefined, + pendingTurnIds: [], + pendingTurnUsage: undefined, + }; +} + +describe("copilotTurnTracking", () => { + it("keeps turn tracking alive until session.idle", () => { + expect(isCopilotTurnTerminalEvent({ type: "assistant.usage" } as never)).toBe(false); + expect(isCopilotTurnTerminalEvent({ type: "session.idle" } as never)).toBe(true); + expect(isCopilotTurnTerminalEvent({ type: "abort" } as never)).toBe(true); + }); + + it("preserves usage details for the eventual turn completion event", () => { + const state = makeState(); + state.pendingTurnIds.push(TurnId.makeUnsafe("turn-1")); + + beginCopilotTurn(state, TurnId.makeUnsafe("provider-turn-1")); + recordTurnUsage(state, { + model: "gpt-4.1", + cost: 0.42, + totalTokens: 123, + } as never); + markTurnAwaitingCompletion(state); + + expect(assistantUsageFields(state.pendingTurnUsage)).toEqual({ + usage: { + model: "gpt-4.1", + cost: 0.42, + totalTokens: 123, + }, + modelUsage: { model: "gpt-4.1" }, + totalCostUsd: 0.42, + }); + + clearTurnTracking(state); + expect(state.pendingTurnUsage).toBeUndefined(); + expect(state.currentTurnId).toBeUndefined(); + expect(state.pendingCompletionTurnId).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/copilotTurnTracking.ts b/apps/server/src/provider/Layers/copilotTurnTracking.ts new file mode 100644 index 0000000000..76ed15ddf3 --- /dev/null +++ b/apps/server/src/provider/Layers/copilotTurnTracking.ts @@ -0,0 +1,74 @@ +import { TurnId } from "@t3tools/contracts"; +import type { SessionEvent } from "@github/copilot-sdk"; + +export type CopilotAssistantUsage = Extract["data"]; + +export interface CopilotTurnTrackingState { + currentTurnId: TurnId | undefined; + currentProviderTurnId: TurnId | undefined; + pendingCompletionTurnId: TurnId | undefined; + pendingCompletionProviderTurnId: TurnId | undefined; + pendingTurnIds: Array; + pendingTurnUsage: CopilotAssistantUsage | undefined; +} + +export function completionTurnRefs(state: CopilotTurnTrackingState) { + return { + turnId: state.pendingCompletionTurnId ?? state.currentTurnId, + providerTurnId: state.pendingCompletionProviderTurnId ?? state.currentProviderTurnId, + }; +} + +export function beginCopilotTurn( + state: CopilotTurnTrackingState, + providerTurnId: TurnId, +): void { + state.pendingCompletionTurnId = undefined; + state.pendingCompletionProviderTurnId = undefined; + state.pendingTurnUsage = undefined; + state.currentProviderTurnId = providerTurnId; + state.currentTurnId = state.pendingTurnIds.shift() ?? state.currentTurnId ?? providerTurnId; +} + +export function markTurnAwaitingCompletion(state: CopilotTurnTrackingState): void { + state.pendingCompletionTurnId = state.currentTurnId ?? state.pendingCompletionTurnId; + state.pendingCompletionProviderTurnId = + state.currentProviderTurnId ?? state.pendingCompletionProviderTurnId; +} + +export function recordTurnUsage( + state: CopilotTurnTrackingState, + usage: CopilotAssistantUsage, +): void { + state.pendingTurnUsage = usage; +} + +export function clearTurnTracking(state: CopilotTurnTrackingState): void { + state.currentTurnId = undefined; + state.currentProviderTurnId = undefined; + state.pendingCompletionTurnId = undefined; + state.pendingCompletionProviderTurnId = undefined; + state.pendingTurnUsage = undefined; +} + +export function assistantUsageFields( + usage: CopilotAssistantUsage | undefined, +): { + usage?: CopilotAssistantUsage; + modelUsage?: { model: string }; + totalCostUsd?: number; +} { + if (!usage) { + return {}; + } + + return { + usage, + ...(usage.cost !== undefined ? { totalCostUsd: usage.cost } : {}), + ...(usage.model ? { modelUsage: { model: usage.model } } : {}), + }; +} + +export function isCopilotTurnTerminalEvent(event: SessionEvent): boolean { + return event.type === "abort" || event.type === "session.idle" || event.type === "assistant.turn_end"; +} diff --git a/apps/server/src/provider/Services/CursorAdapter.test.ts b/apps/server/src/provider/Services/CursorAdapter.test.ts index 007a212e15..90e29c0de3 100644 --- a/apps/server/src/provider/Services/CursorAdapter.test.ts +++ b/apps/server/src/provider/Services/CursorAdapter.test.ts @@ -44,6 +44,30 @@ describe("Cursor ACP schemas", () => { expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); }); + it("decodes session/update message chunks with non-text content envelopes", () => { + const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "content_block", + parts: [ + { + type: "text", + text: "hello", + }, + ], + }, + }, + }, + }); + + expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); + }); + it("decodes tool call lifecycle updates", () => { const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ jsonrpc: "2.0", @@ -109,9 +133,32 @@ describe("Cursor ACP schemas", () => { it("decodes prompt completion result payload", () => { const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ stopReason: "end_turn", + usage: { + input_tokens: 10, + output_tokens: 24, + }, }); expect(decoded.stopReason).toBe("end_turn"); + expect(decoded.usage).toEqual({ + input_tokens: 10, + output_tokens: 24, + }); + }); + + it("accepts a prompt completion without stop reason", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ + usage: { + input_tokens: 10, + output_tokens: 24, + }, + }); + + expect(decoded.stopReason).toBeUndefined(); + expect(decoded.usage).toEqual({ + input_tokens: 10, + output_tokens: 24, + }); }); it("rejects unsupported update types", () => { diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts index 405ea3689b..695999ca7a 100644 --- a/apps/server/src/provider/Services/CursorAdapter.ts +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -13,12 +13,6 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]); export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type; -export const CursorAcpTextContent = Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, -}); -export type CursorAcpTextContent = typeof CursorAcpTextContent.Type; - export const CursorAcpSessionUpdate = Schema.Union([ Schema.Struct({ sessionUpdate: Schema.Literal("available_commands_update"), @@ -31,11 +25,15 @@ export const CursorAcpSessionUpdate = Schema.Union([ }), Schema.Struct({ sessionUpdate: Schema.Literal("agent_thought_chunk"), - content: CursorAcpTextContent, + content: Schema.optional(Schema.Unknown), + text: Schema.optional(Schema.String), + delta: Schema.optional(Schema.String), }), Schema.Struct({ sessionUpdate: Schema.Literal("agent_message_chunk"), - content: CursorAcpTextContent, + content: Schema.optional(Schema.Unknown), + text: Schema.optional(Schema.String), + delta: Schema.optional(Schema.String), }), Schema.Struct({ sessionUpdate: Schema.Literal("tool_call"), @@ -98,6 +96,7 @@ export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type; export const CursorAcpSessionPromptResult = Schema.Struct({ stopReason: Schema.optional(Schema.String), + usage: Schema.optional(Schema.Unknown), }); export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type; diff --git a/apps/server/src/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts index 3c3e03a4c0..0839ce2063 100644 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ b/apps/server/src/provider/claude-agent-sdk.d.ts @@ -87,19 +87,30 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly [key: string]: unknown; } + export type ThinkingConfig = + | { readonly type: "adaptive" } + | { readonly type: "enabled"; readonly budgetTokens?: number } + | { readonly type: "disabled" }; + + export type EffortLevel = "low" | "medium" | "high" | "max"; + export interface Options { readonly cwd?: string; readonly model?: string; readonly pathToClaudeCodeExecutable?: string; readonly permissionMode?: PermissionMode; readonly allowDangerouslySkipPermissions?: boolean; + /** @deprecated Use `thinking` instead. */ readonly maxThinkingTokens?: number; + readonly thinking?: ThinkingConfig; + readonly effort?: EffortLevel; readonly resume?: string; readonly resumeSessionAt?: string; readonly includePartialMessages?: boolean; readonly canUseTool?: CanUseTool; readonly env?: Record; readonly additionalDirectories?: ReadonlyArray; + readonly stderr?: (message: string) => void; } export type Query = AsyncIterable & { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8f9508af5..054c1b9761 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -38,6 +38,7 @@ import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; +import { SessionTextGenerationLive } from "./git/Layers/SessionTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; @@ -97,6 +98,7 @@ export function makeServerProviderLayer(): Layer.Layer< export function makeServerRuntimeServicesLayer() { const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); const textGenerationLayer = CodexTextGenerationLive; + const sessionTextGenerationLayer = SessionTextGenerationLive; const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -144,6 +146,7 @@ export function makeServerRuntimeServicesLayer() { Layer.provideMerge(gitCoreLayer), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(sessionTextGenerationLayer), ); return Layer.mergeAll( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 285028cca6..80d57d7c6f 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1333,9 +1333,7 @@ describe("WebSocket Server", () => { }; terminalManager.emitEvent(manualEvent); - const push = (await waitForMessage(ws)) as WsPush; - expect(push.type).toBe("push"); - expect(push.channel).toBe(WS_CHANNELS.terminalEvent); + const push = await waitForPush(ws, WS_CHANNELS.terminalEvent); expect((push.data as TerminalEvent).type).toBe("output"); }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 0f4b0fea5c..578a5609d7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -74,6 +74,8 @@ import { fetchGeminiCliUsage } from "./geminiCliServerManager.ts"; import { fetchKiloModels } from "./kiloServerManager.ts"; import { fetchOpenCodeModels } from "./opencodeServerManager.ts"; import { fetchCopilotModels, fetchCopilotUsage } from "./provider/Layers/CopilotAdapter.ts"; +import { fetchCursorModels } from "./provider/Layers/CursorAdapter.ts"; +import { fetchCursorUsage } from "./provider/Layers/CursorUsage.ts"; import { fetchClaudeCodeUsage } from "./provider/Layers/ClaudeCodeAdapter.ts"; import { fetchCodexUsage } from "./provider/Layers/CodexAdapter.ts"; import { @@ -932,6 +934,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { models: enriched } satisfies ProviderListModelsResult; } } + if (provider === "cursor") { + const dynamicModels = yield* Effect.tryPromise({ + try: () => fetchCursorModels(), + catch: () => null, + }); + if (dynamicModels && dynamicModels.length > 0) { + const models: ProviderModelOption[] = dynamicModels.map((m) => ({ + slug: m.slug, + name: m.name, + })); + return { models } satisfies ProviderListModelsResult; + } + } const staticModels = (MODEL_OPTIONS_BY_PROVIDER[provider] ?? []) as ReadonlyArray<{ slug: string; @@ -968,6 +983,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); return usage satisfies ProviderUsageResult; } + if (provider === "cursor") { + const usage = yield* Effect.tryPromise({ + try: () => fetchCursorUsage(), + catch: () => + new RouteRequestError({ + message: "Failed to fetch Cursor usage.", + }), + }); + return usage satisfies ProviderUsageResult; + } if (provider === "claudeCode") { return fetchClaudeCodeUsage() satisfies ProviderUsageResult; } diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..7537ae8149 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,7 +8,7 @@ T3 Code (Alpha) diff --git a/apps/web/package.json b/apps/web/package.json index 4372bf9e37..50675ba433 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "ghostty-web": "^0.4.0", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "^19.0.0", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index b5d7b5463e..29ca1eb0d8 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -95,6 +95,11 @@ export interface AppModelOption { isCustom: boolean; } +export interface BuiltInAppModelOption { + slug: string; + name: string; +} + export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { return serviceTier === "auto" ? null : serviceTier; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eea531b033..fa27bdaafb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,7 @@ import { type ApprovalRequestId, + CLAUDE_CODE_EFFORT_OPTIONS, + type ClaudeCodeEffort, DEFAULT_MODEL_BY_PROVIDER, CURSOR_REASONING_OPTIONS, EDITORS, @@ -16,6 +18,8 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, + type ServerProviderModel, + type ServerProviderQuotaSnapshot, type ServerProviderStatus, type ProviderKind, type ThreadId, @@ -25,13 +29,17 @@ import { ProviderInteractionMode, } from "@t3tools/contracts"; import { + getClaudeCodeEffortOptions, + getDefaultClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, + getModelOptions, getCursorModelCapabilities, getCursorModelFamilyOptions, getReasoningEffortOptions, normalizeModelSlug, parseCursorModelSelection, + resolveCursorPickerModelSlug, resolveCursorModelFromSelection, resolveModelSlugForProvider, } from "@t3tools/shared/model"; @@ -79,12 +87,15 @@ import { findLatestProposedPlan, type PendingApproval, type PendingUserInput, + type ProviderPickerKind, PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, + hasToolActivitySince, isLatestTurnSettled, formatElapsed, formatTimestamp, + type WorkLogEntry, } from "../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; import { @@ -98,6 +109,8 @@ import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, + downloadPlanAsTextFile, + normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; @@ -128,22 +141,35 @@ import { shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; +import CommandPalette from "./CommandPalette"; +import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import GhosttyTerminalSplitView from "./GhosttyTerminalSplitView"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, + DatabaseIcon, + EyeIcon, FileIcon, FolderIcon, DiffIcon, EllipsisIcon, FolderClosedIcon, + HammerIcon, + ListTodoIcon, LockIcon, LockOpenIcon, + type LucideIcon, + SearchIcon, + SquarePenIcon, + TargetIcon, + TerminalIcon, Undo2Icon, + WrenchIcon, XIcon, CopyIcon, CheckIcon, @@ -170,14 +196,21 @@ import { import { ClaudeAI, CursorIcon, + FleetIcon, Gemini, + GhosttyIcon, GitHubIcon, Icon, + IntelliJIcon, OpenAI, OpenCodeIcon, AmpIcon, KiloIcon, + PositronIcon, + SublimeTextIcon, VisualStudioCode, + WebStormIcon, + WindsurfIcon, Zed, } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; @@ -258,7 +291,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -300,20 +332,105 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } -function normalizePlanMarkdownForExport(planMarkdown: string): string { - return `${planMarkdown.trimEnd()}\n`; +function workToneIcon(tone: "thinking" | "tool" | "info" | "error") { + if (tone === "error") { + return { + icon: CircleAlertIcon, + className: "text-black/85 dark:text-white/90", + }; + } + if (tone === "thinking") { + return { + icon: BotIcon, + className: "text-black/85 dark:text-white/90", + }; + } + if (tone === "info") { + return { + icon: CheckIcon, + className: "text-black/85 dark:text-white/90", + }; + } + return { + icon: ZapIcon, + className: "text-black/85 dark:text-white/90", + }; } -function downloadTextFile(filename: string, contents: string): void { - const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - window.setTimeout(() => { - URL.revokeObjectURL(url); - }, 0); +function workEntryPreview(workEntry: { + detail?: string; + command?: string; + changedFiles?: ReadonlyArray; +}): string | null { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) > 0) { + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + } + return null; +} + +function workEntryIcon(workEntry: WorkLogEntry): LucideIcon { + if (workEntry.requestKind === "command") return TerminalIcon; + if (workEntry.requestKind === "file-read") return EyeIcon; + if (workEntry.requestKind === "file-change") return SquarePenIcon; + + const haystack = [workEntry.label, workEntry.detail, workEntry.command] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); + + if (haystack.includes("report_intent") || haystack.includes("intent logged")) { + return TargetIcon; + } + if ( + haystack.includes("bash") || + haystack.includes("read_bash") || + haystack.includes("write_bash") || + haystack.includes("stop_bash") || + haystack.includes("list_bash") + ) { + return TerminalIcon; + } + if (haystack.includes("sql")) return DatabaseIcon; + if (haystack.includes("view")) return EyeIcon; + if (haystack.includes("apply_patch")) return SquarePenIcon; + if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { + return SearchIcon; + } + if (haystack.includes("skill")) return ZapIcon; + if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; + if (haystack.includes("store_memory")) return FolderIcon; + if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; + if (haystack.includes("file")) return FileIcon; + + switch (workEntry.itemType) { + case "command_execution": + return TerminalIcon; + case "file_change": + return SquarePenIcon; + case "mcp_tool_call": + return WrenchIcon; + case "dynamic_tool_call": + case "collab_agent_tool_call": + return HammerIcon; + case "web_search": + return SearchIcon; + case "image_view": + return EyeIcon; + } + if (haystack.includes("task")) return HammerIcon; + + if (workEntry.activityKind === "turn.plan.updated") return ListTodoIcon; + if (workEntry.activityKind === "task.progress") return HammerIcon; + if (workEntry.activityKind === "approval.requested") return BotIcon; + if (workEntry.activityKind === "approval.resolved") return CheckIcon; + + return workToneIcon(workEntry.tone).icon; } interface ExpandedImageItem { @@ -627,6 +744,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); + const setComposerDraftClaudeCodeEffort = useComposerDraftStore( + (store) => store.setClaudeCodeEffort, + ); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -645,6 +765,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); + const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + // Tracks whether the user explicitly dismissed the sidebar for the active turn. + const planSidebarDismissedForTurnRef = useRef(null); + // When set, the thread-change reset effect will open the sidebar instead of closing it. + // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. + const planSidebarOpenOnNextThreadRef = useRef(false); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; @@ -664,7 +790,6 @@ export default function ChatView({ threadId }: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); @@ -704,6 +829,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const [ghosttySplitOpen, setGhosttySplitOpen] = useState(false); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); @@ -808,6 +934,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const assistantDeliveryMode = + settings.enableAssistantStreaming || selectedProvider === "cursor" + ? "streaming" + : "buffered"; const customModelsByProvider = useMemo( () => ({ codex: settings.customCodexModels, @@ -855,6 +985,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const claudeCodeEffortOptions = getClaudeCodeEffortOptions(selectedProvider); + const supportsClaudeCodeEffort = claudeCodeEffortOptions.length > 0; + const selectedClaudeCodeEffort = + composerDraft.claudeCodeEffort ?? getDefaultClaudeCodeEffort(selectedProvider); const selectedModelOptionsForDispatch = useMemo(() => { if (selectedProvider === "codex") { const codexOptions = { @@ -863,8 +997,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; } + if (selectedProvider === "claudeCode" && supportsClaudeCodeEffort && selectedClaudeCodeEffort) { + return { claudeCode: { effort: selectedClaudeCodeEffort } }; + } return undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + }, [selectedClaudeCodeEffort, selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsClaudeCodeEffort, supportsReasoningEffort]); const selectedCursorModel = useMemo( () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), [selectedModel, selectedProvider], @@ -884,6 +1021,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? selectedCursorModel.family : selectedModel; const copilotModelsQuery = useQuery(providerListModelsQueryOptions("copilot")); + const cursorModelsQuery = useQuery(providerListModelsQueryOptions("cursor")); const opencodeModelsQuery = useQuery(providerListModelsQueryOptions("opencode")); const kiloModelsQuery = useQuery(providerListModelsQueryOptions("kilo")); const geminiCliModelsQuery = useQuery(providerListModelsQueryOptions("geminiCli")); @@ -892,12 +1030,21 @@ export default function ChatView({ threadId }: ChatViewProps) { () => mergeDiscoveredModels(getCustomModelOptionsByProvider(settings), { copilot: copilotModelsQuery.data, + cursor: cursorModelsQuery.data, opencode: opencodeModelsQuery.data, kilo: kiloModelsQuery.data, geminiCli: geminiCliModelsQuery.data, amp: ampModelsQuery.data, }), - [settings, copilotModelsQuery.data, opencodeModelsQuery.data, kiloModelsQuery.data, geminiCliModelsQuery.data, ampModelsQuery.data], + [ + settings, + copilotModelsQuery.data, + cursorModelsQuery.data, + opencodeModelsQuery.data, + kiloModelsQuery.data, + geminiCliModelsQuery.data, + ampModelsQuery.data, + ], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { if (selectedProvider !== "cursor") { @@ -942,13 +1089,19 @@ export default function ChatView({ threadId }: ChatViewProps) { sendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const latestUserMessageCreatedAt = useMemo( + () => + [...(activeThread?.messages ?? [])].toReversed().find((message) => message.role === "user") + ?.createdAt, + [activeThread?.messages], + ); const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + () => deriveWorkLogEntries(threadActivities, undefined, latestUserMessageCreatedAt), + [latestUserMessageCreatedAt, threadActivities], ); const latestTurnHasToolActivity = useMemo( - () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), - [activeLatestTurn?.turnId, threadActivities], + () => hasToolActivitySince(threadActivities, latestUserMessageCreatedAt), + [latestUserMessageCreatedAt, threadActivities], ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), @@ -1717,6 +1870,37 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeProject, persistProjectScripts], ); + const deleteProjectScript = useCallback( + async (scriptId: string) => { + if (!activeProject) return; + const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + + const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + + try { + await persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.cwd, + previousScripts: activeProject.scripts, + nextScripts, + keybinding: null, + keybindingCommand: commandForProjectScript(scriptId), + }); + toastManager.add({ + type: "success", + title: `Deleted action "${deletedName ?? "Unknown"}"`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + } + }, + [activeProject, persistProjectScripts], + ); + const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; @@ -2004,10 +2188,6 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleStickToBottom(); }, [phase, scheduleStickToBottom, timelineEntries]); - useEffect(() => { - setExpandedWorkGroups({}); - }, [activeThread?.id]); - useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -2022,6 +2202,13 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setIsRevertingCheckpoint(false); + if (planSidebarOpenOnNextThreadRef.current) { + planSidebarOpenOnNextThreadRef.current = false; + setPlanSidebarOpen(true); + } else { + setPlanSidebarOpen(false); + } + planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); useEffect(() => { @@ -2733,7 +2920,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? { modelOptions: selectedModelOptionsForDispatch } : {}), provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode, runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2782,16 +2969,23 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; - const onInterrupt = async () => { + const onInterrupt = useCallback(async () => { const api = readNativeApi(); if (!api || !activeThread) return; - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - }); - }; + try { + await api.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to stop generation.", + ); + } + }, [activeThread, setThreadError]); const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { @@ -3010,11 +3204,18 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode, runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, }); + // Optimistically open the plan sidebar when implementing (not refining). + // "default" mode here means the agent is executing the plan, which produces + // step-tracking activities that the sidebar will display. + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } sendInFlightRef.current = false; } catch (err) { setOptimisticUserMessages((existing) => @@ -3044,7 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedServiceTier, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, + assistantDeliveryMode, ], ); @@ -3112,7 +3313,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode, runtimeMode, interactionMode: "default", createdAt, @@ -3121,6 +3322,8 @@ export default function ChatView({ threadId }: ChatViewProps) { .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { syncServerReadModel(snapshot); + // Signal that the plan sidebar should open on the new thread. + planSidebarOpenOnNextThreadRef.current = true; return navigate({ to: "/$threadId", params: { threadId: nextThreadId }, @@ -3163,7 +3366,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelOptionsForDispatch, selectedProvider, selectedServiceTier, - settings.enableAssistantStreaming, + assistantDeliveryMode, syncServerReadModel, ]); @@ -3247,6 +3450,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setComposerDraftEffort, threadId], ); + const onClaudeCodeEffortSelect = useCallback( + (effort: ClaudeCodeEffort) => { + setComposerDraftClaudeCodeEffort(threadId, effort); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftClaudeCodeEffort, threadId], + ); const onCodexFastModeChange = useCallback( (enabled: boolean) => { setComposerDraftCodexFastMode(threadId, enabled); @@ -3478,12 +3688,6 @@ export default function ChatView({ threadId }: ChatViewProps) { } return false; }; - const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((existing) => ({ - ...existing, - [groupId]: !existing[groupId], - })); - }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3539,6 +3743,35 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
+ + void handleRuntimeModeChange( + runtimeMode === "full-access" ? "approval-required" : "full-access", + ) + } + onInterrupt={onInterrupt} + onRunProjectScript={(script) => runProjectScript(script)} + ghosttySplitOpen={ghosttySplitOpen} + onToggleGhosttySplit={() => setGhosttySplitOpen((v) => !v)} + /> + {/* Top bar */}
{/* Error banner */} - + setThreadError(activeThread.id, null)} + /> + {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} +
{/* Messages */}
) : showPlanFollowUpPrompt && activeProposedPlan ? ( @@ -3872,6 +4112,15 @@ export default function ChatView({ threadId }: ChatViewProps) { onFastModeChange={onCodexFastModeChange} /> + ) : selectedProvider === "claudeCode" && supportsClaudeCodeEffort && selectedClaudeCodeEffort != null ? ( + <> + + + ) : null} {/* Divider */} @@ -3921,6 +4170,43 @@ export default function ChatView({ threadId }: ChatViewProps) { {runtimeMode === "full-access" ? "Full access" : "Supervised"} + + {/* Plan sidebar toggle */} + {(activePlan || activeProposedPlan || planSidebarOpen) ? ( + <> + + + + ) : null}
{/* Right side: send / stop button */} @@ -4087,6 +4373,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
+
{/* end chat column */} + + {/* Plan sidebar */} + {planSidebarOpen ? ( + { + setPlanSidebarOpen(false); + // Track that the user explicitly dismissed for this turn so auto-open won't fight them. + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + if (turnKey) { + planSidebarDismissedForTurnRef.current = turnKey; + } + }} + /> + ) : null} + {/* end horizontal flex container */} + {isGitRepo && ( + )} + {expandedImage && expandedImageItem && (
void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; onToggleDiff: () => void; } @@ -4230,6 +4547,7 @@ const ChatHeader = memo(function ChatHeader({ onRunProjectScript, onAddProjectScript, onUpdateProjectScript, + onDeleteProjectScript, onToggleDiff, }: ChatHeaderProps) { return ( @@ -4262,6 +4580,7 @@ const ChatHeader = memo(function ChatHeader({ onRunScript={onRunProjectScript} onAddScript={onAddProjectScript} onUpdateScript={onUpdateProjectScript} + onDeleteScript={onDeleteProjectScript} /> )} {activeProjectName && ( @@ -4301,7 +4620,13 @@ const ChatHeader = memo(function ChatHeader({ ); }); -const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { +const ThreadErrorBanner = memo(function ThreadErrorBanner({ + error, + onDismiss, +}: { + error: string | null; + onDismiss?: () => void; +}) { if (!error) return null; return (
@@ -4310,6 +4635,18 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st {error} + {onDismiss && ( + + + + )}
); @@ -4334,7 +4671,11 @@ const ProviderHealthBanner = memo(function ProviderHealthBanner({ - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} + {status.provider === "codex" + ? "Codex provider status" + : status.provider === "copilot" + ? "GitHub Copilot provider status" + : `${status.provider} status`} {status.message ?? defaultMessage} @@ -4480,6 +4821,7 @@ interface PendingUserInputPanelProps { answers: Record; questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; } const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -4488,6 +4830,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane answers, questionIndex, onSelectOption, + onAdvance, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -4501,6 +4844,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane answers={answers} questionIndex={questionIndex} onSelectOption={onSelectOption} + onAdvance={onAdvance} /> ); }); @@ -4511,27 +4855,89 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( answers, questionIndex, onSelectOption, + onAdvance, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; + const autoAdvanceTimerRef = useRef(null); + + // Clear auto-advance timer on unmount + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); + + const selectOptionAndAutoAdvance = useCallback( + (questionId: string, optionLabel: string) => { + onSelectOption(questionId, optionLabel); + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvance(); + }, 200); + }, + [onSelectOption, onAdvance], + ); + + // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. + // Works even when the Lexical composer (contenteditable) has focus — the composer + // doubles as a custom-answer field during user input, and when it's empty the digit + // keys should pick options instead of typing into the editor. + useEffect(() => { + if (!activeQuestion || isResponding) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ) { + return; + } + // If the user has started typing a custom answer in the contenteditable + // composer, let digit keys pass through so they can type numbers. + if (target instanceof HTMLElement && target.isContentEditable) { + const hasCustomText = progress.customAnswer.length > 0; + if (hasCustomText) return; + } + const digit = Number.parseInt(event.key, 10); + if (Number.isNaN(digit) || digit < 1 || digit > 9) return; + const optionIndex = digit - 1; + if (optionIndex >= activeQuestion.options.length) return; + const option = activeQuestion.options[optionIndex]; + if (!option) return; + event.preventDefault(); + selectOptionAndAutoAdvance(activeQuestion.id, option.label); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); if (!activeQuestion) { return null; } return ( -
-
+
+
+
{questionIndex + 1}/{prompt.questions.length} {activeQuestion.header}
{activeQuestion.question}
+
{activeQuestion.options.map((option) => { @@ -4542,7 +4948,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( size="sm" variant={isSelected ? "default" : "outline"} disabled={isResponding} - onClick={() => onSelectOption(activeQuestion.id, option.label)} + onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} title={option.description} > {option.label} @@ -4760,7 +5166,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ const saveContents = normalizePlanMarkdownForExport(planMarkdown); const handleDownload = () => { - downloadTextFile(downloadFilename, saveContents); + downloadPlanAsTextFile(downloadFilename, saveContents); }; const openSaveDialog = () => { @@ -4921,8 +5327,6 @@ interface MessagesTimelineProps { completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; - expandedWorkGroups: Record; - onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; @@ -4975,8 +5379,6 @@ const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, - expandedWorkGroups, - onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, @@ -5024,21 +5426,33 @@ const MessagesTimeline = memo(function MessagesTimeline({ } if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; + if (timelineEntry.entry.tone === "tool") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work" || nextEntry.entry.tone !== "tool") { + break; + } + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries, + }); + index = cursor - 1; + continue; } + nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, - groupedEntries, + groupedEntries: [timelineEntry.entry], }); - index = cursor - 1; continue; } @@ -5187,23 +5601,13 @@ const MessagesTimeline = memo(function MessagesTimeline({ > {row.kind === "work" && (() => { - const groupId = row.id; const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const groupLabel = onlyToolEntries ? groupedEntries.length === 1 ? "Tool call" : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; + : "Work event"; return (
@@ -5211,59 +5615,71 @@ const MessagesTimeline = memo(function MessagesTimeline({

{groupLabel}

- {hasOverflow && ( - - )}
- {visibleEntries.map((workEntry) => ( -
- -
-

- {workEntry.label} -

- {workEntry.command && ( -
-                          {workEntry.command}
-                        
- )} - {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles.slice(0, 6).map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} - {workEntry.detail && - (!workEntry.command || workEntry.detail !== workEntry.command) && ( + {groupedEntries.map((workEntry) => { + const iconConfig = workToneIcon(workEntry.tone); + const EntryIcon = workEntryIcon(workEntry); + const preview = workEntryPreview(workEntry); + return ( +
+ + + +
+

+ {workEntry.label} +

+ {preview && preview !== workEntry.label && (

- {workEntry.detail} + {preview}

)} + {workEntry.command && ( +
+                            {workEntry.command}
+                          
+ )} + {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( +
+ {workEntry.changedFiles.slice(0, 6).map((filePath) => ( + + {basenameOfPath(filePath)} + + ))} + {workEntry.changedFiles.length > 6 && ( + + +{workEntry.changedFiles.length - 6} more + + )} +
+ )} + {workEntry.detail && + (!workEntry.command || workEntry.detail !== workEntry.command) && + workEntry.detail !== preview && ( +

+ {workEntry.detail} +

+ )} +
-
- ))} + ); + })}
); @@ -5315,9 +5731,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
)} {row.message.text && ( -
+                  
{row.message.text} -
+
)}
@@ -5553,6 +5969,11 @@ function mergeDiscoveredModels( [ProviderKind, ReadonlyArray | undefined] >) { if (!models || models.length === 0) continue; + const normalizedModels = + provider === "cursor" + ? models.filter((model) => resolveCursorPickerModelSlug(model.slug) === model.slug) + : models; + const dedupedModels = Array.from(new Map(normalizedModels.map((m) => [m.slug, m])).values()); const existing = new Set(base[provider]?.map((m) => m.slug)); // For copilot, discovered models replace the static list but inherit // pricingTier from the static entries when the SDK doesn't provide it. @@ -5560,26 +5981,26 @@ function mergeDiscoveredModels( const baseTiers = new Map( (base[provider] ?? []).map((m) => [m.slug, m.pricingTier]), ); - const enriched = models.map((m) => { + const enriched = dedupedModels.map((m) => { if (m.pricingTier) return m; const tier = baseTiers.get(m.slug); return tier ? { ...m, pricingTier: tier } : m; }); const customOnly = (base[provider] ?? []).filter( - (m) => m.isCustom && !models.some((d) => d.slug === m.slug), + (m) => m.isCustom && !dedupedModels.some((d) => d.slug === m.slug), ); result[provider] = [...enriched, ...customOnly]; continue; } // Build a lookup of discovered models by slug so we can merge metadata // (e.g. pricingTier) into base entries and also add truly-new models. - const discoveredBySlug = new Map(models.map((m) => [m.slug, m])); + const discoveredBySlug = new Map(dedupedModels.map((m) => [m.slug, m])); const merged = (base[provider] ?? []).map((m) => { const discovered = discoveredBySlug.get(m.slug); return discovered ? { ...m, ...discovered } : m; }); // Append any discovered models that weren't already in the base list. - const additions = models.filter((m) => !existing.has(m.slug)); + const additions = dedupedModels.filter((m) => !existing.has(m.slug)); result[provider] = [...additions, ...merged]; } return result; @@ -5971,6 +6392,65 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { ); }); +const CLAUDE_CODE_EFFORT_LABEL: Record = { + low: "Low", + medium: "Medium", + high: "High", + max: "Max", +}; + +const ClaudeCodeTraitsPicker = memo(function ClaudeCodeTraitsPicker(props: { + effort: ClaudeCodeEffort; + options: ReadonlyArray; + onEffortChange: (effort: ClaudeCodeEffort) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultEffort = getDefaultClaudeCodeEffort("claudeCode"); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {CLAUDE_CODE_EFFORT_LABEL[props.effort]} + + + +
Effort
+ { + if (!value) return; + const nextEffort = props.options.find((option) => option === value); + if (!nextEffort) return; + props.onEffortChange(nextEffort); + }} + > + {props.options.map((effort) => ( + + {CLAUDE_CODE_EFFORT_LABEL[effort]} + {effort === defaultEffort ? " (default)" : ""} + + ))} + +
+
+
+ ); +}); + const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { selection: ReturnType; capabilities: ReturnType; @@ -6103,6 +6583,11 @@ const OpenInPicker = memo(function OpenInPicker({ Icon: CursorIcon, value: "cursor", }, + { + label: "Windsurf", + Icon: WindsurfIcon, + value: "windsurf", + }, { label: "VS Code", Icon: VisualStudioCode, @@ -6113,6 +6598,36 @@ const OpenInPicker = memo(function OpenInPicker({ Icon: Zed, value: "zed", }, + { + label: "Positron", + Icon: PositronIcon, + value: "positron", + }, + { + label: "Sublime Text", + Icon: SublimeTextIcon, + value: "sublime", + }, + { + label: "WebStorm", + Icon: WebStormIcon, + value: "webstorm", + }, + { + label: "IntelliJ IDEA", + Icon: IntelliJIcon, + value: "intellij", + }, + { + label: "Fleet", + Icon: FleetIcon, + value: "fleet", + }, + { + label: "Ghostty", + Icon: GhosttyIcon, + value: "ghostty", + }, { label: isMacPlatform(navigator.platform) ? "Finder" @@ -6148,6 +6663,30 @@ const OpenInPicker = memo(function OpenInPicker({ [effectiveEditor, openInCwd, setLastEditor], ); + const [copiedPath, setCopiedPath] = useState(false); + const copiedPathTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedPathTimeoutRef.current !== null) { + clearTimeout(copiedPathTimeoutRef.current); + } + }; + }, []); + + const copyPath = useCallback(() => { + if (!openInCwd) return; + void navigator.clipboard.writeText(openInCwd); + setCopiedPath(true); + if (copiedPathTimeoutRef.current !== null) { + clearTimeout(copiedPathTimeoutRef.current); + } + copiedPathTimeoutRef.current = setTimeout(() => { + setCopiedPath(false); + copiedPathTimeoutRef.current = null; + }, 2000); + }, [openInCwd]); + const openFavoriteEditorShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), [keybindings], @@ -6196,6 +6735,19 @@ const OpenInPicker = memo(function OpenInPicker({ )} ))} + {openInCwd && ( + <> + + + {copiedPath ? ( + + + )} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000000..9e9c58141b --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,664 @@ +import { + BotIcon, + FolderIcon, + GitBranchIcon, + PanelBottomIcon, + PlayIcon, + SearchIcon, + SettingsIcon, + SparklesIcon, + SquareSplitHorizontalIcon, + TerminalSquareIcon, + MessageSquareIcon, + StopCircleIcon, + GhostIcon, +} from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ProviderInteractionMode, + type ResolvedKeybindingsConfig, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useProjectThreadNavigation } from "../hooks/useProjectThreadNavigation"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { useStore } from "../store"; +import { type Project, type ProjectScript, type Thread } from "../types"; +import { CommandDialog, CommandDialogPopup, CommandFooter } from "./ui/command"; +import { ScrollArea } from "./ui/scroll-area"; +import { cn } from "~/lib/utils"; + +type PaletteGroupId = "actions" | "scripts" | "projects" | "threads"; + +interface PaletteItem { + id: string; + group: PaletteGroupId; + title: string; + subtitle?: string; + keywords?: string[]; + shortcut?: string | null; + icon: ReactNode; + disabled?: boolean; + onSelect: () => void | Promise; +} + +interface CommandPaletteProps { + threadId: ThreadId; + activeThread: Thread; + activeProject?: Project | undefined; + keybindings: ResolvedKeybindingsConfig; + diffOpen: boolean; + terminalOpen: boolean; + isGitRepo: boolean; + isWorking: boolean; + canCreateTerminal: boolean; + canSplitTerminal: boolean; + interactionMode: ProviderInteractionMode; + runtimeMode: RuntimeMode; + onToggleDiff: () => void; + onToggleTerminal: () => void; + onCreateTerminal: () => void; + onSplitTerminal: () => void; + onToggleInteractionMode: () => void; + onToggleRuntimeMode: () => void; + onInterrupt: () => void | Promise; + onRunProjectScript?: ((script: ProjectScript) => void | Promise) | undefined; + ghosttySplitOpen?: boolean; + onToggleGhosttySplit?: () => void; +} + +const GROUP_LABELS: Record = { + actions: "Actions", + scripts: "Scripts", + projects: "Projects", + threads: "Threads", +}; + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function threadSubtitle(thread: Thread, projectName: string | undefined): string { + const parts: string[] = []; + if (projectName) parts.push(projectName); + if (thread.session?.status === "running") { + parts.push("working"); + } else if (thread.session?.status === "connecting") { + parts.push("connecting"); + } + parts.push(formatRelativeTime(thread.createdAt)); + return parts.join(" · "); +} + +function isTerminalFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; +} + +function matchesPaletteQuery(item: PaletteItem, query: string): boolean { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (normalizedQuery.length === 0) return true; + + const haystack = [ + item.title, + item.subtitle ?? "", + ...(item.keywords ?? []), + ] + .join(" ") + .toLocaleLowerCase(); + + return normalizedQuery + .split(/\s+/) + .filter((token) => token.length > 0) + .every((token) => haystack.includes(token)); +} + +function groupPaletteItems(items: ReadonlyArray) { + return Object.entries(GROUP_LABELS) + .map(([group, label]) => ({ + group: group as PaletteGroupId, + label, + items: items.filter((item) => item.group === group), + })) + .filter((entry) => entry.items.length > 0); +} + +export default function CommandPalette({ + threadId, + activeThread, + activeProject, + keybindings, + diffOpen, + terminalOpen, + isGitRepo, + isWorking, + canCreateTerminal, + canSplitTerminal, + interactionMode, + runtimeMode, + onToggleDiff, + onToggleTerminal, + onCreateTerminal, + onSplitTerminal, + onToggleInteractionMode, + onToggleRuntimeMode, + onInterrupt, + onRunProjectScript, + ghosttySplitOpen, + onToggleGhosttySplit, +}: CommandPaletteProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + const itemRefs = useRef>([]); + const navigate = useNavigate(); + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); + const { openOrCreateThread, openProject } = useProjectThreadNavigation(threadId); + const projectNameById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name] as const)), + [projects], + ); + + const paletteShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "commandPalette.toggle"), + [keybindings], + ); + const newThreadShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "chat.new"), + [keybindings], + ); + const newLocalThreadShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "chat.newLocal"), + [keybindings], + ); + const diffShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "diff.toggle"), + [keybindings], + ); + const terminalToggleShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.toggle"), + [keybindings], + ); + const terminalNewShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.new"), + [keybindings], + ); + const terminalSplitShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.split"), + [keybindings], + ); + + const actionItems = useMemo(() => { + const items: PaletteItem[] = [ + { + id: "action:settings", + group: "actions", + title: "Open settings", + subtitle: "Configure appearance, models, keybindings, and safety", + keywords: ["preferences", "config"], + icon: , + onSelect: () => navigate({ to: "/settings" }), + }, + ]; + + if (activeProject) { + items.unshift( + { + id: `action:new-thread:${activeProject.id}`, + group: "actions", + title: `New thread in ${activeProject.name}`, + subtitle: "Use the current project context", + keywords: ["chat", "conversation", "draft"], + shortcut: newThreadShortcutLabel, + icon: , + onSelect: () => + openOrCreateThread(activeProject.id, { + branch: activeThread.branch ?? null, + worktreePath: activeThread.worktreePath ?? null, + envMode: activeThread.worktreePath ? "worktree" : "local", + }), + }, + { + id: `action:new-local-thread:${activeProject.id}`, + group: "actions", + title: `New local thread in ${activeProject.name}`, + subtitle: "Start fresh without reusing the current worktree", + keywords: ["chat", "conversation", "local", "draft"], + shortcut: newLocalThreadShortcutLabel, + icon: , + onSelect: () => + openOrCreateThread(activeProject.id, { + branch: null, + worktreePath: null, + envMode: "local", + }), + }, + ); + } + + if (isGitRepo) { + items.push({ + id: "action:toggle-diff", + group: "actions", + title: diffOpen ? "Hide diff panel" : "Show diff panel", + subtitle: "Toggle the thread diff viewer", + keywords: ["git", "changes", "files"], + shortcut: diffShortcutLabel, + icon: , + onSelect: onToggleDiff, + }); + } + + items.push({ + id: "action:toggle-terminal", + group: "actions", + title: terminalOpen ? "Hide terminal" : "Show terminal", + subtitle: "Toggle the thread terminal drawer", + keywords: ["shell", "console"], + shortcut: terminalToggleShortcutLabel, + icon: , + onSelect: onToggleTerminal, + }); + + items.push({ + id: "action:new-terminal", + group: "actions", + title: "Create terminal", + subtitle: canCreateTerminal ? "Open a new terminal for this thread" : "Terminal limit reached", + keywords: ["shell", "console"], + shortcut: terminalNewShortcutLabel, + icon: , + disabled: !canCreateTerminal, + onSelect: onCreateTerminal, + }); + + items.push({ + id: "action:split-terminal", + group: "actions", + title: "Split terminal", + subtitle: canSplitTerminal ? "Split the current terminal group" : "Terminal limit reached", + keywords: ["shell", "console", "pane"], + shortcut: terminalSplitShortcutLabel, + icon: , + disabled: !canSplitTerminal, + onSelect: onSplitTerminal, + }); + + if (onToggleGhosttySplit) { + items.push({ + id: "action:toggle-ghostty-split", + group: "actions", + title: ghosttySplitOpen ? "Hide Ghostty split view" : "Show Ghostty split view", + subtitle: "Toggle the libghostty-powered split terminal (WASM)", + keywords: ["ghostty", "split", "wasm", "libghostty", "terminal"], + icon: , + onSelect: onToggleGhosttySplit, + }); + } + + items.push({ + id: "action:toggle-interaction-mode", + group: "actions", + title: interactionMode === "plan" ? "Switch to chat mode" : "Switch to plan mode", + subtitle: + interactionMode === "plan" + ? "Return to direct implementation and follow-up chat" + : "Ask the agent to plan before implementing", + keywords: ["mode", "planner"], + icon: , + onSelect: onToggleInteractionMode, + }); + + items.push({ + id: "action:toggle-runtime-mode", + group: "actions", + title: runtimeMode === "full-access" ? "Switch to supervised mode" : "Switch to full access", + subtitle: + runtimeMode === "full-access" + ? "Require approvals before sensitive actions" + : "Allow the agent to execute without approval prompts", + keywords: ["mode", "permissions", "approval", "access"], + icon: , + onSelect: onToggleRuntimeMode, + }); + + if (isWorking) { + items.push({ + id: "action:interrupt", + group: "actions", + title: "Stop active turn", + subtitle: "Interrupt the current agent turn", + keywords: ["cancel", "interrupt", "stop"], + icon: , + onSelect: onInterrupt, + }); + } + + return items; + }, [ + activeProject, + activeThread.branch, + activeThread.worktreePath, + canCreateTerminal, + canSplitTerminal, + diffOpen, + diffShortcutLabel, + interactionMode, + isGitRepo, + isWorking, + newLocalThreadShortcutLabel, + newThreadShortcutLabel, + onCreateTerminal, + onInterrupt, + onSplitTerminal, + onToggleDiff, + onToggleInteractionMode, + onToggleRuntimeMode, + onToggleTerminal, + onToggleGhosttySplit, + ghosttySplitOpen, + openOrCreateThread, + navigate, + runtimeMode, + terminalNewShortcutLabel, + terminalOpen, + terminalSplitShortcutLabel, + terminalToggleShortcutLabel, + ]); + + const scriptItems = useMemo(() => { + if (!activeProject || !onRunProjectScript) return []; + return activeProject.scripts.map((script) => ({ + id: `script:${script.id}`, + group: "scripts", + title: script.name, + subtitle: script.command, + keywords: [script.command, activeProject.name, "script", "action"], + icon: , + onSelect: () => onRunProjectScript(script), + })); + }, [activeProject, onRunProjectScript]); + + const projectItems = useMemo( + () => + projects + .toSorted((left, right) => left.name.localeCompare(right.name)) + .map((project) => { + const latestThread = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((left, right) => { + const byDate = new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); + if (byDate !== 0) return byDate; + return right.id.localeCompare(left.id); + })[0]; + const hasDraft = Object.values(draftThreadsByThreadId).some( + (draftThread) => draftThread.projectId === project.id, + ); + const projectSubtitle = latestThread + ? `Latest thread: ${latestThread.title}` + : hasDraft + ? "Draft thread available" + : "No threads yet"; + return { + id: `project:${project.id}`, + group: "projects", + title: project.name, + subtitle: projectSubtitle, + keywords: [project.cwd, project.name, latestThread?.title ?? ""], + icon: , + onSelect: () => openProject(project.id), + } satisfies PaletteItem; + }), + [draftThreadsByThreadId, openProject, projects, threads], + ); + + const threadItems = useMemo( + () => + threads + .toSorted((left, right) => { + const byDate = new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); + if (byDate !== 0) return byDate; + return right.id.localeCompare(left.id); + }) + .map((thread) => ({ + id: `thread:${thread.id}`, + group: "threads", + title: thread.title, + subtitle: threadSubtitle(thread, projectNameById.get(thread.projectId)), + keywords: [thread.model, projectNameById.get(thread.projectId) ?? "", thread.id], + icon: , + onSelect: () => + navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }), + })), + [navigate, projectNameById, threads], + ); + + const filteredItems = useMemo( + () => [...actionItems, ...scriptItems, ...projectItems, ...threadItems].filter((item) => matchesPaletteQuery(item, query)), + [actionItems, projectItems, query, scriptItems, threadItems], + ); + const groupedItems = useMemo(() => groupPaletteItems(filteredItems), [filteredItems]); + + /** Pre-compute flat visible index for each item id so we avoid mutating a counter during render. */ + const flatVisibleIndexById = useMemo(() => { + const map = new Map(); + let index = 0; + for (const group of groupedItems) { + for (const item of group.items) { + map.set(item.id, index); + index += 1; + } + } + return map; + }, [groupedItems]); + + useEffect(() => { + if (!open) { + setQuery(""); + setHighlightedIndex(0); + itemRefs.current = []; + return; + } + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [open]); + + useEffect(() => { + setHighlightedIndex((current) => { + if (filteredItems.length === 0) return 0; + return Math.min(current, filteredItems.length - 1); + }); + }, [filteredItems]); + + useEffect(() => { + if (!open) return; + itemRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex, open]); + + useEffect(() => { + setOpen(false); + }, [threadId]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + if (command !== "commandPalette.toggle") return; + event.preventDefault(); + event.stopPropagation(); + setOpen((current) => !current); + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [keybindings, terminalOpen]); + + const closePalette = useCallback(() => { + setOpen(false); + }, []); + + const activateItem = useCallback(async (item: PaletteItem | undefined) => { + if (!item || item.disabled) return; + closePalette(); + await item.onSelect(); + }, [closePalette]); + + const onListKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + if (filteredItems.length === 0) return; + setHighlightedIndex((current) => (current + 1) % filteredItems.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + if (filteredItems.length === 0) return; + setHighlightedIndex((current) => (current - 1 + filteredItems.length) % filteredItems.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + void activateItem(filteredItems[highlightedIndex]); + return; + } + if (event.key !== "Escape") return; + event.preventDefault(); + closePalette(); + }, + [activateItem, closePalette, filteredItems, highlightedIndex], + ); + + return ( + + +
+ +
+ +
+ +
+ {groupedItems.length === 0 ? ( +
+ No matching commands found. +
+ ) : ( + groupedItems.map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((item) => { + const itemIndex = flatVisibleIndexById.get(item.id) ?? 0; + const isHighlighted = highlightedIndex === itemIndex; + const isActiveThread = item.id === `thread:${activeThread.id}`; + return ( + + ); + })} +
+
+ )) + )} +
+
+
+ + + Enter to open + Up/Down to navigate + {paletteShortcutLabel ? `${paletteShortcutLabel} or Esc to close` : "Esc to close"} + +
+
+ ); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 27248ce634..58e4daa84b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -28,6 +28,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-file], [data-error-wrapper], [data-virtualizer-buffer] { + --diffs-font-family: var(--font-mono); --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; diff --git a/apps/web/src/components/GhosttyTerminalSplitView.tsx b/apps/web/src/components/GhosttyTerminalSplitView.tsx new file mode 100644 index 0000000000..4292765509 --- /dev/null +++ b/apps/web/src/components/GhosttyTerminalSplitView.tsx @@ -0,0 +1,708 @@ +/** + * Mini Terminal Split View powered by libghostty (via ghostty-web WASM). + * + * This component demonstrates embedding Ghostty's battle-tested VT100 parser + * (compiled to WebAssembly from the original Zig source) into a React-based + * split terminal pane layout. + * + * Instead of xterm.js's JavaScript-based terminal emulation, this uses + * libghostty-vt — the same core used by the native Ghostty terminal app — + * providing superior Unicode handling, SIMD-optimized parsing, and proper + * support for complex scripts (Devanagari, Arabic, etc.). + * + * Architecture: + * ghostty-web (npm) → WASM (libghostty-vt compiled from Zig) → Canvas renderer + * React component → manages split pane layout, focus, resize + * Server PTY → WebSocket → ghostty-web Terminal.write() + */ + +import { init as initGhostty, Terminal, FitAddon, type ITheme } from "ghostty-web"; +import { + GripVertical, + Maximize2, + Minimize2, + Plus, + Split, + Terminal as TerminalIcon, + X, +} from "lucide-react"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { readNativeApi } from "~/nativeApi"; +import type { ThreadId } from "@t3tools/contracts"; +import { + contrastSafeTerminalColor, + normalizeAccentColor, + resolveAccentColorRgba, +} from "../accentColor"; + +// ─── Ghostty WASM Initialization ──────────────────────────────────────────── +// ghostty-web requires a one-time async init to load the WASM module. +// We track the promise globally so multiple components share the same load. + +let ghosttyInitPromise: Promise | null = null; +let ghosttyReady = false; + +function ensureGhosttyInit(): Promise { + if (ghosttyReady) return Promise.resolve(); + if (!ghosttyInitPromise) { + ghosttyInitPromise = initGhostty().then(() => { + ghosttyReady = true; + }); + } + return ghosttyInitPromise; +} + +// ─── Theme ────────────────────────────────────────────────────────────────── + +const DARK_BG_HEX = "#0e1218"; +const LIGHT_BG_HEX = "#ffffff"; + +function clampByte(v: number): number { + return Math.min(255, Math.max(0, Math.round(v))); +} + +function mixHexWithWhite(hex: string, ratio: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + const mr = clampByte(r + (255 - r) * ratio); + const mg = clampByte(g + (255 - g) * ratio); + const mb = clampByte(b + (255 - b) * ratio); + return `#${mr.toString(16).padStart(2, "0")}${mg.toString(16).padStart(2, "0")}${mb.toString(16).padStart(2, "0")}`; +} + +function ghosttyThemeFromApp(): ITheme { + const isDark = document.documentElement.classList.contains("dark"); + const bodyStyles = getComputedStyle(document.body); + const rootStyles = getComputedStyle(document.documentElement); + const background = + bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); + const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); + const accentColor = normalizeAccentColor(rootStyles.getPropertyValue("--accent-color")); + const bgHex = isDark ? DARK_BG_HEX : LIGHT_BG_HEX; + const terminalBlue = contrastSafeTerminalColor(accentColor, bgHex); + const brightMix = isDark ? 0.3 : 0.18; + const terminalBrightBlue = contrastSafeTerminalColor( + mixHexWithWhite(accentColor, brightMix), + bgHex, + ); + const selectionBackground = resolveAccentColorRgba(accentColor, isDark ? 0.3 : 0.22); + + if (isDark) { + return { + background, + foreground, + cursor: terminalBrightBlue, + selectionBackground, + black: "rgb(24, 30, 38)", + red: "rgb(255, 122, 142)", + green: "rgb(134, 231, 149)", + yellow: "rgb(244, 205, 114)", + blue: terminalBlue, + magenta: "rgb(208, 176, 255)", + cyan: "rgb(124, 232, 237)", + white: "rgb(210, 218, 230)", + brightBlack: "rgb(110, 120, 136)", + brightRed: "rgb(255, 168, 180)", + brightGreen: "rgb(176, 245, 186)", + brightYellow: "rgb(255, 224, 149)", + brightBlue: terminalBrightBlue, + brightMagenta: "rgb(229, 203, 255)", + brightCyan: "rgb(167, 244, 247)", + brightWhite: "rgb(244, 247, 252)", + }; + } + + return { + background, + foreground, + cursor: terminalBlue, + selectionBackground, + black: "rgb(44, 53, 66)", + red: "rgb(191, 70, 87)", + green: "rgb(60, 126, 86)", + yellow: "rgb(146, 112, 35)", + blue: terminalBlue, + magenta: "rgb(132, 86, 149)", + cyan: "rgb(53, 127, 141)", + white: "rgb(210, 215, 223)", + brightBlack: "rgb(112, 123, 140)", + brightRed: "rgb(212, 95, 112)", + brightGreen: "rgb(85, 148, 111)", + brightYellow: "rgb(173, 133, 45)", + brightBlue: terminalBrightBlue, + brightMagenta: "rgb(153, 107, 172)", + brightCyan: "rgb(70, 149, 164)", + brightWhite: "rgb(236, 240, 246)", + }; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const MIN_PANE_WIDTH_PX = 120; +const MAX_PANES = 4; +const MIN_CONTAINER_HEIGHT = 200; +const MAX_CONTAINER_HEIGHT = 600; +const DEFAULT_CONTAINER_HEIGHT = 350; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface SplitPane { + id: string; + terminalId: string; +} + +// ─── Single Ghostty Terminal Pane ─────────────────────────────────────────── + +interface GhosttyPaneProps { + threadId: ThreadId; + terminalId: string; + cwd: string; + runtimeEnv?: Record; + isActive: boolean; + onFocus: () => void; + onClose: () => void; + resizeEpoch: number; + containerHeight: number; +} + +function GhosttyPane({ + threadId, + terminalId, + cwd, + runtimeEnv, + isActive, + onFocus, + onClose, + resizeEpoch, + containerHeight, +}: GhosttyPaneProps) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + + // Initialize ghostty-web terminal + useEffect(() => { + const mount = containerRef.current; + if (!mount) return; + + let disposed = false; + + const setup = async () => { + try { + // Ensure WASM is loaded + await ensureGhosttyInit(); + if (disposed) return; + + const fitAddon = new FitAddon(); + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 12, + scrollback: 5_000, + fontFamily: + '"Geist Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + theme: ghosttyThemeFromApp(), + }); + + terminal.loadAddon(fitAddon); + terminal.open(mount); + fitAddon.fit(); + + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + + if (disposed) { + terminal.dispose(); + return; + } + + setStatus("ready"); + + // Connect to backend PTY + const api = readNativeApi(); + if (!api) return; + + // Handle user input → send to PTY + const inputDisposable = terminal.onData((data) => { + void api.terminal + .write({ threadId, terminalId, data }) + .catch((err) => { + terminal.write( + `\r\n[ghostty] ${err instanceof Error ? err.message : "Write failed"}\r\n`, + ); + }); + }); + + // Listen for PTY output → write to terminal + const unsubscribe = api.terminal.onEvent((event) => { + if (event.threadId !== threadId || event.terminalId !== terminalId) return; + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + + switch (event.type) { + case "output": + activeTerminal.write(event.data); + break; + case "started": + case "restarted": + activeTerminal.write("\u001bc"); + if (event.snapshot.history.length > 0) { + activeTerminal.write(event.snapshot.history); + } + break; + case "cleared": + activeTerminal.clear(); + activeTerminal.write("\u001bc"); + break; + case "error": + activeTerminal.write(`\r\n[ghostty] ${event.message}\r\n`); + break; + case "exited": { + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((v): v is string => v !== null) + .join(", "); + activeTerminal.write( + `\r\n[ghostty] ${details ? `Process exited (${details})` : "Process exited"}\r\n`, + ); + break; + } + } + }); + + // Open the terminal session on the server + try { + fitAddon.fit(); + const snapshot = await api.terminal.open({ + threadId, + terminalId, + cwd, + cols: terminal.cols, + rows: terminal.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }); + if (disposed) return; + terminal.write("\u001bc"); + if (snapshot.history.length > 0) { + terminal.write(snapshot.history); + } + if (isActive) { + window.requestAnimationFrame(() => terminal.focus()); + } + } catch (err) { + if (disposed) return; + terminal.write( + `\r\n[ghostty] ${err instanceof Error ? err.message : "Failed to open terminal"}\r\n`, + ); + } + + // Theme observer + const themeObserver = new MutationObserver(() => { + const t = terminalRef.current; + if (!t) return; + t.options.theme = ghosttyThemeFromApp(); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + + // Cleanup on unmount + return () => { + disposed = true; + unsubscribe(); + inputDisposable.dispose(); + themeObserver.disconnect(); + terminalRef.current = null; + fitAddonRef.current = null; + terminal.dispose(); + }; + } catch (err) { + if (!disposed) { + setStatus("error"); + console.error("[ghostty-web] Init failed:", err); + } + } + }; + + const cleanupPromise = setup(); + + return () => { + disposed = true; + void cleanupPromise?.then((cleanup) => cleanup?.()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cwd, runtimeEnv, terminalId, threadId]); + + // Handle focus + useEffect(() => { + if (!isActive) return; + const terminal = terminalRef.current; + if (!terminal) return; + const frame = window.requestAnimationFrame(() => terminal.focus()); + return () => window.cancelAnimationFrame(frame); + }, [isActive]); + + // Handle resize + useEffect(() => { + const api = readNativeApi(); + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!api || !terminal || !fitAddon) return; + + const frame = window.requestAnimationFrame(() => { + fitAddon.fit(); + terminal.scrollToBottom(); + void api.terminal + .resize({ + threadId, + terminalId, + cols: terminal.cols, + rows: terminal.rows, + }) + .catch(() => undefined); + }); + return () => window.cancelAnimationFrame(frame); + }, [containerHeight, resizeEpoch, terminalId, threadId]); + + return ( +
+ {/* Pane header */} +
+
+ + + {status === "loading" ? "Loading WASM…" : status === "error" ? "Error" : "ghostty"} + + {status === "ready" && ( + + libghostty + + )} +
+ +
+ + {/* Terminal canvas area */} +
+
+ ); +} + +// ─── Split Divider ────────────────────────────────────────────────────────── + +interface SplitDividerProps { + onPointerDown: (e: ReactPointerEvent) => void; + onPointerMove: (e: ReactPointerEvent) => void; + onPointerUp: (e: ReactPointerEvent) => void; +} + +function SplitDivider({ onPointerDown, onPointerMove, onPointerUp }: SplitDividerProps) { + return ( +
+ +
+ ); +} + +// ─── Main Split View Component ────────────────────────────────────────────── + +export interface GhosttyTerminalSplitViewProps { + threadId: ThreadId; + cwd: string; + runtimeEnv?: Record; +} + +let nextPaneCounter = 0; +function createPaneId(): string { + nextPaneCounter += 1; + return `ghostty-pane-${nextPaneCounter}-${Date.now().toString(36)}`; +} + +export default function GhosttyTerminalSplitView({ + threadId, + cwd, + runtimeEnv, +}: GhosttyTerminalSplitViewProps) { + const [panes, setPanes] = useState(() => { + const id = createPaneId(); + return [{ id, terminalId: `ghostty-${id}` }]; + }); + const [activePaneId, setActivePaneId] = useState(() => panes[0]!.id); + const [containerHeight, setContainerHeight] = useState(DEFAULT_CONTAINER_HEIGHT); + const [isCollapsed, setIsCollapsed] = useState(false); + const [resizeEpoch, setResizeEpoch] = useState(0); + const containerRef = useRef(null); + const resizeStateRef = useRef<{ + pointerId: number; + startY: number; + startHeight: number; + } | null>(null); + + const canSplit = panes.length < MAX_PANES; + + // ─── Pane management ──────────────────────────────────────────────── + + const handleSplit = useCallback(() => { + if (!canSplit) return; + const id = createPaneId(); + const newPane: SplitPane = { id, terminalId: `ghostty-${id}` }; + setPanes((prev) => [...prev, newPane]); + setActivePaneId(id); + setResizeEpoch((e) => e + 1); + }, [canSplit]); + + const handleClosePane = useCallback( + (paneId: string) => { + setPanes((prev) => { + if (prev.length <= 1) return prev; // keep at least one + const next = prev.filter((p) => p.id !== paneId); + if (activePaneId === paneId) { + setActivePaneId(next[0]?.id ?? ""); + } + setResizeEpoch((e) => e + 1); + return next; + }); + }, + [activePaneId], + ); + + // ─── Vertical resize (container height) ───────────────────────────── + + const handleResizePointerDown = useCallback((e: ReactPointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + resizeStateRef.current = { + pointerId: e.pointerId, + startY: e.clientY, + startHeight: containerHeight, + }; + }, [containerHeight]); + + const handleResizePointerMove = useCallback((e: ReactPointerEvent) => { + const state = resizeStateRef.current; + if (!state || state.pointerId !== e.pointerId) return; + e.preventDefault(); + const nextHeight = Math.min( + MAX_CONTAINER_HEIGHT, + Math.max(MIN_CONTAINER_HEIGHT, state.startHeight + (state.startY - e.clientY)), + ); + setContainerHeight(nextHeight); + }, []); + + const handleResizePointerUp = useCallback((e: ReactPointerEvent) => { + const state = resizeStateRef.current; + if (!state || state.pointerId !== e.pointerId) return; + resizeStateRef.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + setResizeEpoch((v) => v + 1); + }, []); + + // ─── Window resize ────────────────────────────────────────────────── + + useEffect(() => { + const onResize = () => setResizeEpoch((v) => v + 1); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // ─── Keyboard shortcut for splitting ──────────────────────────────── + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl + Shift + D to split + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "d") { + e.preventDefault(); + handleSplit(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleSplit]); + + // ─── Pane label map ───────────────────────────────────────────────── + + const paneLabelMap = useMemo( + () => new Map(panes.map((p, i) => [p.id, `Pane ${i + 1}`])), + [panes], + ); + + if (isCollapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Resize handle (top edge) */} +
+ + {/* Toolbar */} +
+
+ + + Ghostty Split View + + + libghostty + +
+ +
+ {/* Pane tabs */} + {panes.length > 1 && + panes.map((pane) => ( + + ))} + +
+ + {/* Split button */} + + + {/* Add new pane */} + + + {/* Collapse */} + +
+
+ + {/* Split pane container */} +
+ {panes.map((pane, index) => ( +
+ {index > 0 && ( + {}} + onPointerMove={() => {}} + onPointerUp={() => {}} + /> + )} + setActivePaneId(pane.id)} + onClose={() => handleClosePane(pane.id)} + resizeEpoch={resizeEpoch} + containerHeight={containerHeight} + /> +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 628f2f359e..108cbe798f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -42,7 +42,7 @@ describe("when: branch is clean and has an open PR", () => { }), false, ); - assert.deepInclude(quick, { kind: "open_pr", label: "Open PR", disabled: false }); + assert.deepInclude(quick, { kind: "open_pr", label: "View PR", disabled: false }); }); it("buildMenuItems disables commit/push and enables open PR", () => { @@ -78,7 +78,7 @@ describe("when: branch is clean and has an open PR", () => { }, { id: "pr", - label: "Open PR", + label: "View PR", disabled: false, icon: "pr", kind: "open_pr", @@ -199,7 +199,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }, { id: "pr", - label: "Open PR", + label: "View PR", disabled: false, icon: "pr", kind: "open_pr", @@ -547,7 +547,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "open_pr", - label: "Open PR", + label: "View PR", disabled: false, }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 9a9476e4ce..4ad5e79406 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -151,7 +151,7 @@ export function buildMenuItems( hasOpenPr ? { id: "pr", - label: "Open PR", + label: "View PR", disabled: !canOpenPr, icon: "pr", kind: "open_pr", @@ -216,7 +216,7 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream) { if (!isAhead) { if (hasOpenPr) { - return { label: "Open PR", disabled: false, kind: "open_pr" }; + return { label: "View PR", disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -266,7 +266,7 @@ export function resolveQuickAction( } if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "Open PR", disabled: false, kind: "open_pr" }; + return { label: "View PR", disabled: false, kind: "open_pr" }; } return { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 3662d1b79c..2b292ba5f7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,4 +1,4 @@ -import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; +import type { GitStackedAction, GitStatusResult, ProviderKind, ThreadId } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; @@ -42,6 +42,7 @@ import { } from "~/lib/gitReactQuery"; import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { useStore } from "~/store"; interface GitActionsControlProps { gitCwd: string | null; @@ -98,7 +99,7 @@ function getMenuActionDisabledReason( } if (hasOpenPr) { - return "Open PR is currently unavailable."; + return "View PR is currently unavailable."; } if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; @@ -149,6 +150,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); + const activeThread = useStore((state) => + activeThreadId ? state.threads.find((t) => t.id === activeThreadId) : undefined, + ); + const activeProvider: ProviderKind | undefined = activeThread?.session?.provider; + const activeModel: string | undefined = activeThread?.model; + const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); @@ -324,6 +331,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(activeProvider ? { provider: activeProvider } : {}), + ...(activeModel ? { model: activeModel } : {}), }); try { @@ -376,7 +385,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions : shouldOfferOpenPrCta ? { actionProps: { - children: "Open PR", + children: "View PR", onClick: () => { const api = readNativeApi(); if (!api) return; @@ -414,6 +423,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + activeModel, + activeProvider, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3555596e99..843179d88d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -143,6 +143,51 @@ export const Zed: Icon = (props) => { ); }; +export const WindsurfIcon: Icon = (props) => ( + + + + +); + +export const PositronIcon: Icon = (props) => ( + + + + + +); + +export const SublimeTextIcon: Icon = (props) => ( + + + +); + +export const WebStormIcon: Icon = (props) => ( + + + +); + +export const IntelliJIcon: Icon = (props) => ( + + + +); + +export const FleetIcon: Icon = (props) => ( + + + +); + +export const GhosttyIcon: Icon = (props) => ( + + + +); + export const OpenAI: Icon = (props) => ( diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx new file mode 100644 index 0000000000..a9f1a75632 --- /dev/null +++ b/apps/web/src/components/PlanSidebar.tsx @@ -0,0 +1,282 @@ +import { memo, useState, useCallback, useRef, useEffect } from "react"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { ScrollArea } from "./ui/scroll-area"; +import ChatMarkdown from "./ChatMarkdown"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, + PanelRightCloseIcon, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import { formatTimestamp } from "../session-logic"; +import type { ActivePlanState } from "../session-logic"; +import type { LatestProposedPlanState } from "../session-logic"; +import { + proposedPlanTitle, + buildProposedPlanMarkdownFilename, + normalizePlanMarkdownForExport, + downloadPlanAsTextFile, +} from "../proposedPlan"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { readNativeApi } from "~/nativeApi"; +import { toastManager } from "./ui/toast"; + +function stepStatusIcon(status: string): React.ReactNode { + if (status === "completed") { + return ( + + + + ); + } + if (status === "inProgress") { + return ( + + + + ); + } + return ( + + + + ); +} + +interface PlanSidebarProps { + activePlan: ActivePlanState | null; + activeProposedPlan: LatestProposedPlanState | null; + markdownCwd: string | undefined; + workspaceRoot: string | undefined; + onClose: () => void; +} + +const PlanSidebar = memo(function PlanSidebar({ + activePlan, + activeProposedPlan, + markdownCwd, + workspaceRoot, + onClose, +}: PlanSidebarProps) { + const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimeoutRef.current !== null) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); + + const planMarkdown = activeProposedPlan?.planMarkdown ?? null; + const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; + + const handleCopyPlan = useCallback(() => { + if (!planMarkdown) return; + void navigator.clipboard.writeText(planMarkdown); + setCopied(true); + if (copiedTimeoutRef.current !== null) { + clearTimeout(copiedTimeoutRef.current); + } + copiedTimeoutRef.current = setTimeout(() => { + setCopied(false); + copiedTimeoutRef.current = null; + }, 2000); + }, [planMarkdown]); + + const handleDownload = useCallback(() => { + if (!planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); + }, [planMarkdown]); + + const handleSaveToWorkspace = useCallback(() => { + const api = readNativeApi(); + if (!api || !workspaceRoot || !planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath: filename, + contents: normalizePlanMarkdownForExport(planMarkdown), + }) + .then((result) => { + toastManager.add({ + type: "success", + title: "Plan saved", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: + error instanceof Error ? error.message : "An error occurred.", + }); + }) + .then( + () => setIsSavingToWorkspace(false), + () => setIsSavingToWorkspace(false), + ); + }, [planMarkdown, workspaceRoot]); + + return ( +
+ {/* Header */} +
+
+ + Plan + + {activePlan ? ( + + {formatTimestamp(activePlan.createdAt)} + + ) : null} +
+
+ {planMarkdown ? ( + + + } + > + + + + + {copied ? "Copied!" : "Copy to clipboard"} + + Download as markdown + + Save to workspace + + + + ) : null} + +
+
+ + {/* Content */} + +
+ {/* Explanation */} + {activePlan?.explanation ? ( +

+ {activePlan.explanation} +

+ ) : null} + + {/* Plan Steps */} + {activePlan && activePlan.steps.length > 0 ? ( +
+

+ Steps +

+ {activePlan.steps.map((step, index) => ( +
+
+ {stepStatusIcon(step.status)} +
+

+ {step.step} +

+
+ ))} +
+ ) : null} + + {/* Proposed Plan Markdown */} + {planMarkdown ? ( +
+ + {proposedPlanExpanded ? ( +
+ +
+ ) : null} +
+ ) : null} + + {/* Empty state */} + {!activePlan && !planMarkdown ? ( +
+

+ No active plan yet. +

+

+ Plans will appear here when generated. +

+
+ ) : null} +
+
+
+ ); +}); + +export default PlanSidebar; +export type { PlanSidebarProps }; diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 092f2d598a..437b3e78ef 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -14,7 +14,7 @@ import { SettingsIcon, WrenchIcon, } from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; +import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; import { keybindingValueForCommand, @@ -27,6 +27,15 @@ import { } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { isMacPlatform } from "~/lib/utils"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Dialog, @@ -84,6 +93,7 @@ interface ProjectScriptsControlProps { onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise | void; onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; + onDeleteScript: (scriptId: string) => Promise | void; } function normalizeShortcutKeyToken(key: string): string | null { @@ -144,6 +154,7 @@ export default function ProjectScriptsControl({ onRunScript, onAddScript, onUpdateScript, + onDeleteScript, }: ProjectScriptsControlProps) { const addScriptFormId = React.useId(); const [editingScriptId, setEditingScriptId] = useState(null); @@ -155,6 +166,7 @@ export default function ProjectScriptsControl({ const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [keybinding, setKeybinding] = useState(""); const [validationError, setValidationError] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -247,6 +259,13 @@ export default function ProjectScriptsControl({ setDialogOpen(true); }; + const confirmDeleteScript = useCallback(() => { + if (!editingScriptId) return; + setDeleteConfirmOpen(false); + setDialogOpen(false); + void onDeleteScript(editingScriptId); + }, [editingScriptId, onDeleteScript]); + return ( <> {primaryScript ? ( @@ -440,6 +459,16 @@ export default function ProjectScriptsControl({ + {isEditing && ( + + )} + + + ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8e80c0c1fe..ed75e8a333 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,15 +1,18 @@ import { + ArrowLeftIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, + PlusIcon, RocketIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, + XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { - DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type ProviderKind, @@ -21,20 +24,21 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; -import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { useProjectThreadNavigation } from "../hooks/useProjectThreadNavigation"; import { type Thread } from "../types"; import { derivePendingApprovals } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { providerGetUsageQueryOptions } from "../lib/providerReactQuery"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -53,6 +57,7 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, + SidebarInput, SidebarMenuAction, SidebarMenu, SidebarMenuButton, @@ -69,6 +74,15 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; +function normalizeThreadTitleSearchQuery(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function threadTitleMatchesSearch(thread: Thread, query: string): boolean { + if (query.length === 0) return true; + return thread.title.toLocaleLowerCase().includes(query); +} + async function copyTextToClipboard(text: string): Promise { if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { throw new Error("Clipboard API unavailable."); @@ -277,6 +291,54 @@ function formatSidebarTokenCount(n: number): string { return String(n); } +function resolveUserTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return undefined; + } +} + +function formatUsageResetLabel(resetDate: string): string { + const trimmed = resetDate.trim(); + if (trimmed.length === 0) return resetDate; + + const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed); + if (isDateOnly) { + const [year, month, day] = trimmed.split("-").map(Number); + const utcDate = new Date(Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1)); + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(utcDate); + } + + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return resetDate; + + const includeYear = parsed.getFullYear() !== new Date().getFullYear(); + const userTimeZone = resolveUserTimeZone(); + + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + ...(includeYear ? { year: "numeric" as const } : {}), + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + ...(userTimeZone ? { timeZone: userTimeZone } : {}), + }).format(parsed); +} + +function formatUsagePercentLabel(quota: ProviderUsageQuota, percentUsed: number): string { + if (quota.percentUsed != null) { + return `${String(quota.percentUsed)}%`; + } + return `${Math.round(percentUsed)}%`; +} + function ProviderUsageBar({ label, quota, @@ -320,7 +382,7 @@ function ProviderUsageBar({ {quota.plan ? {quota.plan} : null}
- {percentUsed != null ? `${percentUsed}%${countSuffix}` : "?"} + {percentUsed != null ? `${formatUsagePercentLabel(quota, percentUsed)}${countSuffix}` : "?"}
{percentUsed != null && ( @@ -335,7 +397,9 @@ function ProviderUsageBar({
)} {quota.resetDate && ( -

Resets {quota.resetDate}

+

+ Resets {formatUsageResetLabel(quota.resetDate)} +

)}
); @@ -373,6 +437,7 @@ function ProviderSessionUsageBar({ const USAGE_PROVIDERS: ReadonlyArray<{ provider: ProviderKind; label: string }> = [ { provider: "copilot", label: "Copilot" }, { provider: "codex", label: "Codex" }, + { provider: "cursor", label: "Cursor" }, { provider: "claudeCode", label: "Claude Code" }, { provider: "geminiCli", label: "Gemini" }, { provider: "amp", label: "Amp" }, @@ -403,6 +468,7 @@ function ProviderUsageSection() { const copilotUsage = useProviderUsage("copilot"); const codexUsage = useProviderUsage("codex"); + const cursorUsage = useProviderUsage("cursor"); const claudeUsage = useProviderUsage("claudeCode"); const geminiUsage = useProviderUsage("geminiCli"); const ampUsage = useProviderUsage("amp"); @@ -410,6 +476,7 @@ function ProviderUsageSection() { const usageByProvider: Record = { copilot: copilotUsage.data, codex: codexUsage.data, + cursor: cursorUsage.data, claudeCode: claudeUsage.data, geminiCli: geminiUsage.data, amp: ampUsage.data, @@ -445,7 +512,11 @@ function ProviderUsageSection() { ); } // Session usage (no quota) — show token/cost summary - if (data?.sessionUsage && (data.sessionUsage.totalTokens || data.sessionUsage.totalCostUsd)) { + if ( + provider !== "claudeCode" && + data?.sessionUsage && + (data.sessionUsage.totalTokens || data.sessionUsage.totalCostUsd) + ) { entries.push( , ); @@ -475,6 +546,7 @@ export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); + const moveProject = useStore((store) => store.moveProject); const toggleProject = useStore((store) => store.toggleProject); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); const getDraftThreadByProjectId = useComposerDraftStore( @@ -483,8 +555,6 @@ export default function Sidebar() { const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -492,11 +562,14 @@ export default function Sidebar() { (store) => store.clearProjectDraftThreadById, ); const navigate = useNavigate(); + const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); + const { openOrCreateThread: handleNewThread, openProject: focusMostRecentThreadForProject } = + useProjectThreadNavigation(routeThreadId); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(), select: (config) => config.keybindings, @@ -507,11 +580,20 @@ export default function Sidebar() { const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const [draggingProjectId, setDraggingProjectId] = useState(null); + const draggingProjectIdRef = useRef(null); + const [projectDropTarget, setProjectDropTarget] = useState<{ + projectId: ProjectId; + position: "before" | "after"; + } | null>(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -596,99 +678,59 @@ export default function Sidebar() { }); }, []); - const handleNewThread = useCallback( - ( - projectId: ProjectId, - options?: { - branch?: string | null; - worktreePath?: string | null; - envMode?: DraftThreadEnvMode; - }, - ): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - if (storedDraftThread) { - return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - })(); - } - clearProjectDraftThreadId(projectId); + const getProjectDropPosition = useCallback( + (event: DragEvent): "before" | "after" => { + const bounds = event.currentTarget.getBoundingClientRect(); + return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; + }, + [], + ); - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, routeThreadId); - return Promise.resolve(); - } - const threadId = newThreadId(); - const createdAt = new Date().toISOString(); - return (async () => { - setProjectDraftThreadId(projectId, threadId, { - createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); + const handleProjectDragStart = useCallback((event: DragEvent, projectId: ProjectId) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(projectId)); + draggingProjectIdRef.current = projectId; + setDraggingProjectId(projectId); + setProjectDropTarget({ projectId, position: "after" }); + }, []); - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); + const handleProjectDragOver = useCallback( + (event: DragEvent, projectId: ProjectId) => { + if (!draggingProjectIdRef.current) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setProjectDropTarget({ + projectId, + position: getProjectDropPosition(event), + }); }, - [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - getDraftThread, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], + [getProjectDropPosition], ); - const focusMostRecentThreadForProject = useCallback( - (projectId: ProjectId) => { - const latestThread = threads - .filter((thread) => thread.projectId === projectId) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - })[0]; - if (!latestThread) return; - - void navigate({ - to: "/$threadId", - params: { threadId: latestThread.id }, - }); + const handleProjectDrop = useCallback( + (event: DragEvent, targetProjectId: ProjectId) => { + const currentDraggingId = draggingProjectIdRef.current; + if (!currentDraggingId) { + return; + } + event.preventDefault(); + const position = getProjectDropPosition(event); + moveProject(currentDraggingId, targetProjectId, position); + draggingProjectIdRef.current = null; + setDraggingProjectId(null); + setProjectDropTarget(null); }, - [navigate, threads], + [getProjectDropPosition, moveProject], ); + const clearProjectDragState = useCallback(() => { + draggingProjectIdRef.current = null; + setDraggingProjectId(null); + setProjectDropTarget(null); + }, []); + const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); @@ -700,12 +742,13 @@ export default function Sidebar() { const finishAddingProject = () => { setIsAddingProject(false); setNewCwd(""); + setAddProjectError(null); setAddingProject(false); }; const existing = projects.find((project) => project.cwd === cwd); if (existing) { - focusMostRecentThreadForProject(existing.id); + await focusMostRecentThreadForProject(existing.id); finishAddingProject(); return; } @@ -726,12 +769,9 @@ export default function Sidebar() { await handleNewThread(projectId).catch(() => undefined); } catch (error) { setIsAddingProject(false); - toastManager.add({ - type: "error", - title: "Unable to add project", - description: - error instanceof Error ? error.message : "An error occurred while adding the project.", - }); + setAddProjectError( + error instanceof Error ? error.message : "An error occurred while adding the project.", + ); return; } finishAddingProject(); @@ -755,6 +795,8 @@ export default function Sidebar() { } if (pickedPath) { await addProjectFromPath(pickedPath); + } else { + addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; @@ -1112,6 +1154,15 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); + const normalizedThreadSearchQuery = useMemo( + () => normalizeThreadTitleSearchQuery(threadSearchQuery), + [threadSearchQuery], + ); + const hasActiveThreadSearch = normalizedThreadSearchQuery.length > 0; + const matchingThreadCount = useMemo(() => { + if (!hasActiveThreadSearch) return 0; + return threads.filter((thread) => threadTitleMatchesSearch(thread, normalizedThreadSearchQuery)).length; + }, [hasActiveThreadSearch, normalizedThreadSearchQuery, threads]); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1247,6 +1298,133 @@ export default function Sidebar() { +
+ + Projects + + + { + setAddingProject((prev) => !prev); + setAddProjectError(null); + }} + /> + } + > + + + Add project + +
+ + {addingProject && ( +
+ {isElectron && ( + + )} +
+ { + setNewCwd(event.target.value); + setAddProjectError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }} + autoFocus + /> + +
+ {addProjectError && ( +

+ {addProjectError} +

+ )} +
+ +
+
+ )} + +
+
+ + { + setThreadSearchQuery(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setThreadSearchQuery(""); + }} + /> + {threadSearchQuery.length > 0 && ( + + )} +
+ {hasActiveThreadSearch && ( +

+ {matchingThreadCount === 1 ? "1 matching thread" : `${matchingThreadCount} matching threads`} +

+ )} +
{projects.map((project) => { const projectThreads = threads @@ -1256,30 +1434,76 @@ export default function Sidebar() { if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); }); - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const filteredProjectThreads = hasActiveThreadSearch + ? projectThreads.filter((thread) => + threadTitleMatchesSearch(thread, normalizedThreadSearchQuery), + ) + : projectThreads; + if (hasActiveThreadSearch && filteredProjectThreads.length === 0) { + return null; + } + const isThreadSearchFiltering = hasActiveThreadSearch; + const isThreadListExpanded = + isThreadSearchFiltering || expandedThreadListsByProject.has(project.id); + const hasHiddenThreads = + !isThreadSearchFiltering && filteredProjectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; + ? filteredProjectThreads.slice(0, THREAD_PREVIEW_LIMIT) + : filteredProjectThreads; + const isProjectOpen = project.expanded || isThreadSearchFiltering; return ( { - if (open === project.expanded) return; + if (isThreadSearchFiltering || open === project.expanded) return; toggleProject(project.id); }} > -
+
{ + handleProjectDragStart(event, project.id); + }} + onDragEnd={clearProjectDragState} + onDragOver={(event) => { + handleProjectDragOver(event, project.id); + }} + onDragLeave={(event) => { + if ( + !event.currentTarget.contains( + event.relatedTarget as Node | null, + ) + ) { + setProjectDropTarget(null); + } + }} + onDrop={(event) => { + handleProjectDrop(event, project.id); + }} + > + {projectDropTarget?.projectId === project.id ? ( +
+ ) : null} } onContextMenu={(event) => { @@ -1292,7 +1516,7 @@ export default function Sidebar() { > @@ -1471,7 +1695,6 @@ export default function Sidebar() { ); })} - {hasHiddenThreads && !isThreadListExpanded && ( + {hasActiveThreadSearch && matchingThreadCount === 0 && ( +
+ No matching threads. +
+ )} {projects.length === 0 && !addingProject && (
No projects yet. @@ -1518,62 +1746,32 @@ export default function Sidebar() { - - - {addingProject ? ( - <> -

- Add project -

- setNewCwd(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") setAddingProject(false); - }} - /> - {isElectron && ( - - )} -
- - -
- - ) : ( - - )} + + Settings + + )} + +
); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index e6016e6cd0..c47ab076c7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -185,7 +185,7 @@ function TerminalViewport({ lineHeight: 1.2, fontSize: 12, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: '"Geist Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 001a240ddf..79ce8957d5 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -6,6 +6,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; type InputProps = Omit, "size"> & { + inputClassName?: string; size?: "sm" | "default" | "lg" | number; unstyled?: boolean; nativeInput?: boolean; @@ -13,6 +14,7 @@ type InputProps = Omit void; setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void; setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void; + setClaudeCodeEffort: (threadId: ThreadId, effort: ClaudeCodeEffort | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; @@ -166,12 +172,17 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, effort: null, codexFastMode: false, + claudeCodeEffort: null, }) as ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set( REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, ); +const CLAUDE_CODE_EFFORT_VALUES = new Set( + CLAUDE_CODE_EFFORT_OPTIONS_BY_PROVIDER.claudeCode, +); + function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", @@ -184,6 +195,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { interactionMode: null, effort: null, codexFastMode: false, + claudeCodeEffort: null, }; } @@ -203,7 +215,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + draft.claudeCodeEffort === null ); } @@ -392,6 +405,12 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const codexFastMode = draftCandidate.codexFastMode === true || (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + const claudeCodeEffortCandidate = + typeof draftCandidate.claudeCodeEffort === "string" ? draftCandidate.claudeCodeEffort : null; + const claudeCodeEffort = + claudeCodeEffortCandidate && CLAUDE_CODE_EFFORT_VALUES.has(claudeCodeEffortCandidate as ClaudeCodeEffort) + ? (claudeCodeEffortCandidate as ClaudeCodeEffort) + : null; if ( prompt.length === 0 && attachments.length === 0 && @@ -400,7 +419,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer !runtimeMode && !interactionMode && !effort && - !codexFastMode + !codexFastMode && + !claudeCodeEffort ) { continue; } @@ -413,6 +433,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(interactionMode ? { interactionMode } : {}), ...(effort ? { effort } : {}), ...(codexFastMode ? { codexFastMode } : {}), + ...(claudeCodeEffort ? { claudeCodeEffort } : {}), }; } return { @@ -520,6 +541,7 @@ function toHydratedThreadDraft( interactionMode: persistedDraft.interactionMode ?? null, effort: persistedDraft.effort ?? null, codexFastMode: persistedDraft.codexFastMode === true, + claudeCodeEffort: persistedDraft.claudeCodeEffort ?? null, }; } @@ -950,6 +972,38 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setClaudeCodeEffort: (threadId, effort) => { + if (threadId.length === 0) { + return; + } + const nextEffort = + effort && + CLAUDE_CODE_EFFORT_VALUES.has(effort) && + effort !== DEFAULT_CLAUDE_CODE_EFFORT_BY_PROVIDER.claudeCode + ? effort + : null; + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing && nextEffort === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.claudeCodeEffort === nextEffort) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + claudeCodeEffort: nextEffort, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, addImage: (threadId, image) => { if (threadId.length === 0) { return; @@ -1187,7 +1241,8 @@ export const useComposerDraftStore = create()( draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + draft.claudeCodeEffort === null ) { continue; } @@ -1213,6 +1268,9 @@ export const useComposerDraftStore = create()( if (draft.codexFastMode) { persistedDraft.codexFastMode = true; } + if (draft.claudeCodeEffort) { + persistedDraft.claudeCodeEffort = draft.claudeCodeEffort; + } persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; } return { diff --git a/apps/web/src/hooks/useProjectThreadNavigation.ts b/apps/web/src/hooks/useProjectThreadNavigation.ts new file mode 100644 index 0000000000..5338ec48f7 --- /dev/null +++ b/apps/web/src/hooks/useProjectThreadNavigation.ts @@ -0,0 +1,130 @@ +import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { newThreadId } from "../lib/utils"; +import { useStore } from "../store"; + +interface OpenProjectThreadOptions { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; +} + +function latestThreadIdForProject( + projectId: ProjectId, + threadIdsByProject: ReadonlyArray<{ id: ThreadId; projectId: ProjectId; createdAt: string }>, +): ThreadId | null { + const latestThread = threadIdsByProject + .filter((thread) => thread.projectId === projectId) + .toSorted((left, right) => { + const byDate = new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); + if (byDate !== 0) return byDate; + return right.id.localeCompare(left.id); + })[0]; + + return latestThread?.id ?? null; +} + +export function useProjectThreadNavigation(routeThreadId: ThreadId | null) { + const threads = useStore((store) => store.threads); + const navigate = useNavigate(); + const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId); + + const navigateToThread = useCallback( + async (threadId: ThreadId) => { + if (routeThreadId === threadId) return; + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [navigate, routeThreadId], + ); + + const openOrCreateThread = useCallback( + async (projectId: ProjectId, options?: OpenProjectThreadOptions) => { + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const hasEnvModeOption = options?.envMode !== undefined; + const storedDraftThread = getDraftThreadByProjectId(projectId); + + if (storedDraftThread) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(storedDraftThread.threadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + await navigateToThread(storedDraftThread.threadId); + return; + } + + clearProjectDraftThreadId(projectId); + + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; + if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(routeThreadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, routeThreadId); + return; + } + + const nextThreadId = newThreadId(); + setProjectDraftThreadId(projectId, nextThreadId, { + createdAt: new Date().toISOString(), + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, + }); + + await navigateToThread(nextThreadId); + }, + [ + clearProjectDraftThreadId, + getDraftThread, + getDraftThreadByProjectId, + navigateToThread, + routeThreadId, + setDraftThreadContext, + setProjectDraftThreadId, + ], + ); + + const openProject = useCallback( + async (projectId: ProjectId) => { + const latestThreadId = latestThreadIdForProject(projectId, threads); + if (latestThreadId) { + await navigateToThread(latestThreadId); + return; + } + + const draftThread = getDraftThreadByProjectId(projectId); + if (draftThread) { + setProjectDraftThreadId(projectId, draftThread.threadId); + await navigateToThread(draftThread.threadId); + return; + } + + await openOrCreateThread(projectId); + }, + [getDraftThreadByProjectId, navigateToThread, openOrCreateThread, setProjectDraftThreadId, threads], + ); + + return { + openOrCreateThread, + openProject, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8a597be55c..acb2722c14 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -36,6 +36,7 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + --font-mono: "Geist Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; @keyframes skeleton { to { background-position: -200% 0; @@ -162,7 +163,7 @@ pre, code, textarea, input { - font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-family: "Geist Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } /* Window drag region (frameless titlebar) */ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..142bfa9b54 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, + isCommandPaletteShortcut, isChatNewShortcut, isChatNewLocalShortcut, isDiffToggleShortcut, @@ -76,6 +77,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("k"), command: "commandPalette.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("d"), @@ -235,6 +237,10 @@ describe("shortcutLabelForCommand", () => { }); it("returns labels for non-terminal commands", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), + "⌘K", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( @@ -245,6 +251,19 @@ describe("shortcutLabelForCommand", () => { }); describe("chat/editor shortcuts", () => { + it("matches commandPalette.toggle shortcut", () => { + assert.isTrue( + isCommandPaletteShortcut(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isTrue( + isCommandPaletteShortcut(event({ key: "k", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); + }); + it("matches chat.new shortcut", () => { assert.isTrue( isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..48cdb8c612 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -174,6 +174,14 @@ export function isTerminalToggleShortcut( return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); } +export function isCommandPaletteShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "commandPalette.toggle", options); +} + export function isTerminalSplitShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index dbd7ad1c3a..59b1174eac 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import type { GitStackedAction } from "@t3tools/contracts"; +import type { GitStackedAction, ProviderKind } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -97,10 +97,14 @@ export function gitRunStackedActionMutationOptions(input: { action, commitMessage, featureBranch, + provider, + model, }: { action: GitStackedAction; commitMessage?: string; featureBranch?: boolean; + provider?: ProviderKind; + model?: string; }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git action is unavailable."); @@ -109,6 +113,8 @@ export function gitRunStackedActionMutationOptions(input: { action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), }); }, onSettled: async () => { diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 1550eb7de1..3bd4f62e60 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string const title = proposedPlanTitle(planMarkdown); return `${sanitizePlanFileSegment(title ?? "plan")}.md`; } + +export function normalizePlanMarkdownForExport(planMarkdown: string): string { + return `${planMarkdown.trimEnd()}\n`; +} + +export function downloadPlanAsTextFile(filename: string, contents: string): void { + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 0); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a462afe555..472a3922f9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type ProviderKind } from "@t3tools/contracts"; import { Outlet, createRootRouteWithContext, @@ -35,6 +35,21 @@ export const Route = createRootRouteWithContext<{ }), }); +const PROVIDER_KINDS = [ + "codex", + "copilot", + "claudeCode", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", +] as const satisfies ReadonlyArray; + +function isProviderKind(value: string): value is ProviderKind { + return (PROVIDER_KINDS as readonly string[]).includes(value); +} + function RootRouteView() { const { settings } = useAppSettings(); @@ -200,6 +215,14 @@ function EventRouter() { return; } latestSequence = event.sequence; + if (event.type === "thread.session-set") { + const providerName = event.payload.session.providerName; + if (providerName && isProviderKind(providerName)) { + void queryClient.invalidateQueries({ + queryKey: providerQueryKeys.getUsage(providerName), + }); + } + } if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index dbda3fec2b..8f9176d6ee 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -641,11 +641,11 @@ function SettingsRouteView() {

Custom color for this provider's usage bar. Leave unset to use the global accent color.

-
+
{ const color = normalizeAccentColor(event.target.value); updateSettings({ @@ -693,7 +693,8 @@ function SettingsRouteView() {

Stream assistant messages

- Show token-by-token output while a response is in progress. + Show token-by-token output while a response is in progress. Cursor turns + always stream so tool calls and assistant text stay interleaved.

{ "apps/web/src/session-logic.ts", ]); }); + + it("keeps tool item types for icon rendering", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "web-search-tool", + kind: "tool.completed", + summary: "Web search complete", + payload: { + itemType: "web_search", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.activityKind).toBe("tool.completed"); + expect(entry?.itemType).toBe("web_search"); + }); + + it("maps request kinds for approval work log entries", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-entry", + kind: "approval.requested", + summary: "File-read approval requested", + tone: "approval", + payload: { + requestType: "file_read_approval", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.activityKind).toBe("approval.requested"); + expect(entry?.requestKind).toBe("file-read"); + expect(entry?.tone).toBe("info"); + }); + + it("keeps multi-turn tool activity since the latest user message", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "before-user", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + summary: "Old tool call", + kind: "tool.completed", + tone: "tool", + }), + makeActivity({ + id: "after-user-first-turn", + createdAt: "2026-02-23T00:00:03.000Z", + turnId: "turn-2", + summary: "First Copilot tool call", + kind: "tool.completed", + tone: "tool", + }), + makeActivity({ + id: "after-user-second-turn", + createdAt: "2026-02-23T00:00:04.000Z", + turnId: "turn-3", + summary: "Second Copilot tool call", + kind: "tool.completed", + tone: "tool", + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined, "2026-02-23T00:00:02.000Z"); + expect(entries.map((entry) => entry.id)).toEqual([ + "after-user-first-turn", + "after-user-second-turn", + ]); + }); }); describe("deriveTimelineEntries", () => { @@ -508,6 +580,7 @@ describe("deriveTimelineEntries", () => { createdAt: "2026-02-23T00:00:03.000Z", label: "Ran tests", tone: "tool", + activityKind: "tool.completed", }, ], ); @@ -543,6 +616,30 @@ describe("hasToolActivityForTurn", () => { }); }); +describe("hasToolActivitySince", () => { + it("tracks tool activity across multiple turns since the latest user message", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "before-user", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + kind: "tool.completed", + tone: "tool", + }), + makeActivity({ + id: "after-user", + createdAt: "2026-02-23T00:00:03.000Z", + turnId: "turn-2", + kind: "tool.completed", + tone: "tool", + }), + ]; + + expect(hasToolActivitySince(activities, "2026-02-23T00:00:02.000Z")).toBe(true); + expect(hasToolActivitySince(activities, "2026-02-23T00:00:04.000Z")).toBe(false); + }); +}); + describe("isLatestTurnSettled", () => { const latestTurn = { turnId: TurnId.makeUnsafe("turn-1"), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5e188d711d..93762eb677 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,6 +35,16 @@ export interface WorkLogEntry { command?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; + activityKind: OrchestrationThreadActivity["kind"]; + itemType?: + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + requestKind?: PendingApproval["requestKind"]; } export interface PendingApproval { @@ -410,10 +420,14 @@ export function findLatestProposedPlan( export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, + sinceCreatedAt?: string, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); return ordered - .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) + .filter((activity) => + latestTurnId ? activity.turnId === latestTurnId || activity.turnId === null : true, + ) + .filter((activity) => (sinceCreatedAt ? activity.createdAt >= sinceCreatedAt : true)) .filter((activity) => activity.kind !== "tool.started") .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") .filter((activity) => activity.summary !== "Checkpoint captured") @@ -429,7 +443,10 @@ export function deriveWorkLogEntries( createdAt: activity.createdAt, label: activity.summary, tone: activity.tone === "approval" ? "info" : activity.tone, + activityKind: activity.kind, }; + const itemType = extractWorkLogItemType(payload); + const requestKind = extractWorkLogRequestKind(payload); if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { entry.detail = payload.detail; } @@ -439,6 +456,12 @@ export function deriveWorkLogEntries( if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } + if (itemType) { + entry.itemType = itemType; + } + if (requestKind) { + entry.requestKind = requestKind; + } return entry; }); } @@ -483,6 +506,36 @@ function extractToolCommand(payload: Record | null): string | n return candidates.find((candidate) => candidate !== null) ?? null; } +function extractWorkLogItemType( + payload: Record | null, +): WorkLogEntry["itemType"] | undefined { + switch (payload?.itemType) { + case "command_execution": + case "file_change": + case "mcp_tool_call": + case "dynamic_tool_call": + case "collab_agent_tool_call": + case "web_search": + case "image_view": + return payload.itemType; + default: + return undefined; + } +} + +function extractWorkLogRequestKind( + payload: Record | null, +): WorkLogEntry["requestKind"] | undefined { + if ( + payload?.requestKind === "command" || + payload?.requestKind === "file-read" || + payload?.requestKind === "file-change" + ) { + return payload.requestKind; + } + return requestKindFromRequestType(payload?.requestType) ?? undefined; +} + function pushChangedFile(target: string[], seen: Set, value: unknown) { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { @@ -577,6 +630,16 @@ export function hasToolActivityForTurn( return activities.some((activity) => activity.turnId === turnId && activity.tone === "tool"); } +export function hasToolActivitySince( + activities: ReadonlyArray, + sinceCreatedAt: string | undefined, +): boolean { + return activities.some( + (activity) => + activity.tone === "tool" && (sinceCreatedAt ? activity.createdAt >= sinceCreatedAt : true), + ); +} + export function deriveTimelineEntries( messages: ChatMessage[], proposedPlans: ProposedPlan[], diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 1b279eea57..97aea424dc 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,7 +1,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; +import { markThreadUnread, moveProject, syncServerReadModel, type AppState } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { @@ -44,6 +44,17 @@ function makeState(thread: Thread): AppState { }; } +function makeProject(projectId: string, name = projectId) { + return { + id: ProjectId.makeUnsafe(projectId), + name, + cwd: `/tmp/${projectId}`, + model: "gpt-5-codex", + expanded: true, + scripts: [], + }; +} + function makeReadModelThread(overrides: Partial) { return { id: ThreadId.makeUnsafe("thread-1"), @@ -126,6 +137,48 @@ describe("store pure functions", () => { expect(next).toEqual(initialState); }); + + it("moveProject inserts a project before the drop target", () => { + const initialState: AppState = { + projects: [makeProject("project-1"), makeProject("project-2"), makeProject("project-3")], + threads: [], + threadsHydrated: true, + }; + + const next = moveProject( + initialState, + ProjectId.makeUnsafe("project-3"), + ProjectId.makeUnsafe("project-1"), + "before", + ); + + expect(next.projects.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-3"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + + it("moveProject inserts a project after the drop target", () => { + const initialState: AppState = { + projects: [makeProject("project-1"), makeProject("project-2"), makeProject("project-3")], + threads: [], + threadsHydrated: true, + }; + + const next = moveProject( + initialState, + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + "after", + ); + + expect(next.projects.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-3"), + ]); + }); }); describe("store read model sync", () => { @@ -186,4 +239,46 @@ describe("store read model sync", () => { expect(next.threads[0]?.model).toBe("composer-1.5"); expect(next.threads[0]?.session?.provider).toBe("cursor"); }); + + it("preserves locally reordered projects across read model syncs", () => { + const initialState: AppState = { + projects: [makeProject("project-2", "Project 2"), makeProject("project-1", "Project 1")], + threads: [], + threadsHydrated: true, + }; + const readModel: OrchestrationReadModel = { + snapshotSequence: 1, + updatedAt: "2026-02-27T00:00:00.000Z", + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModel: "gpt-5.3-codex", + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + scripts: [], + }, + { + id: ProjectId.makeUnsafe("project-2"), + title: "Project 2", + workspaceRoot: "/tmp/project-2", + defaultModel: "gpt-5.3-codex", + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + deletedAt: null, + scripts: [], + }, + ], + threads: [], + }; + + const next = syncServerReadModel(initialState, readModel); + + expect(next.projects.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index b001251327..3070232fb4 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -25,6 +25,7 @@ export interface AppState { const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; const LEGACY_PERSISTED_STATE_KEYS = [ + "t3code:renderer-state:v7", "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", @@ -41,6 +42,7 @@ const initialState: AppState = { threadsHydrated: false, }; const persistedExpandedProjectCwds = new Set(); +let persistedProjectOrderCwds: string[] = []; // ── Persist helpers ────────────────────────────────────────────────── @@ -49,15 +51,25 @@ function readPersistedState(): AppState { try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; - const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] }; + const parsed = JSON.parse(raw) as { + expandedProjectCwds?: string[]; + projectOrderCwds?: string[]; + }; persistedExpandedProjectCwds.clear(); + persistedProjectOrderCwds = []; for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } + for (const cwd of parsed.projectOrderCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedProjectOrderCwds.push(cwd); + } + } return { ...initialState }; } catch { + persistedProjectOrderCwds = []; return initialState; } } @@ -71,6 +83,7 @@ function persistState(state: AppState): void { expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), + projectOrderCwds: state.projects.map((project) => project.cwd), }), ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { @@ -102,7 +115,7 @@ function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], ): Project[] { - return incoming.map((project) => { + const mappedProjects = incoming.map((project) => { const existing = previous.find((entry) => entry.id === project.id) ?? previous.find((entry) => entry.cwd === project.workspaceRoot); @@ -121,6 +134,25 @@ function mapProjectsFromReadModel( scripts: project.scripts.map((script) => ({ ...script })), }; }); + + const projectOrderCwds = + previous.length > 0 ? previous.map((project) => project.cwd) : persistedProjectOrderCwds; + if (projectOrderCwds.length === 0) { + return mappedProjects; + } + + const projectOrderByCwd = new Map( + projectOrderCwds.map((cwd, index) => [cwd, index] as const), + ); + + return mappedProjects.toSorted((left, right) => { + const leftIndex = projectOrderByCwd.get(left.cwd); + const rightIndex = projectOrderByCwd.get(right.cwd); + if (leftIndex === undefined && rightIndex === undefined) return 0; + if (leftIndex === undefined) return 1; + if (rightIndex === undefined) return -1; + return leftIndex - rightIndex; + }); } function toLegacySessionStatus( @@ -408,6 +440,35 @@ export function setProjectExpanded( return changed ? { ...state, projects } : state; } +export function moveProject( + state: AppState, + movedProjectId: Project["id"], + targetProjectId: Project["id"], + position: "before" | "after", +): AppState { + if (movedProjectId === targetProjectId) { + return state; + } + + const movedProject = state.projects.find((project) => project.id === movedProjectId); + if (!movedProject) { + return state; + } + + const remainingProjects = state.projects.filter((project) => project.id !== movedProjectId); + const targetIndex = remainingProjects.findIndex((project) => project.id === targetProjectId); + if (targetIndex < 0) { + return state; + } + + const insertionIndex = position === "before" ? targetIndex : targetIndex + 1; + const projects = [...remainingProjects]; + projects.splice(insertionIndex, 0, movedProject); + + const orderChanged = projects.some((project, index) => project.id !== state.projects[index]?.id); + return orderChanged ? { ...state, projects } : state; +} + export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; @@ -443,6 +504,11 @@ interface AppStore extends AppState { markThreadUnread: (threadId: ThreadId) => void; toggleProject: (projectId: Project["id"]) => void; setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; + moveProject: ( + movedProjectId: Project["id"], + targetProjectId: Project["id"], + position: "before" | "after", + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } @@ -456,6 +522,8 @@ export const useStore = create((set) => ({ toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), + moveProject: (movedProjectId, targetProjectId, position) => + set((state) => moveProject(state, movedProjectId, targetProjectId, position)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index ef9dddb347..f106947738 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -44,7 +44,7 @@ export class WsTransport { ? bridgeUrl : envUrl && envUrl.length > 0 ? envUrl - : `ws://${window.location.hostname}:${window.location.port}`); + : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); this.connect(); } @@ -110,6 +110,10 @@ export class WsTransport { ws.addEventListener("open", () => { this.ws = ws; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } this.reconnectAttempt = 0; }); @@ -122,8 +126,14 @@ export class WsTransport { this.scheduleReconnect(); }); + // The "close" event always fires after "error", but scheduleReconnect() + // guards against double-scheduling via the reconnectTimer check, so both + // handlers can safely call it independently. ws.addEventListener("error", () => { - // close event will fire after error + if (this.ws === ws) { + this.ws = null; + } + this.scheduleReconnect(); }); } @@ -198,7 +208,7 @@ export class WsTransport { } private scheduleReconnect() { - if (this.disposed) return; + if (this.disposed || this.reconnectTimer !== null) return; const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 652ebea30e..18db4746bf 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ tailwindcss(), ], optimizeDeps: { - include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], + include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js", "ghostty-web"], }, define: { // In dev mode, tell the web app where the WebSocket server lives diff --git a/bun.lock b/bun.lock index f03f26bdf3..f243c060c0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,11 @@ "": { "name": "@t3tools/monorepo", "devDependencies": { - "@types/node": "catalog:", + "@types/node": "^24.12.0", "oxfmt": "^0.35.0", - "oxlint": "^1.50.0", - "turbo": "^2.3.3", - "vitest": "catalog:", + "oxlint": "^1.51.0", + "turbo": "^2.8.14", + "vitest": "^4.0.18", }, }, "apps/desktop": { @@ -91,6 +91,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "ghostty-web": "^0.4.0", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "^19.0.0", @@ -1173,6 +1174,8 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/package.json b/package.json index 02e10dcefe..3523fcdd23 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", - "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" + "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", + "release:smoke": "node scripts/release-smoke.ts" }, "devDependencies": { "@types/node": "^24.12.0", diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 8d7e029233..345b8ace4c 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -3,8 +3,15 @@ import { TrimmedNonEmptyString } from "./baseSchemas"; export const EDITORS = [ { id: "cursor", label: "Cursor", command: "cursor" }, + { id: "windsurf", label: "Windsurf", command: "windsurf" }, { id: "vscode", label: "VS Code", command: "code" }, { id: "zed", label: "Zed", command: "zed" }, + { id: "positron", label: "Positron", command: "positron" }, + { id: "sublime", label: "Sublime Text", command: "subl" }, + { id: "webstorm", label: "WebStorm", command: "webstorm" }, + { id: "intellij", label: "IntelliJ IDEA", command: "idea" }, + { id: "fleet", label: "Fleet", command: "fleet" }, + { id: "ghostty", label: "Ghostty", command: "ghostty" }, { id: "file-manager", label: "File Manager", command: null }, ] as const; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 80ede248e6..8d9df00c58 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -53,6 +54,8 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + provider: Schema.optional(ProviderKind), + model: Schema.optional(Schema.String), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..db9de7a14d 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -23,6 +23,12 @@ const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as it.effect("parses keybinding rules", () => Effect.gen(function* () { + const parsedPalette = yield* decode(KeybindingRule, { + key: "mod+k", + command: "commandPalette.toggle", + }); + assert.strictEqual(parsedPalette.command, "commandPalette.toggle"); + const parsed = yield* decode(KeybindingRule, { key: "mod+j", command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..6c16e94c9d 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -8,6 +8,7 @@ export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; const STATIC_KEYBINDING_COMMANDS = [ + "commandPalette.toggle", "terminal.toggle", "terminal.split", "terminal.new", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8934e47bd0..4f9658a0f9 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -7,13 +7,18 @@ export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; +export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max"] as const; +export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; + export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), }); export type CodexModelOptions = typeof CodexModelOptions.Type; -export const CopilotModelOptions = Schema.Struct({}); +export const CopilotModelOptions = Schema.Struct({ + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), +}); export type CopilotModelOptions = typeof CopilotModelOptions.Type; export const OpencodeModelOptions = Schema.Struct({ @@ -27,6 +32,7 @@ export type OpencodeModelOptions = typeof OpencodeModelOptions.Type; export const ClaudeCodeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), + effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), }); export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type; @@ -83,12 +89,30 @@ export const CURSOR_MODEL_FAMILY_OPTIONS = [ { slug: "auto", name: "Auto" }, { slug: "composer-1.5", name: "Composer 1.5" }, { slug: "composer-1", name: "Composer 1" }, + { slug: "gpt-5.4-medium", name: "GPT-5.4" }, + { slug: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" }, + { slug: "gpt-5.4-high", name: "GPT-5.4 High" }, + { slug: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { slug: "gpt-5.4-xhigh", name: "GPT-5.4 Extra High" }, + { slug: "gpt-5.4-xhigh-fast", name: "GPT-5.4 Extra High Fast" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + { slug: "gpt-5.2-high", name: "GPT-5.2 High" }, + { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { slug: "gpt-5.1-codex-max-high", name: "GPT-5.1 Codex Max High" }, + { slug: "gpt-5.1-high", name: "GPT-5.1 High" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, { slug: "opus-4.6", name: "Claude 4.6 Opus" }, { slug: "opus-4.5", name: "Claude 4.5 Opus" }, { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, + { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { slug: "gemini-3-pro", name: "Gemini 3 Pro" }, + { slug: "gemini-3-flash", name: "Gemini 3 Flash" }, + { slug: "grok", name: "Grok" }, + { slug: "kimi-k2.5", name: "Kimi K2.5" }, ] as const satisfies readonly CursorModelFamilyOption[]; export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; @@ -102,32 +126,24 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2", name: "GPT-5.2" }, ], copilot: [ - // Multipliers sourced from https://docs.github.com/en/copilot/concepts/billing/copilot-requests - { slug: "gpt-5.4", name: "GPT-5.4", pricingTier: "1x" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex", pricingTier: "1x" }, - { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex", pricingTier: "1x" }, - { slug: "gpt-5.2", name: "GPT-5.2", pricingTier: "1x" }, - { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max", pricingTier: "1x" }, - { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex", pricingTier: "1x" }, - { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini (Preview)", pricingTier: "0.33x" }, - { slug: "gpt-5.1", name: "GPT-5.1", pricingTier: "1x" }, - { slug: "gpt-5-mini", name: "GPT-5 mini" }, // included, no premium cost - { slug: "gpt-4.1", name: "GPT-4.1" }, // included, no premium cost - { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", pricingTier: "1x" }, - { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", pricingTier: "1x" }, - { slug: "claude-sonnet-4", name: "Claude Sonnet 4", pricingTier: "1x" }, - { slug: "claude-opus-4.6", name: "Claude Opus 4.6", pricingTier: "3x" }, - { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 Fast (Preview)", pricingTier: "30x" }, - { slug: "claude-opus-4.5", name: "Claude Opus 4.5", pricingTier: "3x" }, - { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5", pricingTier: "0.33x" }, - { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro (Preview)", pricingTier: "1x" }, - { slug: "gemini-3-pro", name: "Gemini 3 Pro (Preview)", pricingTier: "1x" }, - { slug: "gemini-3-flash", name: "Gemini 3 Flash (Preview)", pricingTier: "0.33x" }, - { slug: "gemini-2.5-pro", name: "Gemini 2.5 Pro", pricingTier: "1x" }, - { slug: "grok-code-fast-1", name: "Grok Code Fast 1", pricingTier: "0.25x" }, - { slug: "goldeneye", name: "Goldeneye (Preview)" }, - { slug: "qwen2.5", name: "Qwen2.5" }, - { slug: "raptor-mini", name: "Raptor mini (Preview)" }, // included, no premium cost + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { slug: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { slug: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { slug: "claude-opus-4.6", name: "Claude Opus 4.6" }, + { slug: "claude-opus-4.6-fast", name: "Claude Opus 4.6 (fast mode)" }, + { slug: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { slug: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { slug: "gemini-3-pro-preview", name: "Gemini 3 Pro (Preview)" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, + { slug: "gpt-5.1", name: "GPT-5.1" }, + { slug: "gpt-5-mini", name: "GPT-5 mini" }, + { slug: "gpt-4.1", name: "GPT-4.1" }, ], claudeCode: [ { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, @@ -146,14 +162,40 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" }, { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" }, + { slug: "gpt-5.2-codex-low", name: "GPT-5.2 Codex Low" }, + { slug: "gpt-5.2-codex-low-fast", name: "GPT-5.2 Codex Low Fast" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2-codex-fast", name: "GPT-5.2 Codex Fast" }, + { slug: "gpt-5.2-codex-high", name: "GPT-5.2 Codex High" }, + { slug: "gpt-5.2-codex-high-fast", name: "GPT-5.2 Codex High Fast" }, + { slug: "gpt-5.2-codex-xhigh", name: "GPT-5.2 Codex Extra High" }, + { slug: "gpt-5.2-codex-xhigh-fast", name: "GPT-5.2 Codex Extra High Fast" }, + { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, + { slug: "gpt-5.1-codex-max-high", name: "GPT-5.1 Codex Max High" }, + { slug: "gpt-5.4-high", name: "GPT-5.4 High" }, { slug: "opus-4.6", name: "Claude 4.6 Opus" }, { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" }, + { slug: "gpt-5.4-medium", name: "GPT-5.4" }, + { slug: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" }, + { slug: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { slug: "gpt-5.4-xhigh", name: "GPT-5.4 Extra High" }, + { slug: "gpt-5.4-xhigh-fast", name: "GPT-5.4 Extra High Fast" }, { slug: "opus-4.5", name: "Claude 4.5 Opus" }, { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" }, { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" }, + { slug: "gpt-5.2-high", name: "GPT-5.2 High" }, { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { slug: "grok", name: "Grok" }, + { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, + { slug: "sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)" }, + { slug: "gpt-5.1-high", name: "GPT-5.1 High" }, + { slug: "gemini-3-pro", name: "Gemini 3 Pro" }, + { slug: "gemini-3-flash", name: "Gemini 3 Flash" }, + { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, + { slug: "kimi-k2.5", name: "Kimi K2.5" }, ], opencode: [] as ModelOption[], geminiCli: [ @@ -178,7 +220,7 @@ export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][numbe export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", - copilot: "gpt-5.4", + copilot: "claude-sonnet-4.6", claudeCode: "claude-sonnet-4-6", cursor: "opus-4.6-thinking", opencode: "gpt-5", @@ -201,8 +243,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; + +export const CLAUDE_CODE_EFFORT_OPTIONS_BY_PROVIDER = { + codex: [], + copilot: [], + claudeCode: CLAUDE_CODE_EFFORT_OPTIONS, + cursor: [], + opencode: [], + kilo: [], + geminiCli: [], + amp: [], +} as const satisfies Record; + +export const DEFAULT_CLAUDE_CODE_EFFORT_BY_PROVIDER = { + codex: null, + copilot: null, + claudeCode: "high", + cursor: null, + opencode: null, + kilo: null, + geminiCli: null, + amp: null, +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 61f0ff2c51..6500c5ddce 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -54,6 +54,73 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); export type ProviderServiceTier = typeof ProviderServiceTier.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; +export const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), +}); +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; + +export const CopilotProviderStartOptions = Schema.Struct({ + cliPath: Schema.optional(TrimmedNonEmptyString), + configDir: Schema.optional(TrimmedNonEmptyString), +}); +export type CopilotProviderStartOptions = typeof CopilotProviderStartOptions.Type; + +export const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + permissionMode: Schema.optional(TrimmedNonEmptyString), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); +export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type; + +export const CursorProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), +}); +export type CursorProviderStartOptions = typeof CursorProviderStartOptions.Type; + +export const GeminiCliProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), +}); +export type GeminiCliProviderStartOptions = typeof GeminiCliProviderStartOptions.Type; + +export const AmpProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), +}); +export type AmpProviderStartOptions = typeof AmpProviderStartOptions.Type; + +export const OpencodeProviderStartOptions = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyString), + binaryPath: Schema.optional(TrimmedNonEmptyString), + hostname: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(Schema.Number), + workspace: Schema.optional(TrimmedNonEmptyString), + username: Schema.optional(TrimmedNonEmptyString), + password: Schema.optional(TrimmedNonEmptyString), +}); +export type OpencodeProviderStartOptions = typeof OpencodeProviderStartOptions.Type; + +export const KiloProviderStartOptions = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyString), + binaryPath: Schema.optional(TrimmedNonEmptyString), + hostname: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(Schema.Number), + workspace: Schema.optional(TrimmedNonEmptyString), + username: Schema.optional(TrimmedNonEmptyString), + password: Schema.optional(TrimmedNonEmptyString), +}); +export type KiloProviderStartOptions = typeof KiloProviderStartOptions.Type; + +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), + amp: Schema.optional(AmpProviderStartOptions), + geminiCli: Schema.optional(GeminiCliProviderStartOptions), + opencode: Schema.optional(OpencodeProviderStartOptions), + kilo: Schema.optional(KiloProviderStartOptions), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -386,6 +453,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -408,6 +476,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -689,6 +758,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 97e168a33a..3c5650907a 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -43,6 +43,26 @@ describe("ProviderSessionStartInput", () => { ).toThrow(); }); + it("accepts copilot provider payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "copilot", + cwd: "/tmp/workspace", + model: "claude-sonnet-4.6", + providerOptions: { + copilot: { + cliPath: "/usr/local/bin/gh", + configDir: "/tmp/.copilot", + }, + }, + runtimeMode: "full-access", + }); + expect(parsed.provider).toBe("copilot"); + expect(parsed.providerOptions?.copilot?.cliPath).toBe("/usr/local/bin/gh"); + expect(parsed.providerOptions?.copilot?.configDir).toBe("/tmp/.copilot"); + expect(parsed.runtimeMode).toBe("full-access"); + }); + it("accepts claude runtime knobs", () => { const parsed = decodeProviderSessionStartInput({ threadId: "thread-1", @@ -65,7 +85,7 @@ describe("ProviderSessionStartInput", () => { expect(parsed.runtimeMode).toBe("full-access"); }); - it("accepts cursor provider payloads", () => { + it("accepts cursor provider payloads with binary path", () => { const parsed = decodeProviderSessionStartInput({ threadId: "thread-1", provider: "cursor", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index dfe67b9df6..946a38e451 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -5,7 +5,6 @@ import { ApprovalRequestId, EventId, IsoDateTime, - NonNegativeInt, ProviderItemId, ThreadId, TurnId, @@ -21,6 +20,7 @@ import { ProviderRequestKind, ProviderSandboxMode, ProviderServiceTier, + ProviderStartOptions, ProviderUserInputAnswers, RuntimeMode, } from "./orchestration"; @@ -49,64 +49,7 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; -const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - homePath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const ClaudeCodeProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - permissionMode: Schema.optional(TrimmedNonEmptyStringSchema), - maxThinkingTokens: Schema.optional(NonNegativeInt), -}); - -const CursorProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const CopilotProviderStartOptions = Schema.Struct({ - cliPath: Schema.optional(TrimmedNonEmptyStringSchema), - configDir: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const OpencodeProviderStartOptions = Schema.Struct({ - serverUrl: Schema.optional(TrimmedNonEmptyStringSchema), - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - hostname: Schema.optional(TrimmedNonEmptyStringSchema), - port: Schema.optional(Schema.Number), - workspace: Schema.optional(TrimmedNonEmptyStringSchema), - username: Schema.optional(TrimmedNonEmptyStringSchema), - password: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const GeminiCliProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const AmpProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const KiloProviderStartOptions = Schema.Struct({ - serverUrl: Schema.optional(TrimmedNonEmptyStringSchema), - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - hostname: Schema.optional(TrimmedNonEmptyStringSchema), - port: Schema.optional(Schema.Number), - workspace: Schema.optional(TrimmedNonEmptyStringSchema), - username: Schema.optional(TrimmedNonEmptyStringSchema), - password: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), - copilot: Schema.optional(CopilotProviderStartOptions), - claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), - cursor: Schema.optional(CursorProviderStartOptions), - opencode: Schema.optional(OpencodeProviderStartOptions), - geminiCli: Schema.optional(GeminiCliProviderStartOptions), - amp: Schema.optional(AmpProviderStartOptions), - kilo: Schema.optional(KiloProviderStartOptions), -}); +export { ProviderStartOptions }; export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..dbaa02f3fe 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,7 +1,8 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; +import { CODEX_REASONING_EFFORT_OPTIONS } from "./model"; import { ProviderKind } from "./orchestration"; const KeybindingsMalformedConfigIssue = Schema.Struct({ @@ -33,6 +34,31 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; +export const ServerProviderModelReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); +export type ServerProviderModelReasoningEffort = typeof ServerProviderModelReasoningEffort.Type; + +export const ServerProviderModel = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + supportsReasoningEffort: Schema.Boolean, + supportedReasoningEfforts: Schema.optional(Schema.Array(ServerProviderModelReasoningEffort)), + defaultReasoningEffort: Schema.optional(ServerProviderModelReasoningEffort), + billingMultiplier: Schema.optional(Schema.Number), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + +export const ServerProviderQuotaSnapshot = Schema.Struct({ + key: TrimmedNonEmptyString, + entitlementRequests: NonNegativeInt, + usedRequests: NonNegativeInt, + remainingRequests: NonNegativeInt, + remainingPercentage: Schema.Number, + overage: NonNegativeInt, + overageAllowedWithExhaustedQuota: Schema.Boolean, + resetDate: Schema.optional(IsoDateTime), +}); +export type ServerProviderQuotaSnapshot = typeof ServerProviderQuotaSnapshot.Type; + export const ServerProviderStatus = Schema.Struct({ provider: ProviderKind, status: ServerProviderStatusState, @@ -40,6 +66,8 @@ export const ServerProviderStatus = Schema.Struct({ authStatus: ServerProviderAuthStatus, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + models: Schema.optional(Schema.Array(ServerProviderModel)), + quotaSnapshots: Schema.optional(Schema.Array(ServerProviderQuotaSnapshot)), }); export type ServerProviderStatus = typeof ServerProviderStatus.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index f26a981d47..33a91dd0dc 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -18,6 +18,7 @@ import { getReasoningEffortOptions, normalizeModelSlug, parseCursorModelSelection, + resolveCursorPickerModelSlug, resolveCursorModelFromSelection, resolveModelSlug, resolveModelSlugForProvider, @@ -51,10 +52,15 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe( "gpt-5.3-codex-spark-preview", ); + expect(normalizeModelSlug("gpt-5.4", "cursor")).toBe("gpt-5.4-medium"); + expect(normalizeModelSlug("gpt-5.2-codex", "cursor")).toBe("gpt-5.2-codex"); expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1-pro"); expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe( "sonnet-4.6-thinking", ); + expect(normalizeModelSlug("claude-4.5-sonnet-thinking", "cursor")).toBe( + "sonnet-4.5-thinking", + ); }); it("does not leak prototype properties as aliases", () => { @@ -113,7 +119,10 @@ describe("cursor model selection", () => { it("includes the expected cursor reasoning levels and families", () => { expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]); expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex"); + expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.2-codex"); + expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.4-medium"); expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6"); + expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("sonnet-4.5"); }); it("parses codex reasoning and fast mode variants", () => { @@ -123,9 +132,28 @@ describe("cursor model selection", () => { fast: true, thinking: false, }); - expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual( - parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor), - ); + expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual({ + family: "gpt-5.2-codex", + reasoning: "normal", + fast: false, + thinking: false, + }); + }); + + it("parses newer cursor codex reasoning variants", () => { + expect(parseCursorModelSelection("gpt-5.2-codex-high-fast")).toEqual({ + family: "gpt-5.2-codex", + reasoning: "high", + fast: true, + thinking: false, + }); + expect( + resolveCursorModelFromSelection({ + family: "gpt-5.2-codex", + reasoning: "xhigh", + fast: true, + }), + ).toBe("gpt-5.2-codex-xhigh-fast"); }); it("parses and resolves thinking variants", () => { @@ -141,6 +169,12 @@ describe("cursor model selection", () => { thinking: true, }), ).toBe("sonnet-4.6-thinking"); + expect(parseCursorModelSelection("sonnet-4.5-thinking")).toEqual({ + family: "sonnet-4.5", + reasoning: "normal", + fast: false, + thinking: true, + }); }); it("resolves codex family selections into concrete model ids", () => { @@ -152,6 +186,13 @@ describe("cursor model selection", () => { }), ).toBe("gpt-5.3-codex-xhigh-fast"); }); + + it("collapses trait-backed cursor variants to a single picker option", () => { + expect(resolveCursorPickerModelSlug("gpt-5.2-codex-high-fast")).toBe("gpt-5.2-codex"); + expect(resolveCursorPickerModelSlug("opus-4.6-thinking")).toBe("opus-4.6"); + expect(resolveCursorPickerModelSlug("sonnet-4.5-thinking")).toBe("sonnet-4.5"); + expect(resolveCursorPickerModelSlug("gpt-5.4-high-fast")).toBe("gpt-5.4-high-fast"); + }); }); describe("getReasoningEffortOptions", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 507b0425a5..93a2532147 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,12 +1,16 @@ import { + CLAUDE_CODE_EFFORT_OPTIONS, + CLAUDE_CODE_EFFORT_OPTIONS_BY_PROVIDER, CODEX_REASONING_EFFORT_OPTIONS, CURSOR_MODEL_FAMILY_OPTIONS, CURSOR_REASONING_OPTIONS, + DEFAULT_CLAUDE_CODE_EFFORT_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, REASONING_EFFORT_OPTIONS_BY_PROVIDER, + type ClaudeCodeEffort, type CodexReasoningEffort, type CursorModelFamily, type CursorModelSlug, @@ -52,6 +56,83 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record> = { @@ -146,10 +276,25 @@ function resolveCursorModelFamily(model: string | null | undefined): CursorModel ) { return "gpt-5.3-codex"; } + if ( + normalized === "gpt-5.2-codex" || + normalized === "gpt-5.2-codex-fast" || + normalized === "gpt-5.2-codex-low" || + normalized === "gpt-5.2-codex-low-fast" || + normalized === "gpt-5.2-codex-high" || + normalized === "gpt-5.2-codex-high-fast" || + normalized === "gpt-5.2-codex-xhigh" || + normalized === "gpt-5.2-codex-xhigh-fast" + ) { + return "gpt-5.2-codex"; + } if (normalized === "sonnet-4.6-thinking") { return "sonnet-4.6"; } + if (normalized === "sonnet-4.5-thinking") { + return "sonnet-4.5"; + } if (normalized === "opus-4.6-thinking") { return "opus-4.6"; } @@ -200,6 +345,15 @@ export function parseCursorModelSelection(model: string | null | undefined): Cur }; } +export function resolveCursorPickerModelSlug( + model: string | null | undefined, +): CursorModelSlug | CursorModelFamily { + const selection = parseCursorModelSelection(model); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[selection.family]; + const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; + return capability.supportsReasoning || capability.supportsThinking ? selection.family : normalized; +} + export function resolveCursorModelFromSelection(input: { readonly family: CursorModelFamily; readonly reasoning?: CursorReasoningOption | null; @@ -286,4 +440,18 @@ export function getDefaultReasoningEffort( return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; } -export { CODEX_REASONING_EFFORT_OPTIONS }; +export function getClaudeCodeEffortOptions( + provider: ProviderKind = "claudeCode", +): ReadonlyArray { + return CLAUDE_CODE_EFFORT_OPTIONS_BY_PROVIDER[provider]; +} + +export function getDefaultClaudeCodeEffort(provider: "claudeCode"): ClaudeCodeEffort; +export function getDefaultClaudeCodeEffort(provider: ProviderKind): ClaudeCodeEffort | null; +export function getDefaultClaudeCodeEffort( + provider: ProviderKind = "claudeCode", +): ClaudeCodeEffort | null { + return DEFAULT_CLAUDE_CODE_EFFORT_BY_PROVIDER[provider]; +} + +export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS }; diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index dbdf0db49f..3167dd7c1e 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -168,6 +168,22 @@ interface ResolvedBuildOptions { readonly verbose: boolean; } +function resolveBundledCopilotPlatformPackages( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): ReadonlyArray { + if (platform === "mac") { + if (arch === "universal") { + return ["@github/copilot-darwin-arm64", "@github/copilot-darwin-x64"]; + } + return [arch === "arm64" ? "@github/copilot-darwin-arm64" : "@github/copilot-darwin-x64"]; + } + if (platform === "linux") { + return [arch === "arm64" ? "@github/copilot-linux-arm64" : "@github/copilot-linux-x64"]; + } + return [arch === "arm64" ? "@github/copilot-win32-arm64" : "@github/copilot-win32-x64"]; +} + interface StagePackageJson { readonly name: string; readonly version: string; @@ -475,6 +491,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( appId: "com.t3tools.t3code", productName, artifactName: "T3-Code-${version}-${arch}.${ext}", + asarUnpack: ["node_modules/@github/copilot*/**/*"], directories: { buildResources: "apps/desktop/resources", }, @@ -557,6 +574,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } + const bundledCopilotVersion = serverDependencies["@github/copilot"]; + if (typeof bundledCopilotVersion !== "string" || bundledCopilotVersion.trim().length === 0) { + return yield* new BuildScriptError({ + message: "Could not resolve bundled @github/copilot version from apps/server/package.json.", + }); + } + const bundledCopilotPlatformDependencies = Object.fromEntries( + resolveBundledCopilotPlatformPackages(options.platform, options.arch).map((dependencyName) => [ + dependencyName, + bundledCopilotVersion, + ]), + ); + const resolvedServerDependencies = yield* Effect.try({ try: () => resolveCatalogDependencies( @@ -605,6 +635,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: repoRoot, ...commandOutputOptions(options.verbose), + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + shell: process.platform === "win32", })`bun run build:desktop`, ); } @@ -655,6 +687,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ), dependencies: { ...resolvedServerDependencies, + ...bundledCopilotPlatformDependencies, ...resolvedDesktopRuntimeDependencies, }, devDependencies: { @@ -670,6 +703,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: stageAppDir, ...commandOutputOptions(options.verbose), + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + shell: process.platform === "win32", })`bun install --production`, ); @@ -708,6 +743,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cwd: stageAppDir, env: buildEnv, ...commandOutputOptions(options.verbose), + // Windows needs shell mode to resolve .cmd shims. + shell: process.platform === "win32", })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, ); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index c72411e318..89d7a104cf 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -455,6 +455,8 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { stderr: "inherit", env, extendEnv: false, + // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + shell: process.platform === "win32", // Keep turbo in the same process group so terminal signals (Ctrl+C) // reach it directly. Effect defaults to detached: true on non-Windows, // which would put turbo in a new group and require manual forwarding. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..87362420f7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,529 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# T3 Code — Interactive Install Script +# https://github.com/aaditagrawal/t3code +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/aaditagrawal/t3code/main/scripts/install.sh | bash +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +# ── Constants ───────────────────────────────────────────────────────────────── + +REPO_URL="https://github.com/aaditagrawal/t3code.git" +REQUIRED_NODE_MAJOR=24 +REQUIRED_BUN_MAJOR=1 +REQUIRED_BUN_MINOR=3 +REQUIRED_BUN_PATCH=9 +DEFAULT_INSTALL_DIR="./t3code" + +# ── Colors & Symbols ───────────────────────────────────────────────────────── + +if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then + RED=$(tput setaf 1) + GREEN=$(tput setaf 2) + YELLOW=$(tput setaf 3) + CYAN=$(tput setaf 6) + BOLD=$(tput bold) + RESET=$(tput sgr0) +else + RED="" GREEN="" YELLOW="" CYAN="" BOLD="" RESET="" +fi + +OK="${GREEN}✓${RESET}" +FAIL="${RED}✗${RESET}" +WARN="${YELLOW}⚠${RESET}" +INFO="${CYAN}→${RESET}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +log_ok() { printf " %s %s\n" "$OK" "$*"; } +log_fail() { printf " %s %s\n" "$FAIL" "$*"; } +log_warn() { printf " %s %s\n" "$WARN" "$*"; } +log_info() { printf " %s %s\n" "$INFO" "$*"; } + +die() { + printf "\n%s%s%s\n" "$RED" "$*" "$RESET" >&2 + exit 1 +} + +# Detect whether stdin is interactive (not piped) +INTERACTIVE=true +if [[ ! -t 0 ]]; then + INTERACTIVE=false +fi + +# Prompt helper — falls back to default when non-interactive +prompt() { + local var_name="$1" message="$2" default="$3" + if $INTERACTIVE; then + read -rp " ${CYAN}?${RESET} ${message} [${BOLD}${default}${RESET}]: " input + printf -v "$var_name" '%s' "${input:-$default}" + else + printf -v "$var_name" '%s' "$default" + fi +} + +prompt_choice() { + local var_name="$1" message="$2" default="$3" + shift 3 + local options=("$@") + if $INTERACTIVE; then + printf "\n ${CYAN}?${RESET} %s\n" "$message" + for i in "${!options[@]}"; do + local num=$((i + 1)) + if [[ "$num" == "$default" ]]; then + printf " ${BOLD}[%d] %s (default)${RESET}\n" "$num" "${options[$i]}" + else + printf " [%d] %s\n" "$num" "${options[$i]}" + fi + done + read -rp " ${CYAN}→${RESET} Enter choice: " input + input="${input:-$default}" + # Validate + if [[ ! "$input" =~ ^[0-9]+$ ]] || (( input < 1 || input > ${#options[@]} )); then + log_warn "Invalid choice '${input}', using default: ${default}" + input="$default" + fi + printf -v "$var_name" '%s' "$input" + else + printf -v "$var_name" '%s' "$default" + fi +} + +# ── Ctrl+C handler ─────────────────────────────────────────────────────────── + +cleanup() { + printf "\n\n${YELLOW}Installation cancelled.${RESET}\n" + exit 130 +} +trap cleanup INT TERM + +# ── Banner ──────────────────────────────────────────────────────────────────── + +banner() { + printf "\n" + printf " ${BOLD}${CYAN}╔══════════════════════════════════════╗${RESET}\n" + printf " ${BOLD}${CYAN}║ T3 Code — Installer ║${RESET}\n" + printf " ${BOLD}${CYAN}╚══════════════════════════════════════╝${RESET}\n" + printf "\n" +} + +# ── OS Detection ────────────────────────────────────────────────────────────── + +detect_os() { + OS_RAW="$(uname -s 2>/dev/null || echo unknown)" + ARCH="$(uname -m 2>/dev/null || echo unknown)" + + case "$OS_RAW" in + Darwin*) OS="macos" ;; + Linux*) OS="linux" ;; + CYGWIN*|MSYS*|MINGW*|MINGW64*) OS="windows" ;; + *) OS="unknown" ;; + esac + + # Detect Linux distro family + DISTRO="" + if [[ "$OS" == "linux" ]]; then + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + source /etc/os-release 2>/dev/null || true + case "${ID:-}${ID_LIKE:-}" in + *debian*|*ubuntu*) DISTRO="debian" ;; + *fedora*|*rhel*|*centos*) DISTRO="fedora" ;; + *arch*) DISTRO="arch" ;; + *) DISTRO="other" ;; + esac + fi + fi + + # Detect WSL + IS_WSL=false + if [[ "$OS" == "linux" ]] && grep -qi microsoft /proc/version 2>/dev/null; then + IS_WSL=true + fi + + log_ok "Detected OS: ${BOLD}${OS}${RESET} (${ARCH})" + [[ -n "$DISTRO" ]] && log_info "Linux distro family: ${BOLD}${DISTRO}${RESET}" + $IS_WSL && log_info "Running inside ${BOLD}WSL${RESET}" +} + +# ── Resolve a binary from multiple candidate paths ─────────────────────────── + +find_bin() { + local name="$1" + shift + # First check PATH + local found + found="$(command -v "$name" 2>/dev/null || true)" + if [[ -n "$found" ]]; then + echo "$found" + return 0 + fi + # Then check explicit candidate paths + for candidate in "$@"; do + # Expand globs (e.g. nvm version dirs) + for expanded in $candidate; do + if [[ -x "$expanded" ]]; then + echo "$expanded" + return 0 + fi + done + done + return 1 +} + +# ── Prerequisite: git ───────────────────────────────────────────────────────── + +check_git() { + local git_bin + git_bin="$(find_bin git \ + /usr/bin/git \ + /usr/local/bin/git \ + "$HOME/.local/bin/git" \ + "/c/Program Files/Git/bin/git.exe" \ + "/c/Program Files/Git/cmd/git.exe" \ + )" || true + + if [[ -n "$git_bin" ]]; then + local ver + ver="$("$git_bin" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)" + log_ok "git found: ${BOLD}${git_bin}${RESET} (${ver})" + GIT_BIN="$git_bin" + return 0 + fi + + log_fail "git is ${BOLD}not installed${RESET}" + suggest_install_git + return 1 +} + +suggest_install_git() { + printf "\n" + log_info "Install git:" + case "$OS" in + macos) + log_info " brew install git" + log_info " — or — xcode-select --install" + ;; + linux) + case "$DISTRO" in + debian) log_info " sudo apt update && sudo apt install -y git" ;; + fedora) log_info " sudo dnf install -y git" ;; + arch) log_info " sudo pacman -S --noconfirm git" ;; + *) log_info " Use your distro's package manager to install git" ;; + esac + ;; + windows) + log_info " winget install --id Git.Git -e" + log_info " — or — https://git-scm.com/download/win" + ;; + esac +} + +# ── Prerequisite: Node.js ──────────────────────────────────────────────────── + +check_node() { + local node_bin + node_bin="$(find_bin node \ + /usr/local/bin/node \ + /usr/bin/node \ + "$HOME/.local/bin/node" \ + "$HOME/.nvm/versions/node/"*/bin/node \ + "$HOME/.fnm/node-versions/"*/installation/bin/node \ + "$HOME/.volta/bin/node" \ + /opt/homebrew/bin/node \ + )" || true + + if [[ -z "$node_bin" ]]; then + log_fail "Node.js is ${BOLD}not installed${RESET} (required >= ${REQUIRED_NODE_MAJOR})" + suggest_install_node + return 1 + fi + + local node_ver + node_ver="$("$node_bin" --version 2>/dev/null)" || true + local major + major="$(echo "$node_ver" | sed 's/^v//' | cut -d. -f1)" + + if [[ -z "$major" ]] || (( major < REQUIRED_NODE_MAJOR )); then + log_fail "Node.js ${BOLD}${node_ver}${RESET} found, but >= ${REQUIRED_NODE_MAJOR} is required" + suggest_install_node + return 1 + fi + + log_ok "Node.js found: ${BOLD}${node_bin}${RESET} (${node_ver})" + NODE_BIN="$node_bin" + return 0 +} + +suggest_install_node() { + printf "\n" + log_info "Install Node.js >= ${REQUIRED_NODE_MAJOR}:" + case "$OS" in + macos) + log_info " brew install node@${REQUIRED_NODE_MAJOR}" + log_info " — or — curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash" + log_info " nvm install ${REQUIRED_NODE_MAJOR}" + ;; + linux) + log_info " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash" + log_info " nvm install ${REQUIRED_NODE_MAJOR}" + case "$DISTRO" in + debian) log_info " — or — https://github.com/nodesource/distributions#installation-instructions" ;; + fedora) log_info " — or — https://github.com/nodesource/distributions#installation-instructions" ;; + arch) log_info " — or — sudo pacman -S nodejs" ;; + esac + ;; + windows) + log_info " winget install --id OpenJS.NodeJS -e" + log_info " — or — https://nodejs.org/en/download" + ;; + esac +} + +# ── Prerequisite: bun ──────────────────────────────────────────────────────── + +# Compare two semver strings: returns 0 if $1 >= $2 +semver_gte() { + local a_major a_minor a_patch b_major b_minor b_patch + IFS='.' read -r a_major a_minor a_patch <<< "$1" + IFS='.' read -r b_major b_minor b_patch <<< "$2" + a_major="${a_major:-0}"; a_minor="${a_minor:-0}"; a_patch="${a_patch:-0}" + b_major="${b_major:-0}"; b_minor="${b_minor:-0}"; b_patch="${b_patch:-0}" + + (( a_major > b_major )) && return 0 + (( a_major < b_major )) && return 1 + (( a_minor > b_minor )) && return 0 + (( a_minor < b_minor )) && return 1 + (( a_patch >= b_patch )) && return 0 + return 1 +} + +check_bun() { + local bun_bin + bun_bin="$(find_bin bun \ + "$HOME/.bun/bin/bun" \ + /usr/local/bin/bun \ + /opt/homebrew/bin/bun \ + )" || true + + local required_ver="${REQUIRED_BUN_MAJOR}.${REQUIRED_BUN_MINOR}.${REQUIRED_BUN_PATCH}" + + if [[ -n "$bun_bin" ]]; then + local bun_ver + bun_ver="$("$bun_bin" --version 2>/dev/null)" || true + bun_ver="${bun_ver#v}" # strip leading v if present + + if semver_gte "$bun_ver" "$required_ver"; then + log_ok "bun found: ${BOLD}${bun_bin}${RESET} (${bun_ver})" + BUN_BIN="$bun_bin" + return 0 + else + log_warn "bun ${BOLD}${bun_ver}${RESET} found, but >= ${required_ver} is required" + fi + else + log_warn "bun is ${BOLD}not installed${RESET} (required >= ${required_ver})" + fi + + # Offer to auto-install bun + install_bun +} + +install_bun() { + local required_ver="${REQUIRED_BUN_MAJOR}.${REQUIRED_BUN_MINOR}.${REQUIRED_BUN_PATCH}" + printf "\n" + log_info "bun >= ${required_ver} is ${BOLD}required${RESET} as the project package manager." + + local do_install="y" + if $INTERACTIVE; then + read -rp " ${CYAN}?${RESET} Install bun now? [Y/n]: " do_install + do_install="${do_install:-y}" + fi + + case "$do_install" in + [Yy]|[Yy]es|"") + log_info "Installing bun…" + if [[ "$OS" == "windows" ]] && command -v powershell.exe &>/dev/null; then + powershell.exe -Command "irm bun.sh/install.ps1 | iex" || die "Failed to install bun via PowerShell" + else + curl -fsSL https://bun.sh/install | bash || die "Failed to install bun" + fi + + # Source the bun env so it's available in this session + if [[ -f "$HOME/.bun/bin/bun" ]]; then + export PATH="$HOME/.bun/bin:$PATH" + BUN_BIN="$HOME/.bun/bin/bun" + else + BUN_BIN="$(command -v bun 2>/dev/null || true)" + fi + + if [[ -z "$BUN_BIN" ]] || ! "$BUN_BIN" --version &>/dev/null; then + die "bun installation succeeded but binary not found. Restart your shell and try again." + fi + + local installed_ver + installed_ver="$("$BUN_BIN" --version 2>/dev/null)" + installed_ver="${installed_ver#v}" + log_ok "bun installed: ${BOLD}${BUN_BIN}${RESET} (${installed_ver})" + ;; + *) + die "bun is required. Install it manually: https://bun.sh" + ;; + esac +} + +# ── Prerequisites Summary ──────────────────────────────────────────────────── + +check_prerequisites() { + printf "\n ${BOLD}Checking prerequisites…${RESET}\n\n" + + local failed=false + + GIT_BIN="" NODE_BIN="" BUN_BIN="" + + check_git || failed=true + check_node || failed=true + + if $failed; then + printf "\n" + die "Please install the missing prerequisites above, then re-run this script." + fi + + # bun check handles its own installation flow + check_bun + + if [[ -z "$BUN_BIN" ]]; then + die "bun is required but could not be found after installation." + fi + + printf "\n ${GREEN}${BOLD}All prerequisites satisfied.${RESET}\n" +} + +# ── Step 1: Clone ──────────────────────────────────────────────────────────── + +step_clone() { + printf "\n ${BOLD}Step 1: Clone Repository${RESET}\n\n" + + local install_dir + prompt install_dir "Install directory" "$DEFAULT_INSTALL_DIR" + + # Expand ~ if present + install_dir="${install_dir/#\~/$HOME}" + + if [[ -d "$install_dir" ]]; then + if [[ -d "$install_dir/.git" ]]; then + log_warn "Directory '${install_dir}' already exists and is a git repo." + log_info "Pulling latest changes…" + "$GIT_BIN" -C "$install_dir" pull --ff-only || log_warn "Pull failed — continuing with existing checkout" + else + die "Directory '${install_dir}' already exists but is not a git repo. Remove it or choose another path." + fi + else + log_info "Cloning ${REPO_URL} → ${install_dir}" + "$GIT_BIN" clone "$REPO_URL" "$install_dir" || die "git clone failed" + log_ok "Repository cloned" + fi + + INSTALL_DIR="$(cd "$install_dir" && pwd)" +} + +# ── Step 2: Build Mode ────────────────────────────────────────────────────── + +step_build_mode() { + prompt_choice BUILD_MODE "Choose build mode:" "1" \ + "Development (hot-reload)" \ + "Production (full build)" +} + +# ── Step 3: App Type ───────────────────────────────────────────────────────── + +step_app_type() { + prompt_choice APP_TYPE "Choose app type:" "1" \ + "Desktop app (Electron)" \ + "Web app (browser)" +} + +# ── Step 4: Install Dependencies ───────────────────────────────────────────── + +step_install_deps() { + printf "\n ${BOLD}Step 4: Install Dependencies${RESET}\n\n" + + log_info "Running ${BOLD}bun install${RESET} in ${INSTALL_DIR}…" + (cd "$INSTALL_DIR" && "$BUN_BIN" install) || die "bun install failed" + log_ok "Dependencies installed" +} + +# ── Step 5: Run ────────────────────────────────────────────────────────────── + +step_run() { + printf "\n ${BOLD}Step 5: Launch T3 Code${RESET}\n\n" + + local cmd="" + local label="" + + if [[ "$BUILD_MODE" == "1" && "$APP_TYPE" == "1" ]]; then + cmd="dev:desktop" + label="Development · Desktop" + elif [[ "$BUILD_MODE" == "1" && "$APP_TYPE" == "2" ]]; then + cmd="dev" + label="Development · Web" + elif [[ "$BUILD_MODE" == "2" && "$APP_TYPE" == "1" ]]; then + label="Production · Desktop" + elif [[ "$BUILD_MODE" == "2" && "$APP_TYPE" == "2" ]]; then + label="Production · Web" + fi + + log_info "Mode: ${BOLD}${label}${RESET}" + + if [[ "$BUILD_MODE" == "2" ]]; then + # Production: build first, then start + local build_cmd start_cmd + if [[ "$APP_TYPE" == "1" ]]; then + build_cmd="build:desktop" + start_cmd="start:desktop" + else + build_cmd="build" + start_cmd="start" + fi + + log_info "Building… (${BOLD}bun run ${build_cmd}${RESET})" + (cd "$INSTALL_DIR" && "$BUN_BIN" run "$build_cmd") || die "Build failed" + log_ok "Build complete" + + log_info "Starting… (${BOLD}bun run ${start_cmd}${RESET})" + (cd "$INSTALL_DIR" && exec "$BUN_BIN" run "$start_cmd") + else + # Development: single command + log_info "Starting… (${BOLD}bun run ${cmd}${RESET})" + (cd "$INSTALL_DIR" && exec "$BUN_BIN" run "$cmd") + fi +} + +# ── Non-interactive Warning ────────────────────────────────────────────────── + +warn_non_interactive() { + if ! $INTERACTIVE; then + printf "\n" + log_warn "${BOLD}Non-interactive mode detected${RESET} (piped input)." + log_warn "Using defaults: dir=${DEFAULT_INSTALL_DIR}, mode=Development, type=Desktop" + printf "\n" + fi +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +main() { + banner + detect_os + warn_non_interactive + check_prerequisites + + step_clone + step_build_mode + step_app_type + step_install_deps + step_run +} + +main "$@" diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts new file mode 100644 index 0000000000..22d2e7627e --- /dev/null +++ b/scripts/merge-mac-update-manifests.test.ts @@ -0,0 +1,108 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + mergeMacUpdateManifests, + parseMacUpdateManifest, + serializeMacUpdateManifest, +} from "./merge-mac-update-manifests.ts"; + +describe("merge-mac-update-manifests", () => { + it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { + const arm64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + const merged = mergeMacUpdateManifests(arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.zip", + "T3-Code-0.0.4-arm64.dmg", + "T3-Code-0.0.4-x64.zip", + "T3-Code-0.0.4-x64.dmg", + ], + ); + + const serialized = serializeMacUpdateManifest(merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const arm64 = parseMacUpdateManifest( + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parseMacUpdateManifest( + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.zip + sha512: x64zip + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parseMacUpdateManifest( + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseName: 'true' +minimumSystemVersion: '13.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "13.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); +}); diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/merge-mac-update-manifests.ts new file mode 100644 index 0000000000..c59bc76b9b --- /dev/null +++ b/scripts/merge-mac-update-manifests.ts @@ -0,0 +1,287 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface MacUpdateFile { + readonly url: string; + readonly sha512: string; + readonly size: number; +} + +type MacUpdateScalar = string | number | boolean; + +interface MacUpdateManifest { + readonly version: string; + readonly releaseDate: string; + readonly files: ReadonlyArray; + readonly extras: Readonly>; +} + +interface MutableMacUpdateFile { + url?: string; + sha512?: string; + size?: number; +} + +function stripSingleQuotes(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'"); + } + return value; +} + +function parseFileRecord( + currentFile: MutableMacUpdateFile | null, + sourcePath: string, + lineNumber: number, +): MacUpdateFile | null { + if (currentFile === null) { + return null; + } + if ( + typeof currentFile.url !== "string" || + typeof currentFile.sha512 !== "string" || + typeof currentFile.size !== "number" + ) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + ); + } + return { + url: currentFile.url, + sha512: currentFile.sha512, + size: currentFile.size, + }; +} + +function parseScalarValue(rawValue: string): MacUpdateScalar { + const trimmed = rawValue.trim(); + const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; + const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; + if (isQuoted) return value; + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value); + } + return value; +} + +export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest { + const lines = raw.split(/\r?\n/); + const files: MacUpdateFile[] = []; + const extras: Record = {}; + let version: string | null = null; + let releaseDate: string | null = null; + let inFiles = false; + let currentFile: MutableMacUpdateFile | null = null; + + for (const [index, rawLine] of lines.entries()) { + const lineNumber = index + 1; + const line = rawLine.trimEnd(); + if (line.length === 0) continue; + + const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); + if (fileUrlMatch?.[1]) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + if (finalized) files.push(finalized); + currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; + inFiles = true; + continue; + } + + const fileShaMatch = line.match(/^ sha512:\s*(.+)$/); + if (fileShaMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + ); + } + currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); + continue; + } + + const fileSizeMatch = line.match(/^ size:\s*(\d+)$/); + if (fileSizeMatch?.[1]) { + if (currentFile === null) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + ); + } + currentFile.size = Number(fileSizeMatch[1]); + continue; + } + + if (line === "files:") { + inFiles = true; + continue; + } + + if (inFiles && currentFile !== null) { + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + if (finalized) files.push(finalized); + currentFile = null; + } + inFiles = false; + + const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); + if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + ); + } + + const [, key, rawValue] = topLevelMatch; + const value = parseScalarValue(rawValue); + + if (key === "version") { + if (typeof value !== "string") { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + ); + } + version = value; + continue; + } + + if (key === "releaseDate") { + if (typeof value !== "string") { + throw new Error( + `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + ); + } + releaseDate = value; + continue; + } + + if (key === "path" || key === "sha512") { + continue; + } + + extras[key] = value; + } + + const finalized = parseFileRecord(currentFile, sourcePath, lines.length); + if (finalized) files.push(finalized); + + if (!version) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`); + } + if (!releaseDate) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`); + } + if (files.length === 0) { + throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`); + } + + return { + version, + releaseDate, + files, + extras, + }; +} + +function mergeExtras( + primary: Readonly>, + secondary: Readonly>, +): Record { + const merged: Record = { ...primary }; + + for (const [key, value] of Object.entries(secondary)) { + const existing = merged[key]; + if (existing !== undefined && existing !== value) { + throw new Error( + `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + ); + } + merged[key] = value; + } + + return merged; +} + +export function mergeMacUpdateManifests( + primary: MacUpdateManifest, + secondary: MacUpdateManifest, +): MacUpdateManifest { + if (primary.version !== secondary.version) { + throw new Error( + `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`, + ); + } + + const filesByUrl = new Map(); + for (const file of [...primary.files, ...secondary.files]) { + const existing = filesByUrl.get(file.url); + if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { + throw new Error( + `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`, + ); + } + filesByUrl.set(file.url, file); + } + + return { + version: primary.version, + releaseDate: + primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, + files: [...filesByUrl.values()], + extras: mergeExtras(primary.extras, secondary.extras), + }; +} + +function quoteYamlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function serializeScalarValue(value: MacUpdateScalar): string { + if (typeof value === "string") { + return quoteYamlString(value); + } + return String(value); +} + +export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string { + const lines = [`version: ${manifest.version}`, "files:"]; + + for (const file of manifest.files) { + lines.push(` - url: ${file.url}`); + lines.push(` sha512: ${file.sha512}`); + lines.push(` size: ${file.size}`); + } + + for (const key of Object.keys(manifest.extras).toSorted()) { + const value = manifest.extras[key]; + if (value === undefined) { + throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`); + } + lines.push(`${key}: ${serializeScalarValue(value)}`); + } + + lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`); + lines.push(""); + return lines.join("\n"); +} + +function main(args: ReadonlyArray): void { + const [arm64PathArg, x64PathArg, outputPathArg] = args; + if (!arm64PathArg || !x64PathArg) { + throw new Error( + "Usage: node scripts/merge-mac-update-manifests.ts [output-path]", + ); + } + + const arm64Path = resolve(arm64PathArg); + const x64Path = resolve(x64PathArg); + const outputPath = resolve(outputPathArg ?? arm64PathArg); + + const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path); + const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path); + const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest); + writeFileSync(outputPath, serializeMacUpdateManifest(merged)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts new file mode 100644 index 0000000000..9e7595e619 --- /dev/null +++ b/scripts/release-smoke.ts @@ -0,0 +1,113 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const workspaceFiles = [ + "package.json", + "bun.lock", + "apps/server/package.json", + "apps/desktop/package.json", + "apps/web/package.json", + "apps/marketing/package.json", + "packages/contracts/package.json", + "packages/shared/package.json", + "scripts/package.json", +] as const; + +function copyWorkspaceManifestFixture(targetRoot: string): void { + for (const relativePath of workspaceFiles) { + const sourcePath = resolve(repoRoot, relativePath); + const destinationPath = resolve(targetRoot, relativePath); + mkdirSync(dirname(destinationPath), { recursive: true }); + cpSync(sourcePath, destinationPath); + } +} + +function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "latest-mac.yml"); + const x64Path = resolve(assetDirectory, "latest-mac-x64.yml"); + + writeFileSync( + arm64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-9.9.9-smoke.0-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-9.9.9-smoke.0-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-08T10:32:14.587Z' +`, + ); + + writeFileSync( + x64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-9.9.9-smoke.0-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-9.9.9-smoke.0-x64.zip +sha512: x64zip +releaseDate: '2026-03-08T10:36:07.540Z' +`, + ); + + return { arm64Path, x64Path }; +} + +function assertContains(haystack: string, needle: string, message: string): void { + if (!haystack.includes(needle)) { + throw new Error(message); + } +} + +const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); + +try { + copyWorkspaceManifestFixture(tempRoot); + + execFileSync( + process.execPath, + [resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], { + cwd: tempRoot, + stdio: "inherit", + }); + + const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8"); + assertContains(lockfile, `"version": "9.9.9-smoke.0"`, "Expected bun.lock to contain the smoke version."); + + const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); + execFileSync(process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], { + cwd: repoRoot, + stdio: "inherit", + }); + + const mergedManifest = readFileSync(arm64Path, "utf8"); + assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", "Merged manifest is missing the arm64 asset."); + assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset."); + + console.log("Release smoke checks passed."); +} finally { + rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts new file mode 100644 index 0000000000..7cdf13dd39 --- /dev/null +++ b/scripts/update-release-package-versions.ts @@ -0,0 +1,111 @@ +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const releasePackageFiles = [ + "apps/server/package.json", + "apps/desktop/package.json", + "apps/web/package.json", + "packages/contracts/package.json", +] as const; + +interface UpdateReleasePackageVersionsOptions { + readonly rootDir?: string; +} + +interface MutablePackageJson { + version?: string; + [key: string]: unknown; +} + +export function updateReleasePackageVersions( + version: string, + options: UpdateReleasePackageVersionsOptions = {}, +): { changed: boolean } { + const rootDir = resolve(options.rootDir ?? process.cwd()); + let changed = false; + + for (const relativePath of releasePackageFiles) { + const filePath = resolve(rootDir, relativePath); + const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson; + if (packageJson.version === version) { + continue; + } + + packageJson.version = version; + writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`); + changed = true; + } + + return { changed }; +} + +function parseArgs(argv: ReadonlyArray): { + version: string; + rootDir: string | undefined; + writeGithubOutput: boolean; +} { + let version: string | undefined; + let rootDir: string | undefined; + let writeGithubOutput = false; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) { + continue; + } + + if (argument === "--github-output") { + writeGithubOutput = true; + continue; + } + + if (argument === "--root") { + rootDir = argv[index + 1]; + if (!rootDir) { + throw new Error("Missing value for --root."); + } + index += 1; + continue; + } + + if (argument.startsWith("--")) { + throw new Error(`Unknown argument: ${argument}`); + } + + if (version !== undefined) { + throw new Error("Only one release version can be provided."); + } + version = argument; + } + + if (!version) { + throw new Error( + "Usage: node scripts/update-release-package-versions.ts [--root ] [--github-output]", + ); + } + + return { version, rootDir, writeGithubOutput }; +} + +const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); + const { changed } = updateReleasePackageVersions( + version, + rootDir === undefined ? {} : { rootDir }, + ); + + if (!changed) { + console.log("All package.json versions already match release version."); + } + + if (writeGithubOutput) { + const githubOutputPath = process.env.GITHUB_OUTPUT; + if (!githubOutputPath) { + throw new Error("GITHUB_OUTPUT is required when --github-output is set."); + } + appendFileSync(githubOutputPath, `changed=${changed}\n`); + } +} diff --git a/update b/update new file mode 100644 index 0000000000..e69de29bb2 From 7e919c61647ca7c325d65292afede2763d4844dd Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 9 Mar 2026 16:20:32 +0530 Subject: [PATCH 22/23] fix: address all CodeRabbit PR review feedback - ProviderCommandReactor: don't mark session stopped after failed stop; document provider options only apply at session start - ClaudeCodeAdapter: move executable path resolution inside Effect.try for proper error mapping in packaged environments - ProviderHealth: replace unsafe getQuotaInfo cast with documented client.rpc.account.getQuota() SDK API - ProviderService: preserve providerOptions across all directory.upsert calls (sendTurn, recovery, stopAll) to prevent silent data loss - ChatView: add removeKeybinding API to clean up stale shortcuts on script deletion; document snapshot resync trade-offs - CommandPalette: catch async onSelect failures to prevent unhandled promise rejections - ProjectScriptsControl: await onDeleteScript and surface errors via validation state instead of fire-and-forget - Settings: add aria-label to provider accent color pickers - wsTransport: close sockets that finish connecting after disposal - orchestration.ts: redact credentials (username/password) from persisted event payloads; use PositiveInt for port validation - install.sh: read prompts from /dev/tty for curl|bash compatibility; fix word-splitting vulnerability in find_bin path probing --- apps/server/src/keybindings.ts | 29 +++++++++++ .../Layers/ProviderCommandReactor.ts | 13 ++++- apps/server/src/orchestration/decider.ts | 20 +++++++- .../src/provider/Layers/ClaudeCodeAdapter.ts | 14 +++++- .../src/provider/Layers/ProviderHealth.ts | 13 +---- .../src/provider/Layers/ProviderService.ts | 34 +++++++++---- apps/server/src/wsServer.ts | 8 ++++ apps/web/src/components/ChatView.tsx | 14 ++++++ apps/web/src/components/CommandPalette.tsx | 6 ++- .../src/components/ProjectScriptsControl.tsx | 12 +++-- apps/web/src/routes/_chat.settings.tsx | 1 + apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsTransport.ts | 4 ++ packages/contracts/src/ipc.ts | 8 +++- packages/contracts/src/orchestration.ts | 38 +++++++++++++-- packages/contracts/src/server.ts | 13 ++++- packages/contracts/src/ws.ts | 4 +- scripts/install.sh | 48 ++++++++++++++----- 18 files changed, 234 insertions(+), 46 deletions(-) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index ca118c7a34..52b26a4578 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -494,6 +494,13 @@ export interface KeybindingsShape { readonly upsertKeybindingRule: ( rule: KeybindingRule, ) => Effect.Effect; + + /** + * Remove all keybinding rules for the given command and persist the result. + */ + readonly removeKeybindingForCommand: ( + command: KeybindingRule["command"], + ) => Effect.Effect; } /** @@ -838,6 +845,28 @@ const makeKeybindings = Effect.gen(function* () { return nextResolved; }), ), + removeKeybindingForCommand: (command) => + upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const nextConfig = customConfig.filter((entry) => entry.command !== command); + if (nextConfig.length === customConfig.length) { + // No matching entry found — return current resolved config unchanged. + const current = yield* loadConfigStateFromCacheOrDisk; + return current.keybindings; + } + yield* writeConfigAtomically(nextConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(nextConfig), + ); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange([]); + return nextResolved; + }), + ), } satisfies KeybindingsShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cc4ad56a11..cb27f68b68 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -139,6 +139,10 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed(true), }); + // NOTE: Provider options stored here are only consumed at session start time + // (inside `startProviderSession`). If a thread already has a live session and + // only `providerOptions` change, `ensureSessionForThread` keeps the existing + // session — the updated options won't take effect until the next session restart. const threadProviderOptions = new Map(); const hasHandledTurnStartRecently = (key: string) => @@ -643,9 +647,10 @@ const make = Effect.gen(function* () { const now = event.payload.createdAt; if (thread.session && thread.session.status !== "stopped") { - yield* providerService + const stopFailed = yield* providerService .stopSession({ threadId: thread.id }) .pipe( + Effect.as(false), Effect.catchCause((cause) => Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause)) { @@ -660,9 +665,15 @@ const make = Effect.gen(function* () { turnId: null, createdAt: event.payload.createdAt, }); + // Signal that the stop failed so we don't clear thread state + // while the provider may still be running. + return true; }), ), ); + if (stopFailed) { + return; + } } threadProviderOptions.delete(thread.id); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 88e6093c03..3dc871555a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,6 +2,8 @@ import type { OrchestrationCommand, OrchestrationEvent, OrchestrationReadModel, + ProviderStartOptions, + ProviderStartOptionsRedacted, } from "@t3tools/contracts"; import { Effect } from "effect"; @@ -16,6 +18,22 @@ import { const nowIso = () => new Date().toISOString(); const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; +/** Strip sensitive fields (username, password) from provider start options for safe event persistence. */ +function redactProviderStartOptions( + opts: ProviderStartOptions, +): ProviderStartOptionsRedacted { + const redacted = { ...opts } as Record; + if (opts.opencode) { + const { username: _u, password: _p, ...rest } = opts.opencode; + redacted.opencode = rest; + } + if (opts.kilo) { + const { username: _u, password: _p, ...rest } = opts.kilo; + redacted.kilo = rest; + } + return redacted as ProviderStartOptionsRedacted; +} + const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: "thread", @@ -303,7 +321,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), - ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), + ...(command.providerOptions !== undefined ? { providerOptions: redactProviderStartOptions(command.providerOptions) } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 6d1d4a803a..25d3ccec7e 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1709,10 +1709,22 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); const effort = claudeCodeModelOptions?.effort as EffortLevel | undefined; + const pathToClaudeCodeExecutable = yield* Effect.try({ + try: () => + (providerOptions?.binaryPath as string | undefined) ?? defaultClaudeSdkCliPath(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to resolve Claude Code executable."), + cause, + }), + }); + const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), - pathToClaudeCodeExecutable: (providerOptions?.binaryPath as string | undefined) ?? defaultClaudeSdkCliPath(), + pathToClaudeCodeExecutable, ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 7d179fce9a..b024997e89 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -481,17 +481,8 @@ export const checkCopilotProviderStatus: Effect.Effect { - try { - const clientRecord = client as unknown as Record; - if (typeof clientRecord.getQuotaInfo === "function") { - return (clientRecord.getQuotaInfo as () => Promise | undefined>)(); - } - return undefined; - } catch { - return undefined; - } - })(); + const quota = await (client as unknown as { rpc: { account: { getQuota: () => Promise<{ quotaSnapshots?: unknown }> } } }).rpc.account.getQuota().catch(() => undefined); + const quotaSnapshots = quota?.quotaSnapshots as Record | undefined; return { models, quotaSnapshots } as { models: ModelInfo[]; quotaSnapshots: Record | undefined; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 78775d8315..6a9b60a662 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -195,7 +195,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const activeSessions = yield* adapter.listSessions(); const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); if (existing) { - yield* upsertSessionBinding(existing, input.binding.threadId); + const existingProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); + yield* upsertSessionBinding( + existing, + input.binding.threadId, + existingProviderOptions !== undefined ? { providerOptions: existingProviderOptions } : undefined, + ); yield* analytics.record("provider.session.recovered", { provider: existing.provider, strategy: "adopt-existing", @@ -230,7 +235,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } - yield* upsertSessionBinding(resumed, input.binding.threadId); + yield* upsertSessionBinding( + resumed, + input.binding.threadId, + persistedProviderOptions !== undefined ? { providerOptions: persistedProviderOptions } : undefined, + ); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", @@ -331,6 +340,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); const turn = yield* routed.adapter.sendTurn(input); + const sendTurnBinding = yield* directory.getBinding(input.threadId); + const sendTurnProviderOptions = readPersistedProviderOptions( + Option.getOrUndefined(sendTurnBinding)?.runtimePayload, + ); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -340,6 +353,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: new Date().toISOString(), + ...(sendTurnProviderOptions !== undefined ? { providerOptions: sendTurnProviderOptions } : {}), }, }); yield* analytics.record("provider.turn.sent", { @@ -502,19 +516,23 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const threadIds = yield* directory.listThreadIds(); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); yield* Effect.forEach(threadIds, (threadId) => - directory.getProvider(threadId).pipe( - Effect.flatMap((provider) => - directory.upsert({ + directory.getBinding(threadId).pipe( + Effect.flatMap((bindingOption) => { + const binding = Option.getOrUndefined(bindingOption); + if (!binding) return Effect.void; + const existingProviderOptions = readPersistedProviderOptions(binding.runtimePayload); + return directory.upsert({ threadId, - provider, + provider: binding.provider, status: "stopped", runtimePayload: { activeTurnId: null, lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt: new Date().toISOString(), + ...(existingProviderOptions !== undefined ? { providerOptions: existingProviderOptions } : {}), }, - }), - ), + }); + }), ), ).pipe(Effect.asVoid); yield* analytics.record("provider.sessions.stopped_all", { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 578a5609d7..092c00ce42 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1023,6 +1023,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverRemoveKeybinding: { + const body = stripRequestTag(request.body); + const keybindingsConfig = yield* keybindingsManager.removeKeybindingForCommand( + body.command, + ); + return { keybindings: keybindingsConfig, issues: [] }; + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fa27bdaafb..b0c3421e2d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1797,6 +1797,12 @@ export default function ChatView({ threadId }: ChatViewProps) { if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + } else if (isElectron && input.keybinding === null) { + // Explicitly null keybinding means the script (and its shortcut) is + // being deleted. Remove any persisted keybinding for this command so + // stale accelerators don't linger. + await api.server.removeKeybinding({ command: input.keybindingCommand }); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, [queryClient], @@ -3321,6 +3327,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ) .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { + // Snapshot sync is a safety net for the navigation/thread creation + // flow: the newly created thread must exist in the client-side read + // model before we navigate to it. The WebSocket push channel + // (`orchestration.domainEvent`) is the primary update path and will + // usually deliver the event first, but a snapshot fetch here + // guarantees correctness when the push hasn't arrived yet. syncServerReadModel(snapshot); // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; @@ -3337,6 +3349,8 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, }) .catch(() => undefined); + // Re-sync after rollback so the deleted thread is removed from + // the client read model even if the WebSocket push is delayed. await api.orchestration .getSnapshot() .then((snapshot) => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9e9c58141b..0cfb29074a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -528,7 +528,11 @@ export default function CommandPalette({ const activateItem = useCallback(async (item: PaletteItem | undefined) => { if (!item || item.disabled) return; closePalette(); - await item.onSelect(); + try { + await item.onSelect(); + } catch (error) { + console.error("Failed to execute command palette action", error); + } }, [closePalette]); const onListKeyDown = useCallback( diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 437b3e78ef..b5d4a16bcc 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -259,11 +259,15 @@ export default function ProjectScriptsControl({ setDialogOpen(true); }; - const confirmDeleteScript = useCallback(() => { + const confirmDeleteScript = useCallback(async () => { if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); + try { + await onDeleteScript(editingScriptId); + setDeleteConfirmOpen(false); + setDialogOpen(false); + } catch (error) { + setValidationError(error instanceof Error ? error.message : "Failed to delete action."); + } }, [editingScriptId, onDeleteScript]); return ( diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8f9176d6ee..b707701196 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -644,6 +644,7 @@ function SettingsRouteView() {
{ diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 443c5b8bf6..67338f1d36 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -189,6 +189,7 @@ export function createWsNativeApi(): NativeApi { server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + removeKeybinding: (input) => transport.request(WS_METHODS.serverRemoveKeybinding, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index f106947738..1c9bc60a78 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -109,6 +109,10 @@ export class WsTransport { const ws = new WebSocket(this.url); ws.addEventListener("open", () => { + if (this.disposed) { + ws.close(); + return; + } this.ws = ws; if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 39890a3250..d8f826c016 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -36,7 +36,12 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal"; -import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { + ServerRemoveKeybindingInput, + ServerRemoveKeybindingResult, + ServerUpsertKeybindingInput, + ServerUpsertKeybindingResult, +} from "./server"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -147,6 +152,7 @@ export interface NativeApi { server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6500c5ddce..d15b6a3bc1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -8,6 +8,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -92,7 +93,7 @@ export const OpencodeProviderStartOptions = Schema.Struct({ serverUrl: Schema.optional(TrimmedNonEmptyString), binaryPath: Schema.optional(TrimmedNonEmptyString), hostname: Schema.optional(TrimmedNonEmptyString), - port: Schema.optional(Schema.Number), + port: Schema.optional(PositiveInt), workspace: Schema.optional(TrimmedNonEmptyString), username: Schema.optional(TrimmedNonEmptyString), password: Schema.optional(TrimmedNonEmptyString), @@ -103,7 +104,7 @@ export const KiloProviderStartOptions = Schema.Struct({ serverUrl: Schema.optional(TrimmedNonEmptyString), binaryPath: Schema.optional(TrimmedNonEmptyString), hostname: Schema.optional(TrimmedNonEmptyString), - port: Schema.optional(Schema.Number), + port: Schema.optional(PositiveInt), workspace: Schema.optional(TrimmedNonEmptyString), username: Schema.optional(TrimmedNonEmptyString), password: Schema.optional(TrimmedNonEmptyString), @@ -121,6 +122,37 @@ export const ProviderStartOptions = Schema.Struct({ kilo: Schema.optional(KiloProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; + +/** Opencode options with credentials stripped for safe persistence in events. */ +const OpencodeProviderStartOptionsRedacted = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyString), + binaryPath: Schema.optional(TrimmedNonEmptyString), + hostname: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(PositiveInt), + workspace: Schema.optional(TrimmedNonEmptyString), +}); + +/** Kilo options with credentials stripped for safe persistence in events. */ +const KiloProviderStartOptionsRedacted = Schema.Struct({ + serverUrl: Schema.optional(TrimmedNonEmptyString), + binaryPath: Schema.optional(TrimmedNonEmptyString), + hostname: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(PositiveInt), + workspace: Schema.optional(TrimmedNonEmptyString), +}); + +/** ProviderStartOptions without sensitive fields (username/password). Use in persisted events. */ +export const ProviderStartOptionsRedacted = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + copilot: Schema.optional(CopilotProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), + amp: Schema.optional(AmpProviderStartOptions), + geminiCli: Schema.optional(GeminiCliProviderStartOptions), + opencode: Schema.optional(OpencodeProviderStartOptionsRedacted), + kilo: Schema.optional(KiloProviderStartOptionsRedacted), +}); +export type ProviderStartOptionsRedacted = typeof ProviderStartOptionsRedacted.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -758,7 +790,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), - providerOptions: Schema.optional(ProviderStartOptions), + providerOptions: Schema.optional(ProviderStartOptionsRedacted), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index dbaa02f3fe..60cb03f94b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; +import { KeybindingCommand, KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { CODEX_REASONING_EFFORT_OPTIONS } from "./model"; import { ProviderKind } from "./orchestration"; @@ -92,6 +92,17 @@ export const ServerUpsertKeybindingResult = Schema.Struct({ }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; +export const ServerRemoveKeybindingInput = Schema.Struct({ + command: KeybindingCommand, +}); +export type ServerRemoveKeybindingInput = typeof ServerRemoveKeybindingInput.Type; + +export const ServerRemoveKeybindingResult = Schema.Struct({ + keybindings: ResolvedKeybindingsConfig, + issues: ServerConfigIssues, +}); +export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 994a7aa1f7..e030e6d752 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -29,7 +29,7 @@ import { TerminalRestartInput, TerminalWriteInput, } from "./terminal"; -import { KeybindingRule } from "./keybindings"; +import { KeybindingCommand, KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; @@ -72,6 +72,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + serverRemoveKeybinding: "server.removeKeybinding", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -138,6 +139,7 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverRemoveKeybinding, Schema.Struct({ command: KeybindingCommand })), ]); export const WebSocketRequest = Schema.Struct({ diff --git a/scripts/install.sh b/scripts/install.sh index 87362420f7..19fd22cd16 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,12 +53,17 @@ if [[ ! -t 0 ]]; then INTERACTIVE=false fi -# Prompt helper — falls back to default when non-interactive +# Prompt helper — reads from /dev/tty when stdin is piped, falls back to default prompt() { local var_name="$1" message="$2" default="$3" if $INTERACTIVE; then read -rp " ${CYAN}?${RESET} ${message} [${BOLD}${default}${RESET}]: " input printf -v "$var_name" '%s' "${input:-$default}" + elif [[ -r /dev/tty ]]; then + printf " %s?%s %s [%s%s%s]: " "$CYAN" "$RESET" "$message" "$BOLD" "$default" "$RESET" > /dev/tty + local answer + read -r answer < /dev/tty + printf -v "$var_name" '%s' "${answer:-$default}" else printf -v "$var_name" '%s' "$default" fi @@ -68,17 +73,25 @@ prompt_choice() { local var_name="$1" message="$2" default="$3" shift 3 local options=("$@") - if $INTERACTIVE; then - printf "\n ${CYAN}?${RESET} %s\n" "$message" + if $INTERACTIVE || [[ -r /dev/tty ]]; then + local tty_out="/dev/tty" + local tty_in="/dev/tty" + if $INTERACTIVE; then + tty_out="/dev/stderr" + tty_in="/dev/stdin" + fi + printf "\n %s?%s %s\n" "$CYAN" "$RESET" "$message" > "$tty_out" for i in "${!options[@]}"; do local num=$((i + 1)) if [[ "$num" == "$default" ]]; then - printf " ${BOLD}[%d] %s (default)${RESET}\n" "$num" "${options[$i]}" + printf " %s[%d] %s (default)%s\n" "$BOLD" "$num" "${options[$i]}" "$RESET" > "$tty_out" else - printf " [%d] %s\n" "$num" "${options[$i]}" + printf " [%d] %s\n" "$num" "${options[$i]}" > "$tty_out" fi done - read -rp " ${CYAN}→${RESET} Enter choice: " input + printf " %s→%s Enter choice: " "$CYAN" "$RESET" > "$tty_out" + local input + read -r input < "$tty_in" input="${input:-$default}" # Validate if [[ ! "$input" =~ ^[0-9]+$ ]] || (( input < 1 || input > ${#options[@]} )); then @@ -162,13 +175,18 @@ find_bin() { fi # Then check explicit candidate paths for candidate in "$@"; do - # Expand globs (e.g. nvm version dirs) - for expanded in $candidate; do - if [[ -x "$expanded" ]]; then - echo "$expanded" - return 0 - fi - done + # Expand globs (e.g. nvm version dirs), preserving paths with spaces + if [[ "$candidate" == *[\*\?\[]* ]]; then + while IFS= read -r expanded; do + if [[ -x "$expanded" ]]; then + echo "$expanded" + return 0 + fi + done < <(compgen -G "$candidate") + elif [[ -x "$candidate" ]]; then + echo "$candidate" + return 0 + fi done return 1 } @@ -339,6 +357,10 @@ install_bun() { if $INTERACTIVE; then read -rp " ${CYAN}?${RESET} Install bun now? [Y/n]: " do_install do_install="${do_install:-y}" + elif [[ -r /dev/tty ]]; then + printf " %s?%s Install bun now? [Y/n]: " "$CYAN" "$RESET" > /dev/tty + read -r do_install < /dev/tty + do_install="${do_install:-y}" fi case "$do_install" in From 3c9b271dc09b6874cc0ef80fc93e460ca6c9903a Mon Sep 17 00:00:00 2001 From: sherlock Date: Mon, 9 Mar 2026 16:44:47 +0530 Subject: [PATCH 23/23] fix: address round-3 review feedback and internal audit findings CodeRabbit round 3: - decider.ts: move credential redaction to persistence/WS boundaries so ProviderCommandReactor receives full providerOptions at runtime - ProviderCommandReactor: stop active session on thread.deleted before cleanup; don't mark session stopped after failed stop - ChatView: await clipboard.writeText before showing success indicator; fix asymmetric drag enter/leave bookkeeping; scope thread rollback to pre-start failures only - ProviderHealth: fix catch(() => []) to catch(() => undefined) - install.sh: remove unreachable MINGW64* pattern; remove unused NODE_BIN Internal specialist audit: - Security: redact credentials in ProviderService runtimePayload before SQLite persistence (all 3 upsert boundaries) - Remove 6 unused imports in ChatView.tsx (lint warnings eliminated) - Extract toMessage() to shared module (deduplicated across 4 adapters) - CursorAdapter: early return in extractCursorChunkText to prevent duplicate text when both text and content fields are populated - SessionTextGeneration: handle session.exited event to fail fast instead of blocking 180s on provider process crash - CopilotAdapter: eliminate double trimToUndefined call --- .../src/git/Layers/SessionTextGeneration.ts | 7 ++ .../Layers/ProviderCommandReactor.ts | 7 +- apps/server/src/orchestration/decider.ts | 20 +----- apps/server/src/orchestration/redactEvent.ts | 50 +++++++++++++ .../Layers/OrchestrationEventStore.ts | 29 ++++---- .../src/provider/Layers/ClaudeCodeAdapter.ts | 7 +- .../src/provider/Layers/CodexAdapter.ts | 8 +-- .../src/provider/Layers/CopilotAdapter.ts | 14 ++-- .../src/provider/Layers/CursorAdapter.ts | 11 ++- .../src/provider/Layers/ProviderHealth.ts | 2 +- .../src/provider/Layers/ProviderService.ts | 27 ++++++- apps/server/src/provider/toMessage.ts | 6 ++ apps/server/src/wsServer.ts | 3 +- apps/web/src/components/ChatView.tsx | 70 +++++++++---------- packages/contracts/src/orchestration.ts | 4 +- scripts/install.sh | 5 +- 16 files changed, 166 insertions(+), 104 deletions(-) create mode 100644 apps/server/src/orchestration/redactEvent.ts create mode 100644 apps/server/src/provider/toMessage.ts diff --git a/apps/server/src/git/Layers/SessionTextGeneration.ts b/apps/server/src/git/Layers/SessionTextGeneration.ts index d621571ce7..7455b0c2b1 100644 --- a/apps/server/src/git/Layers/SessionTextGeneration.ts +++ b/apps/server/src/git/Layers/SessionTextGeneration.ts @@ -225,6 +225,13 @@ const makeSessionTextGeneration = Effect.gen(function* () { }); } + if (event.type === "session.exited") { + return yield* new TextGenerationError({ + operation, + detail: `${resolvedProvider} provider session exited unexpectedly during text generation.`, + }); + } + if (event.type === "turn.completed") { if (event.payload.state !== "completed") { return yield* new TextGenerationError({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cb27f68b68..71acf8f932 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -697,7 +697,12 @@ const make = Effect.gen(function* () { Effect.gen(function* () { switch (event.type) { case "thread.deleted": { - threadProviderOptions.delete(event.payload.threadId); + const threadId = event.payload.threadId; + // Best-effort stop — thread is being deleted, ignore failures + yield* providerService + .stopSession({ threadId }) + .pipe(Effect.catchCause(() => Effect.void)); + threadProviderOptions.delete(threadId); return; } case "thread.runtime-mode-set": { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3dc871555a..88e6093c03 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,8 +2,6 @@ import type { OrchestrationCommand, OrchestrationEvent, OrchestrationReadModel, - ProviderStartOptions, - ProviderStartOptionsRedacted, } from "@t3tools/contracts"; import { Effect } from "effect"; @@ -18,22 +16,6 @@ import { const nowIso = () => new Date().toISOString(); const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; -/** Strip sensitive fields (username, password) from provider start options for safe event persistence. */ -function redactProviderStartOptions( - opts: ProviderStartOptions, -): ProviderStartOptionsRedacted { - const redacted = { ...opts } as Record; - if (opts.opencode) { - const { username: _u, password: _p, ...rest } = opts.opencode; - redacted.opencode = rest; - } - if (opts.kilo) { - const { username: _u, password: _p, ...rest } = opts.kilo; - redacted.kilo = rest; - } - return redacted as ProviderStartOptionsRedacted; -} - const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: "thread", @@ -321,7 +303,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), - ...(command.providerOptions !== undefined ? { providerOptions: redactProviderStartOptions(command.providerOptions) } : {}), + ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/orchestration/redactEvent.ts b/apps/server/src/orchestration/redactEvent.ts new file mode 100644 index 0000000000..5610a26cde --- /dev/null +++ b/apps/server/src/orchestration/redactEvent.ts @@ -0,0 +1,50 @@ +import type { + OrchestrationEvent, + ProviderStartOptions, + ProviderStartOptionsRedacted, +} from "@t3tools/contracts"; + +/** Strip sensitive fields (username, password) from provider start options. */ +export function redactProviderStartOptions( + opts: ProviderStartOptions, +): ProviderStartOptionsRedacted { + const redacted = { ...opts } as Record; + if (opts.opencode) { + const { username: _u, password: _p, ...rest } = opts.opencode; + redacted.opencode = rest; + } + if (opts.kilo) { + const { username: _u, password: _p, ...rest } = opts.kilo; + redacted.kilo = rest; + } + return redacted as ProviderStartOptionsRedacted; +} + +/** + * Redact sensitive fields from an orchestration event payload. + * + * Currently strips `username`/`password` from opencode and kilo provider + * options on `thread.turn-start-requested` events. Use this at persistence + * and client-broadcast boundaries so credentials never leave the server + * runtime. + */ +export function redactEventForBoundary>( + event: T, +): T { + if (event.type !== "thread.turn-start-requested") { + return event; + } + const payload = event.payload as Record; + if (!payload.providerOptions) { + return event; + } + return { + ...event, + payload: { + ...payload, + providerOptions: redactProviderStartOptions( + payload.providerOptions as ProviderStartOptions, + ), + }, + } as T; +} diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 4d81cf5e8d..59f804caf2 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -15,6 +15,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Stream } from "effect"; +import { redactEventForBoundary } from "../../orchestration/redactEvent.ts"; import { toPersistenceDecodeError, toPersistenceSqlError, @@ -178,19 +179,20 @@ const makeEventStore = Effect.gen(function* () { `, }); - const append: OrchestrationEventStoreShape["append"] = (event) => - appendEventRow({ - eventId: event.eventId, - aggregateKind: event.aggregateKind, - streamId: event.aggregateId, - type: event.type, - causationEventId: event.causationEventId, - correlationId: event.correlationId, - actorKind: inferActorKind(event), - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: event.payload, - metadataJson: event.metadata, + const append: OrchestrationEventStoreShape["append"] = (event) => { + const safeEvent = redactEventForBoundary(event); + return appendEventRow({ + eventId: safeEvent.eventId, + aggregateKind: safeEvent.aggregateKind, + streamId: safeEvent.aggregateId, + type: safeEvent.type, + causationEventId: safeEvent.causationEventId, + correlationId: safeEvent.correlationId, + actorKind: inferActorKind(safeEvent), + occurredAt: safeEvent.occurredAt, + commandId: safeEvent.commandId, + payloadJson: safeEvent.payload, + metadataJson: safeEvent.metadata, }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -204,6 +206,7 @@ const makeEventStore = Effect.gen(function* () { ), ), ); + }; const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( sequenceExclusive, diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 25d3ccec7e..191561f55b 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -51,6 +51,7 @@ import { } from "../Errors.ts"; import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { toMessage } from "../toMessage.ts"; const PROVIDER = "claudeCode" as const; @@ -244,12 +245,6 @@ function isSyntheticClaudeThreadId(value: string): boolean { return value.startsWith("claude-thread-"); } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} function toStringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 19d38d8dbd..887df05b63 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { toMessage } from "../toMessage.ts"; const PROVIDER = "codex" as const; @@ -51,13 +52,6 @@ export interface CodexAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - function toSessionError( threadId: ThreadId, cause: unknown, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 6db5f8f572..e00a64c015 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -46,6 +46,7 @@ import { } from "./copilotTurnTracking.ts"; import { normalizeCopilotCliPathOverride, resolveBundledCopilotCliPath } from "./copilotCliPath.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; +import { toMessage } from "../toMessage.ts"; import type { ProviderThreadSnapshot, ProviderThreadTurnSnapshot, @@ -126,12 +127,6 @@ interface CopilotClientHandle { stop(): Promise; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} function makeEventId(prefix: string) { return EventId.makeUnsafe(`${prefix}-${randomUUID()}`); @@ -1076,8 +1071,11 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => if (event.type === "session.model_change") { record.model = event.data.newModel; } - if (event.type === "tool.execution_start" && trimToUndefined(event.data.toolName)) { - record.toolTitlesByCallId.set(event.data.toolCallId, trimToUndefined(event.data.toolName)!); + if (event.type === "tool.execution_start") { + const toolName = trimToUndefined(event.data.toolName); + if (toolName) { + record.toolTitlesByCallId.set(event.data.toolCallId, toolName); + } } void writeNativeEvent(record.threadId, event); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 9447109dd2..fbbc5237ad 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -45,6 +45,7 @@ import { CursorAcpSessionUpdateNotification, } from "../Services/CursorAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { toMessage } from "../toMessage.ts"; const PROVIDER = "cursor" as const; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; @@ -239,12 +240,6 @@ export interface CursorAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.makeUnsafe(value); @@ -340,6 +335,10 @@ function extractCursorChunkText(update: unknown): string { appendChunkText(fragments, updateRecord.text); appendChunkText(fragments, updateRecord.delta); + if (fragments.length > 0) { + return fragments.join(""); + } + const content = updateRecord.content; if (typeof content === "string") { fragments.push(content); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index b024997e89..7399dc8a62 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -488,7 +488,7 @@ export const checkCopilotProviderStatus: Effect.Effect | undefined; }; } finally { - await client.stop().catch(() => []); + await client.stop().catch(() => undefined); } }, catch: (cause): CopilotHealthProbeError => ({ _tag: "CopilotHealthProbeError", cause }), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 6a9b60a662..5b7846eafe 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -20,12 +20,14 @@ import { ProviderStopSessionInput, type ProviderRuntimeEvent, type ProviderSession, + type ProviderStartOptions, } from "@t3tools/contracts"; import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { redactProviderStartOptions } from "../../orchestration/redactEvent.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, @@ -86,16 +88,27 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st } } +function redactProviderOptions( + providerOptions: unknown, +): unknown { + if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { + return providerOptions; + } + return redactProviderStartOptions(providerOptions as ProviderStartOptions); +} + function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly providerOptions?: unknown }, ): Record { + const safeProviderOptions = + extra?.providerOptions !== undefined ? redactProviderOptions(extra.providerOptions) : undefined; return { cwd: session.cwd ?? null, model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, - ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), + ...(safeProviderOptions !== undefined ? { providerOptions: safeProviderOptions } : {}), }; } @@ -344,6 +357,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const sendTurnProviderOptions = readPersistedProviderOptions( Option.getOrUndefined(sendTurnBinding)?.runtimePayload, ); + const safeSendTurnProviderOptions = + sendTurnProviderOptions !== undefined + ? redactProviderOptions(sendTurnProviderOptions) + : undefined; yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -353,7 +370,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: new Date().toISOString(), - ...(sendTurnProviderOptions !== undefined ? { providerOptions: sendTurnProviderOptions } : {}), + ...(safeSendTurnProviderOptions !== undefined ? { providerOptions: safeSendTurnProviderOptions } : {}), }, }); yield* analytics.record("provider.turn.sent", { @@ -521,6 +538,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const binding = Option.getOrUndefined(bindingOption); if (!binding) return Effect.void; const existingProviderOptions = readPersistedProviderOptions(binding.runtimePayload); + const safeExistingProviderOptions = + existingProviderOptions !== undefined + ? redactProviderOptions(existingProviderOptions) + : undefined; return directory.upsert({ threadId, provider: binding.provider, @@ -529,7 +550,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => activeTurnId: null, lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt: new Date().toISOString(), - ...(existingProviderOptions !== undefined ? { providerOptions: existingProviderOptions } : {}), + ...(safeExistingProviderOptions !== undefined ? { providerOptions: safeExistingProviderOptions } : {}), }, }); }), diff --git a/apps/server/src/provider/toMessage.ts b/apps/server/src/provider/toMessage.ts new file mode 100644 index 0000000000..18870356c6 --- /dev/null +++ b/apps/server/src/provider/toMessage.ts @@ -0,0 +1,6 @@ +export function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 092c00ce42..83037a4bad 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -53,6 +53,7 @@ import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; +import { redactEventForBoundary } from "./orchestration/redactEvent.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; @@ -634,7 +635,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< broadcastPush({ type: "push", channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - data: event, + data: redactEventForBoundary(event), }), ).pipe(Effect.forkIn(subscriptionsScope)); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b0c3421e2d..c6b7c2d8be 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,5 @@ import { type ApprovalRequestId, - CLAUDE_CODE_EFFORT_OPTIONS, type ClaudeCodeEffort, DEFAULT_MODEL_BY_PROVIDER, CURSOR_REASONING_OPTIONS, @@ -18,8 +17,6 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, - type ServerProviderModel, - type ServerProviderQuotaSnapshot, type ServerProviderStatus, type ProviderKind, type ThreadId, @@ -33,7 +30,6 @@ import { getDefaultClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, - getModelOptions, getCursorModelCapabilities, getCursorModelFamilyOptions, getReasoningEffortOptions, @@ -87,10 +83,8 @@ import { findLatestProposedPlan, type PendingApproval, type PendingUserInput, - type ProviderPickerKind, PROVIDER_OPTIONS, deriveWorkLogEntries, - hasToolActivityForTurn, hasToolActivitySince, isLatestTurnSettled, formatElapsed, @@ -2631,10 +2625,6 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragOverComposer(false); @@ -3288,6 +3278,8 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase(); }; + let serverStarted = false; + await api.orchestration .dispatchCommand({ type: "thread.create", @@ -3325,7 +3317,10 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt, }), ) - .then(() => api.orchestration.getSnapshot()) + .then(() => { + serverStarted = true; + return api.orchestration.getSnapshot(); + }) .then((snapshot) => { // Snapshot sync is a safety net for the navigation/thread creation // flow: the newly created thread must exist in the client-side read @@ -3342,21 +3337,23 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .catch(async (err) => { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, - }) - .catch(() => undefined); - // Re-sync after rollback so the deleted thread is removed from - // the client read model even if the WebSocket push is delayed. - await api.orchestration - .getSnapshot() - .then((snapshot) => { - syncServerReadModel(snapshot); - }) - .catch(() => undefined); + if (!serverStarted) { + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: nextThreadId, + }) + .catch(() => undefined); + // Re-sync after rollback so the deleted thread is removed from + // the client read model even if the WebSocket push is delayed. + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + } toastManager.add({ type: "error", title: "Could not start implementation thread", @@ -6690,15 +6687,18 @@ const OpenInPicker = memo(function OpenInPicker({ const copyPath = useCallback(() => { if (!openInCwd) return; - void navigator.clipboard.writeText(openInCwd); - setCopiedPath(true); - if (copiedPathTimeoutRef.current !== null) { - clearTimeout(copiedPathTimeoutRef.current); - } - copiedPathTimeoutRef.current = setTimeout(() => { - setCopiedPath(false); - copiedPathTimeoutRef.current = null; - }, 2000); + void navigator.clipboard.writeText(openInCwd).then(() => { + setCopiedPath(true); + if (copiedPathTimeoutRef.current !== null) { + clearTimeout(copiedPathTimeoutRef.current); + } + copiedPathTimeoutRef.current = setTimeout(() => { + setCopiedPath(false); + copiedPathTimeoutRef.current = null; + }, 2000); + }).catch(() => { + // Clipboard write failed — don't show success indicator. + }); }, [openInCwd]); const openFavoriteEditorShortcutLabel = useMemo( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d15b6a3bc1..a5fa2a8c71 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -790,7 +790,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), - providerOptions: Schema.optional(ProviderStartOptionsRedacted), + /** Runtime events carry full ProviderStartOptions (including credentials). + * Redaction to ProviderStartOptionsRedacted happens at persistence and broadcast boundaries. */ + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/scripts/install.sh b/scripts/install.sh index 19fd22cd16..f9b49974be 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -131,7 +131,7 @@ detect_os() { case "$OS_RAW" in Darwin*) OS="macos" ;; Linux*) OS="linux" ;; - CYGWIN*|MSYS*|MINGW*|MINGW64*) OS="windows" ;; + CYGWIN*|MSYS*|MINGW*) OS="windows" ;; *) OS="unknown" ;; esac @@ -271,7 +271,6 @@ check_node() { fi log_ok "Node.js found: ${BOLD}${node_bin}${RESET} (${node_ver})" - NODE_BIN="$node_bin" return 0 } @@ -402,7 +401,7 @@ check_prerequisites() { local failed=false - GIT_BIN="" NODE_BIN="" BUN_BIN="" + GIT_BIN="" BUN_BIN="" check_git || failed=true check_node || failed=true