From 2093b9d706e1d096ae8afd7587ea173880a360b7 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 17 Aug 2026 21:23:57 -0400 Subject: [PATCH 1/6] feat(providers): first-class OpenRouter provider, v2-survivable Adapts upstream PR pingdotgg/t3code#4125 (closed upstream; archived on this fork) into a built-in OpenRouter driver that rides the Claude Agent CLI as its runtime, with live model-catalog fetching, an owned env contract (Anthropic-compat credentials cleared and re-stamped, never inherited), settings UI, picker option, and provider icon. Restructured for orchestrator-v2 survival: - All OpenRouter logic lives in provider/openrouter (env ownership, base-URL normalization, catalog fetch + fallbacks, Claude-settings bridge) with no V1 adapter contract dependencies. - ClaudeAdapter is NOT modified. The upstream PR parameterized its provider constant across ~50 sites; this port instead decorates the finished adapter (withOpenRouterAdapterIdentity) to re-stamp driver identity on events and sessions, keeping the churn-heavy file merge-clean. - Only Drivers/OpenRouterDriver.ts (the V1 ProviderDriver registration) retires at the v2 cutover; ClaudeAdapterV2 already imports the same env plumbing and accepts per-instance env, so the rewrite is a small instance flavor feeding buildOpenRouterProcessEnv into it. - The PR's 381-line ClaudeAdapter surgery and its probeCliVersion refactor were dropped entirely - both capabilities landed upstream independently since July. Registered as seam openrouter-first-party (27 seams / 175 checks verify). Tests: 12 module tests including env-ownership, auth-vs-CLI status independence, and decorator restamping; registry driver-list expectations extended; web 2691 pass; typecheck clean x5. Co-Authored-By: Claude Fable 5 --- .t3-turbo/customizations.json | 50 ++++ SEAM.md | 28 ++ .../src/provider/Drivers/OpenRouterDriver.ts | 179 ++++++++++++ .../Layers/OpenRouterProvider.test.ts | 168 ++++++++++++ .../src/provider/Layers/OpenRouterProvider.ts | 257 ++++++++++++++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/builtInDrivers.ts | 5 +- .../provider/openrouter/OpenRouterModels.ts | 162 +++++++++++ .../openrouter/OpenRouterRuntime.test.ts | 126 +++++++++ .../provider/openrouter/OpenRouterRuntime.ts | 140 ++++++++++ apps/web/src/components/Icons.tsx | 23 ++ .../src/components/chat/providerIconUtils.ts | 11 +- .../components/settings/providerDriverMeta.ts | 17 +- apps/web/src/composerDraftStore.ts | 12 +- apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/session-logic.ts | 1 + packages/contracts/src/model.ts | 4 + packages/contracts/src/settings.ts | 81 ++++++ 18 files changed, 1261 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/provider/Drivers/OpenRouterDriver.ts create mode 100644 apps/server/src/provider/Layers/OpenRouterProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenRouterProvider.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterModels.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts create mode 100644 apps/server/src/provider/openrouter/OpenRouterRuntime.ts diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 9452d5277a12..5869dbee75b1 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -1290,6 +1290,56 @@ ] } ] + }, + { + "id": "openrouter-first-party", + "status": "implemented", + "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", + "checks": [ + { + "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", + "markers": [ + "OPENROUTER_OWNED_ENV_KEYS", + "buildOpenRouterProcessEnv", + "withOpenRouterAdapterIdentity" + ] + }, + { + "path": "apps/server/src/provider/openrouter/OpenRouterModels.ts", + "markers": ["fetchOpenRouterModels", "FALLBACK_OPENROUTER_MODELS"] + }, + { + "path": "apps/server/src/provider/Drivers/OpenRouterDriver.ts", + "markers": [ + "withOpenRouterAdapterIdentity(", + "buildOpenRouterProcessEnv(effectiveConfig, baseEnv)" + ] + }, + { + "path": "apps/server/src/provider/Layers/OpenRouterProvider.ts", + "markers": ["checkOpenRouterProviderStatus", "makePendingOpenRouterProvider"] + }, + { + "path": "apps/server/src/provider/builtInDrivers.ts", + "markers": ["OpenRouterDriver"] + }, + { + "path": "packages/contracts/src/settings.ts", + "markers": ["export const OpenRouterSettings"] + }, + { + "path": "packages/contracts/src/model.ts", + "markers": ["OPENROUTER_DRIVER_KIND"] + }, + { + "path": "apps/web/src/components/settings/providerDriverMeta.ts", + "markers": ["label: \"OpenRouter\""] + }, + { + "path": "apps/web/src/session-logic.ts", + "markers": ["label: \"OpenRouter\""] + } + ] } ] } diff --git a/SEAM.md b/SEAM.md index 1bf45fbbcdca..7c0758801985 100644 --- a/SEAM.md +++ b/SEAM.md @@ -386,6 +386,34 @@ Still carried (upstream has no equivalent yet, see pingdotgg/t3code#5575): On a nightly-sync conflict in decider.ts, prefer upstream wholesale if upstream lands a sticky un-settle; otherwise reapply only the pin behavior above. +## OpenRouter first-party provider (fork feature) + +Adapted from upstream PR pingdotgg/t3code#4125 (closed upstream; archived at +`archive/upstream-pr-4125-openrouter-provider`). OpenRouter rides the Claude Agent CLI as its +runtime and ships as a built-in driver with live model-catalog fetching. + +Built to survive orchestrator v2 (pingdotgg/t3code#2829): + +- **Additive, v2-safe** `apps/server/src/provider/openrouter/` — env ownership + (`buildOpenRouterProcessEnv` clears and re-stamps every Anthropic/OpenRouter credential key), + base-URL normalization, model catalog fetch with fallbacks, the Claude-settings bridge, and + the `withOpenRouterAdapterIdentity` decorator. No V1 adapter imports besides the shape type. +- **Additive, v2-safe** `Layers/OpenRouterProvider.ts` — status snapshot (CLI probe + API-key + validation via the catalog), contracts (`OpenRouterSettings`, driver-kind maps in `model.ts`), + and the web wiring (icon, driver meta, picker option, composer keys, context-window name). +- **Additive, V1-shim (retires at v2 cutover)** `Drivers/OpenRouterDriver.ts` — the + `ProviderDriver` registration. v2's `ClaudeAdapterV2` already imports the same + `makeClaudeEnvironment`/`mergeProviderInstanceEnvironment` plumbing and accepts per-instance + env, so the rewrite is a small instance flavor feeding `buildOpenRouterProcessEnv` into it. +- Deliberately NOT modified: `ClaudeAdapter.ts`. The upstream PR parameterized its provider + constant across ~50 sites; the fork instead decorates the finished adapter to re-stamp the + driver identity on events and sessions, keeping the churn-heavy file merge-clean. + +On a nightly-sync conflict: everything here is additive except `builtInDrivers.ts`, +`settings.ts`/`model.ts` map entries, and the four web wiring points — re-add the fork lines +after upstream's. If upstream ships its own OpenRouter or the ACP registry (#6071) covers it, +prefer upstream and retire the shim first. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts new file mode 100644 index 000000000000..9e2c3dd48697 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -0,0 +1,179 @@ +/** + * OpenRouterDriver — first-class `ProviderDriver` for OpenRouter. + * + * Uses the Claude Agent SDK/CLI as the agent runtime while owning OpenRouter + * settings (API key, base URL, attribution) and stamping `driverKind: + * "openrouter"` on snapshots and sessions. + * + * @module provider/Drivers/OpenRouterDriver + */ +import { OpenRouterSettings, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "../Layers/OpenRouterProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { + buildOpenRouterProcessEnv, + OPENROUTER_DRIVER_KIND, + toClaudeSettings, + withOpenRouterAdapterIdentity, +} from "../openrouter/OpenRouterRuntime.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: OPENROUTER_DRIVER_KIND, + packageName: null, + }), +); + +export type OpenRouterDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: OPENROUTER_DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenRouterDriver: ProviderDriver = { + driverKind: OPENROUTER_DRIVER_KIND, + metadata: { + displayName: "OpenRouter", + supportsMultipleInstances: true, + }, + configSchema: OpenRouterSettings, + defaultConfig: (): OpenRouterSettings => decodeOpenRouterSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const baseEnv = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OpenRouterSettings; + // Build OpenRouter-owned process env once; pass through to adapter + probes. + const processEnv = buildOpenRouterProcessEnv(effectiveConfig, baseEnv); + const claudeSettings = toClaudeSettings(effectiveConfig); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: OPENROUTER_DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = withOpenRouterAdapterIdentity( + yield* makeClaudeAdapter(claudeSettings, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }), + ); + const textGeneration = yield* makeClaudeTextGeneration(claudeSettings, processEnv); + + const checkProvider = checkOpenRouterProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenRouterProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: OPENROUTER_DRIVER_KIND, + instanceId, + detail: `Failed to build OpenRouter snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: OPENROUTER_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts new file mode 100644 index 000000000000..4166e4f550eb --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -0,0 +1,168 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { OpenRouterSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + checkOpenRouterProviderStatus, + makePendingOpenRouterProvider, +} from "./OpenRouterProvider.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +const makeModelsHttpClient = (status: number, body: unknown) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + ); + +const EmptyModelsHttpClientLive = makeModelsHttpClient(200, { data: [] }); +const ValidModelsHttpClientLive = makeModelsHttpClient(200, { + data: [{ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" }], +}); +const UnauthorizedModelsHttpClientLive = makeModelsHttpClient(401, { error: "unauthorized" }); + +describe("makePendingOpenRouterProvider", () => { + it.effect("builds a disabled snapshot with fallback models", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider( + decodeOpenRouterSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.displayName).toBe("OpenRouter"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4.5")).toBe( + true, + ); + }), + ); + + it.effect("builds a pending snapshot for enabled OpenRouter", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider(decodeOpenRouterSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toMatch(/checking openrouter/i); + }), + ); +}); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (missing binary)", + (it) => { + it.effect("reports a missing Claude runtime binary without throwing", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (auth independent of CLI)", + (it) => { + it.effect("reports authenticated when CLI is missing but API key is valid", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + binaryPath: "/definitely/not/installed/claude-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.some((model) => model.slug === "anthropic/claude-sonnet-4.5")).toBe( + true, + ); + }), + ); + + it.effect("reports ready when CLI version probe and auth both succeed", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-test", + // `node --version` is a reliable cross-platform success probe. + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.message).toMatch(/openrouter\.ai\/api/i); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(UnauthorizedModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (401)", + (it) => { + it.effect("reports unauthenticated on 401 even when CLI is healthy", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "sk-or-bad", + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/API key/i); + }), + ); + }, +); + +it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive)))( + "checkOpenRouterProviderStatus (empty key)", + (it) => { + it.effect("reports unauthenticated when settings apiKey is empty", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenRouterProviderStatus( + decodeOpenRouterSettings({ + enabled: true, + apiKey: "", + binaryPath: process.execPath, + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); + }), + ); + }, +); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.ts b/apps/server/src/provider/Layers/OpenRouterProvider.ts new file mode 100644 index 000000000000..aed0c0be85a8 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -0,0 +1,257 @@ +/** + * OpenRouterProvider — snapshot/status checks for the OpenRouter driver. + * + * OpenRouter rides the Claude Agent CLI as its runtime, so readiness is the + * conjunction of two probes: the `claude` binary answers `--version` with the + * OpenRouter-owned environment applied, and the OpenRouter API accepts the + * configured key (verified by fetching the model catalog). Either failing + * degrades the snapshot rather than erroring the driver. + * + * Turbo seam: v2-safe by design — everything here consumes only the + * `provider/openrouter` transport module and generic snapshot helpers, none + * of the V1 adapter contract. + * + * @module provider/Layers/OpenRouterProvider + */ +import type { OpenRouterSettings, ServerProviderModel } from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + EMPTY_OPENROUTER_CAPABILITIES, + FALLBACK_OPENROUTER_MODELS, + fetchOpenRouterModels, +} from "../openrouter/OpenRouterModels.ts"; +import { + buildOpenRouterProcessEnv, + normalizeOpenRouterBaseUrl, +} from "../openrouter/OpenRouterRuntime.ts"; + +const OPENROUTER_PRESENTATION = { + displayName: "OpenRouter", + showInteractionModeToggle: true, +} as const; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function modelsFromSettings( + builtIn: ReadonlyArray, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings(builtIn, customModels, EMPTY_OPENROUTER_CAPABILITIES); +} + +interface CliProbeFields { + readonly installed: boolean; + readonly version: string | null; + readonly cliOk: boolean; + readonly cliMessage: string; +} + +/** + * Probe the Claude Agent CLI with the OpenRouter environment applied. The + * probe result never fails the effect; failures fold into snapshot fields. + */ +const probeClaudeCliForOpenRouter = Effect.fn("probeClaudeCliForOpenRouter")(function* ( + settings: OpenRouterSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + const run = Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { + env: environment, + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }); + return yield* spawnAndCollect(settings.binaryPath, command); + }); + + const versionProbe = yield* run.pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + if (isCommandMissingCause(error)) { + return { + installed: false, + version: null, + cliOk: false, + cliMessage: + "Claude Agent CLI (`claude`) is not installed or not on PATH. OpenRouter uses Claude Code as its agent runtime.", + }; + } + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "Failed to execute Claude Agent CLI health check for OpenRouter.", + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + installed: true, + version: null, + cliOk: false, + cliMessage: "Claude Agent CLI timed out while running `--version` for OpenRouter.", + }; + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + return { + installed: true, + version: parsedVersion, + cliOk: false, + cliMessage: "Claude Agent CLI is installed but failed to run for OpenRouter.", + }; + } + + return { + installed: true, + version: parsedVersion, + cliOk: true, + cliMessage: "", + }; +}); + +/** Full status check: CLI runtime probe plus API-key-validating model fetch. */ +export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderStatus")(function* ( + settings: OpenRouterSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient +> { + const checkedAt = yield* nowIso; + const fallbackModels = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Turbo settings.", + }, + }); + } + + const processEnv = environment ?? buildOpenRouterProcessEnv(settings); + const cliFields = yield* probeClaudeCliForOpenRouter(settings, processEnv); + const modelFetch = yield* fetchOpenRouterModels(settings); + + const authOk = modelFetch.ok; + const auth = authOk + ? { status: "authenticated" as const } + : { + status: modelFetch.authFailed ? ("unauthenticated" as const) : ("unknown" as const), + }; + + const models = authOk + ? modelsFromSettings(modelFetch.models, settings.customModels) + : fallbackModels; + + if (cliFields.cliOk && authOk) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: cliFields.version, + status: "ready", + auth, + message: `Using ${normalizeOpenRouterBaseUrl(settings.baseUrl)} via the Claude Code runtime.`, + }, + }); + } + + const messages: Array = []; + if (!cliFields.cliOk) { + messages.push(cliFields.cliMessage); + } + if (!authOk) { + messages.push(modelFetch.message); + } + + // A missing runtime or a rejected key blocks sessions outright; a catalog + // fetch failing for network reasons only degrades the model list. + const status = + !cliFields.cliOk || (modelFetch.ok === false && modelFetch.authFailed) ? "error" : "warning"; + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: cliFields.installed, + version: cliFields.version, + status, + auth, + message: messages.join(" "), + }, + }); +}); + +/** Instant pre-probe snapshot so the instance renders before the first check. */ +export const makePendingOpenRouterProvider = ( + settings: OpenRouterSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* nowIso; + const models = modelsFromSettings(FALLBACK_OPENROUTER_MODELS, settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenRouter is disabled in T3 Turbo settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenRouter…", + }, + }); + }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9c88495bf33d..2b2d3c197f46 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1752,6 +1752,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "openrouter", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..1b40c4187b9b 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OpenRouterDriver, type OpenRouterDriverEnv } from "./Drivers/OpenRouterDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | OpenRouterDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ + { + slug: DEFAULT_OPENROUTER_MODEL, + name: "Claude Sonnet 4.5", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, + { + slug: "openai/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }, +]; + +const OpenRouterModelsResponse = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + }), + ), +}); + +const decodeOpenRouterModelsResponse = Schema.decodeUnknownEffect(OpenRouterModelsResponse); +const MAX_DISCOVERED_MODELS = 200; + +export type OpenRouterModelFetchResult = + | { + readonly ok: true; + readonly models: ReadonlyArray; + } + | { + readonly ok: false; + readonly authFailed: boolean; + readonly message: string; + }; + +export const fetchOpenRouterModels = Effect.fn("fetchOpenRouterModels")(function* ( + settings: OpenRouterSettings, +): Effect.fn.Return { + const apiKey = settings.apiKey.trim(); + if (apiKey.length === 0) { + return { + ok: false, + authFailed: true, + message: "Add an OpenRouter API key in provider settings.", + }; + } + + const httpClient = yield* HttpClient.HttpClient; + const url = openRouterModelsUrl(settings.baseUrl); + + // Timeout covers both headers and body consumption so a stalling response + // body cannot hang the provider status refresh indefinitely. + const fetchResult = yield* Effect.gen(function* () { + const httpResponse = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.acceptJson, + httpClient.execute, + ); + + if (httpResponse.status === 401 || httpResponse.status === 403) { + return { + ok: false as const, + authFailed: true, + message: "OpenRouter API key is missing or invalid.", + } satisfies OpenRouterModelFetchResult; + } + + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return { + ok: false as const, + authFailed: false, + message: `OpenRouter models API returned HTTP ${httpResponse.status}.`, + } satisfies OpenRouterModelFetchResult; + } + + const body = yield* httpResponse.json.pipe(Effect.result); + if (Result.isFailure(body)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unreadable response.", + } satisfies OpenRouterModelFetchResult; + } + + const decoded = yield* decodeOpenRouterModelsResponse(body.success).pipe(Effect.result); + if (Result.isFailure(decoded)) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter models API returned an unexpected payload.", + } satisfies OpenRouterModelFetchResult; + } + + const models = decoded.success.data + .filter((model) => model.id.trim().length > 0) + .slice(0, MAX_DISCOVERED_MODELS) + .map( + (model): ServerProviderModel => ({ + slug: model.id, + name: model.name?.trim() || model.id, + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }), + ); + + if (models.length === 0) { + return { + ok: false as const, + authFailed: false, + message: "OpenRouter returned an empty model catalog.", + } satisfies OpenRouterModelFetchResult; + } + + return { ok: true as const, models } satisfies OpenRouterModelFetchResult; + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(fetchResult)) { + return { + ok: false, + authFailed: false, + message: "Failed to reach OpenRouter models API.", + }; + } + + if (Option.isNone(fetchResult.success)) { + return { + ok: false, + authFailed: false, + message: "Timed out while fetching OpenRouter models.", + }; + } + + return fetchResult.success.value; +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts new file mode 100644 index 000000000000..80d91a0ce42c --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { OpenRouterSettings, ProviderDriverKind } from "@t3tools/contracts"; + +import { + buildOpenRouterProcessEnv, + normalizeOpenRouterBaseUrl, + openRouterModelsUrl, + toClaudeSettings, + OPENROUTER_DRIVER_KIND, + withOpenRouterAdapterIdentity, +} from "./OpenRouterRuntime.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const decodeOpenRouterSettings = Schema.decodeSync(OpenRouterSettings); + +describe("OpenRouterRuntime", () => { + it("normalizes base URL trailing slashes", () => { + expect(normalizeOpenRouterBaseUrl("https://openrouter.ai/api/")).toBe( + "https://openrouter.ai/api", + ); + expect(normalizeOpenRouterBaseUrl("")).toBe("https://openrouter.ai/api"); + }); + + it("builds the tools-capable models URL", () => { + expect(openRouterModelsUrl("https://openrouter.ai/api")).toBe( + "https://openrouter.ai/api/v1/models?supported_parameters=tools", + ); + }); + + it("maps settings into Claude settings + OpenRouter-owned process env", () => { + const settings = decodeOpenRouterSettings({ + apiKey: "sk-or-test", + baseUrl: "https://openrouter.ai/api/", + binaryPath: "claude", + httpReferer: "https://t3.chat", + appTitle: "T3 Code", + }); + + expect(toClaudeSettings(settings)).toMatchObject({ + enabled: true, + binaryPath: "claude", + homePath: "", + launchArgs: "", + }); + + const env = buildOpenRouterProcessEnv(settings, { + PATH: "/usr/bin", + ANTHROPIC_API_KEY: "host-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "host-token", + OPENROUTER_API_KEY: "host-openrouter", + OR_SITE_URL: "https://leaked.example", + OR_APP_NAME: "Leaked", + }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + // OpenRouter Claude Code contract: auth token + empty API key. + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-or-test"); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.OPENROUTER_API_KEY).toBe("sk-or-test"); + expect(env.HTTP_REFERER).toBe("https://t3.chat"); + expect(env.X_TITLE).toBe("T3 Code"); + expect(env.OR_SITE_URL).toBeUndefined(); + expect(env.OR_APP_NAME).toBeUndefined(); + expect(env.PATH).toBe("/usr/bin"); + }); + + it("clears inherited Anthropic credentials when settings apiKey is empty", () => { + const settings = decodeOpenRouterSettings({ + apiKey: "", + httpReferer: "", + appTitle: "", + }); + const env = buildOpenRouterProcessEnv(settings, { + ANTHROPIC_API_KEY: "sk-ant-host", + ANTHROPIC_AUTH_TOKEN: "host-token", + OPENROUTER_API_KEY: "sk-or-host", + HTTP_REFERER: "https://host.example", + X_TITLE: "Host App", + }); + + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(""); + expect(env.OPENROUTER_API_KEY).toBe(""); + expect(env.HTTP_REFERER).toBeUndefined(); + expect(env.X_TITLE).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); + }); +}); + +describe("withOpenRouterAdapterIdentity", () => { + it("restamps the adapter identity, events, and sessions without touching behavior", () => { + const claudeKind = ProviderDriverKind.make("claudeAgent"); + const session = { provider: claudeKind, threadId: "thread-1" }; + const event = { provider: claudeKind, type: "session.started" }; + const base = { + provider: claudeKind, + streamEvents: Stream.make(event), + startSession: () => Effect.succeed(session), + listSessions: () => Effect.succeed([session]), + stopSession: () => Effect.void, + } as unknown as ProviderAdapterShape; + + const decorated = withOpenRouterAdapterIdentity(base); + + expect(decorated.provider).toBe(OPENROUTER_DRIVER_KIND); + // Untouched members pass through by reference. + expect(decorated.stopSession).toBe(base.stopSession); + + const events = [ + ...(Effect.runSync(Stream.runCollect(decorated.streamEvents)) as Iterable<{ + provider: string; + }>), + ]; + expect(events.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + + const started = Effect.runSync( + decorated.startSession({} as never) as Effect.Effect<{ provider: string }>, + ); + expect(started.provider).toBe(OPENROUTER_DRIVER_KIND); + + const listed = Effect.runSync(decorated.listSessions()) as ReadonlyArray<{ provider: string }>; + expect(listed.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + }); +}); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts new file mode 100644 index 000000000000..eb1b295fd8e4 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -0,0 +1,140 @@ +import { + ClaudeSettings, + type OpenRouterSettings, + ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSession, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +export const OPENROUTER_DRIVER_KIND = ProviderDriverKind.make("openrouter"); +export const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api"; +export const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.5"; + +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + +/** + * Anthropic-compat credential env vars that OpenRouter owns for the Claude + * Code runtime. Always overwritten (never inherited from the host process). + */ +const OPENROUTER_OWNED_ENV_KEYS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENROUTER_API_KEY", + "HTTP_REFERER", + "X_TITLE", + // Legacy aliases that must not leak from the host into OpenRouter sessions. + "OR_SITE_URL", + "OR_APP_NAME", +] as const; + +export function normalizeOpenRouterBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + const normalized = (trimmed.length > 0 ? trimmed : DEFAULT_OPENROUTER_BASE_URL).replace( + /\/+$/, + "", + ); + return normalized; +} + +export function openRouterModelsUrl(baseUrl: string): string { + return `${normalizeOpenRouterBaseUrl(baseUrl)}/v1/models?supported_parameters=tools`; +} + +/** + * Narrow Claude runtime config needed by `makeClaudeAdapter` / text generation. + * OpenRouter does not expose Claude homePath / launchArgs in its settings. + */ +export function toClaudeSettings(settings: OpenRouterSettings): ClaudeSettings { + return decodeClaudeSettings({ + enabled: settings.enabled, + binaryPath: settings.binaryPath, + homePath: "", + customModels: settings.customModels, + launchArgs: "", + }); +} + +/** + * Build the process env for OpenRouter-backed Claude Code sessions. + * + * Matches OpenRouter's Claude Code contract: + * - `ANTHROPIC_BASE_URL` → OpenRouter Anthropic skin (`https://openrouter.ai/api`) + * - `ANTHROPIC_AUTH_TOKEN` → OpenRouter API key + * - `ANTHROPIC_API_KEY` → always `""` so Claude Code does not prefer a host Anthropic key + * + * Owned credential/attribution keys are always cleared first so host values cannot leak + * when settings omit them. + */ +export function buildOpenRouterProcessEnv( + settings: OpenRouterSettings, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...baseEnv }; + for (const key of OPENROUTER_OWNED_ENV_KEYS) { + delete next[key]; + } + + const baseUrl = normalizeOpenRouterBaseUrl(settings.baseUrl); + const apiKey = settings.apiKey.trim(); + + next.ANTHROPIC_BASE_URL = baseUrl; + // Critical: empty string (not unset) so Claude Code does not fall back to Anthropic auth. + next.ANTHROPIC_API_KEY = ""; + + if (apiKey.length > 0) { + next.ANTHROPIC_AUTH_TOKEN = apiKey; + next.OPENROUTER_API_KEY = apiKey; + } else { + next.ANTHROPIC_AUTH_TOKEN = ""; + next.OPENROUTER_API_KEY = ""; + } + + const httpReferer = settings.httpReferer.trim(); + if (httpReferer.length > 0) { + next.HTTP_REFERER = httpReferer; + } + + const appTitle = settings.appTitle.trim(); + if (appTitle.length > 0) { + next.X_TITLE = appTitle; + } + + return next; +} + +/** + * Re-stamp a Claude-runtime adapter with the OpenRouter driver identity. + * + * The Claude adapter hardcodes provider "claudeAgent" on its sessions and + * runtime events. Rather than parameterizing that heavily-churned module (a + * merge-conflict magnet), OpenRouter decorates the finished adapter: the + * identity field and every outbound event/session carry "openrouter", while + * behavior passes through untouched. v2 note: OrchestratorV2 threads + * instance identity through its own registry, so this decorator retires with + * the V1 adapter contract. + */ +export function withOpenRouterAdapterIdentity( + adapter: ProviderAdapterShape, +): ProviderAdapterShape { + const restampSession = (session: ProviderSession): ProviderSession => ({ + ...session, + provider: OPENROUTER_DRIVER_KIND, + }); + return { + ...adapter, + provider: OPENROUTER_DRIVER_KIND, + streamEvents: Stream.map(adapter.streamEvents, (event) => ({ + ...event, + provider: OPENROUTER_DRIVER_KIND, + })), + startSession: (input) => Effect.map(adapter.startSession(input), restampSession), + listSessions: () => + Effect.map(adapter.listSessions(), (sessions) => sessions.map(restampSession)), + }; +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..159073980de2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -696,3 +696,26 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +export const OpenRouterIcon: Icon = ({ className, ...props }) => ( + + + + + + +); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..92dfcdd67906 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, + OpenRouterIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("openrouter")]: OpenRouterIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..ea114da69a26 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -4,10 +4,19 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, + OpenRouterSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, + OpenRouterIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("openrouter"), + label: "OpenRouter", + icon: OpenRouterIcon, + settingsSchema: OpenRouterSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..98eef0e900f3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -812,7 +812,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "openrouter", "opencode"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -971,7 +971,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "openrouter", "opencode"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2773,7 +2773,13 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "openrouter", + "opencode", + ] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..4ce9f7f63aa3 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -34,6 +34,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Claude"; case "codex": return "Codex"; + case "openrouter": + return "OpenRouter"; case "cursor": return "Cursor"; case "opencode": diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1c5815baac8..4e07a1e91f71 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,6 +35,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, + { value: ProviderDriverKind.make("openrouter"), label: "OpenRouter", available: true }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..bdc56f2de8f7 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OPENROUTER_DRIVER_KIND = ProviderDriverKind.make("openrouter"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OPENROUTER_DRIVER_KIND]: "OpenRouter", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9e73b1fdc02d..466f35f31005 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -500,6 +500,75 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const OpenRouterSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiKey: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API key", + description: "OpenRouter API key. Stored in plain text on disk.", + providerSettingsForm: { + control: "password", + placeholder: "sk-or-...", + clearWhenEmpty: "omit", + }, + }), + ), + baseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("https://openrouter.ai/api")), + Schema.annotateKey({ + title: "Base URL", + description: "OpenRouter API base URL (Anthropic-compatible).", + providerSettingsForm: { + placeholder: "https://openrouter.ai/api", + clearWhenEmpty: "omit", + }, + }), + ), + binaryPath: makeBinaryPathSetting("claude").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Claude Agent CLI used as the OpenRouter runtime.", + providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" }, + }), + ), + httpReferer: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "HTTP referer", + description: "Optional site URL sent as HTTP-Referer for OpenRouter rankings.", + providerSettingsForm: { + placeholder: "https://your-app.example", + clearWhenEmpty: "omit", + }, + }), + ), + appTitle: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("T3 Turbo")), + Schema.annotateKey({ + title: "App title", + description: "Optional app name sent as X-Title for OpenRouter rankings.", + providerSettingsForm: { + placeholder: "T3 Turbo", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["apiKey", "baseUrl", "binaryPath", "httpReferer", "appTitle"], + }, +); +export type OpenRouterSettings = typeof OpenRouterSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -679,6 +748,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + openrouter: OpenRouterSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -775,6 +845,16 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OpenRouterSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + apiKey: Schema.optionalKey(TrimmedString), + baseUrl: Schema.optionalKey(TrimmedString), + binaryPath: Schema.optionalKey(TrimmedString), + httpReferer: Schema.optionalKey(TrimmedString), + appTitle: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -822,6 +902,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + openrouter: Schema.optionalKey(OpenRouterSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), From f4ec6d8f0db2170bd2a32362871abf7fc04ad5a9 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 17 Aug 2026 23:32:32 -0400 Subject: [PATCH 2/6] fix(openrouter): address CodeRabbit review findings - Trim and dedupe model slugs; keep the default model in the truncated catalog - Convert the adapter-identity test to it.effect (no manual Effect runtime) - Remove unused ProviderRuntimeEvent import - Wire attribution through ANTHROPIC_CUSTOM_HEADERS (Claude Code ignores HTTP_REFERER/X_TITLE env vars) and own that key in the env scrub - Correct the SEAM.md web-wiring inventory to the six actual files Co-Authored-By: Claude Fable 5 --- SEAM.md | 11 +++- .../provider/openrouter/OpenRouterModels.ts | 40 ++++++++---- .../openrouter/OpenRouterRuntime.test.ts | 64 ++++++++++--------- .../provider/openrouter/OpenRouterRuntime.ts | 17 ++++- 4 files changed, 88 insertions(+), 44 deletions(-) diff --git a/SEAM.md b/SEAM.md index 7c0758801985..6fff922609a3 100644 --- a/SEAM.md +++ b/SEAM.md @@ -399,8 +399,13 @@ Built to survive orchestrator v2 (pingdotgg/t3code#2829): base-URL normalization, model catalog fetch with fallbacks, the Claude-settings bridge, and the `withOpenRouterAdapterIdentity` decorator. No V1 adapter imports besides the shape type. - **Additive, v2-safe** `Layers/OpenRouterProvider.ts` — status snapshot (CLI probe + API-key - validation via the catalog), contracts (`OpenRouterSettings`, driver-kind maps in `model.ts`), - and the web wiring (icon, driver meta, picker option, composer keys, context-window name). + validation via the catalog). +- **Additive, v2-safe** contracts — `OpenRouterSettings` in `packages/contracts/src/settings.ts` + and the driver-kind default/display maps in `packages/contracts/src/model.ts`. +- **Additive, v2-safe** web wiring (six files) — `components/Icons.tsx` (OpenRouterIcon), + `components/chat/providerIconUtils.ts`, `components/settings/providerDriverMeta.ts`, + `session-logic.ts` (picker option), `composerDraftStore.ts` (provider option keys), and + `lib/contextWindow.ts` (display name). - **Additive, V1-shim (retires at v2 cutover)** `Drivers/OpenRouterDriver.ts` — the `ProviderDriver` registration. v2's `ClaudeAdapterV2` already imports the same `makeClaudeEnvironment`/`mergeProviderInstanceEnvironment` plumbing and accepts per-instance @@ -410,7 +415,7 @@ Built to survive orchestrator v2 (pingdotgg/t3code#2829): driver identity on events and sessions, keeping the churn-heavy file merge-clean. On a nightly-sync conflict: everything here is additive except `builtInDrivers.ts`, -`settings.ts`/`model.ts` map entries, and the four web wiring points — re-add the fork lines +`settings.ts`/`model.ts` map entries, and the six web wiring files above — re-add the fork lines after upstream's. If upstream ships its own OpenRouter or the ACP registry (#6071) covers it, prefer upstream and retire the shim first. diff --git a/apps/server/src/provider/openrouter/OpenRouterModels.ts b/apps/server/src/provider/openrouter/OpenRouterModels.ts index ecb8e4b9d103..4ef293667a79 100644 --- a/apps/server/src/provider/openrouter/OpenRouterModels.ts +++ b/apps/server/src/provider/openrouter/OpenRouterModels.ts @@ -119,17 +119,35 @@ export const fetchOpenRouterModels = Effect.fn("fetchOpenRouterModels")(function } satisfies OpenRouterModelFetchResult; } - const models = decoded.success.data - .filter((model) => model.id.trim().length > 0) - .slice(0, MAX_DISCOVERED_MODELS) - .map( - (model): ServerProviderModel => ({ - slug: model.id, - name: model.name?.trim() || model.id, - isCustom: false, - capabilities: EMPTY_OPENROUTER_CAPABILITIES, - }), - ); + // Trim ids before they become slugs (the contract is TrimmedNonEmptyString) + // and dedupe — OpenRouter has repeated ids across routing variants. + const seenSlugs = new Set(); + const catalog: Array = []; + for (const model of decoded.success.data) { + const slug = model.id.trim(); + if (slug.length === 0 || seenSlugs.has(slug)) { + continue; + } + seenSlugs.add(slug); + catalog.push({ + slug, + name: model.name?.trim() || slug, + isCustom: false, + capabilities: EMPTY_OPENROUTER_CAPABILITIES, + }); + } + // Truncate for the picker, but never truncate away the configured + // default: DEFAULT_MODEL_BY_PROVIDER points at it regardless of where + // the API ordered it. + const models = catalog.slice(0, MAX_DISCOVERED_MODELS); + if (!models.some((model) => model.slug === DEFAULT_OPENROUTER_MODEL)) { + const defaultEntry = + catalog.find((model) => model.slug === DEFAULT_OPENROUTER_MODEL) ?? + FALLBACK_OPENROUTER_MODELS.find((model) => model.slug === DEFAULT_OPENROUTER_MODEL); + if (defaultEntry) { + models.unshift(defaultEntry); + } + } if (models.length === 0) { return { diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts index 80d91a0ce42c..efb76df1b0ec 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -61,6 +61,8 @@ describe("OpenRouterRuntime", () => { expect(env.OPENROUTER_API_KEY).toBe("sk-or-test"); expect(env.HTTP_REFERER).toBe("https://t3.chat"); expect(env.X_TITLE).toBe("T3 Code"); + // Claude Code only forwards headers through ANTHROPIC_CUSTOM_HEADERS. + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe("HTTP-Referer: https://t3.chat\nX-Title: T3 Code"); expect(env.OR_SITE_URL).toBeUndefined(); expect(env.OR_APP_NAME).toBeUndefined(); expect(env.PATH).toBe("/usr/bin"); @@ -75,6 +77,7 @@ describe("OpenRouterRuntime", () => { const env = buildOpenRouterProcessEnv(settings, { ANTHROPIC_API_KEY: "sk-ant-host", ANTHROPIC_AUTH_TOKEN: "host-token", + ANTHROPIC_CUSTOM_HEADERS: "X-Host: leaked", OPENROUTER_API_KEY: "sk-or-host", HTTP_REFERER: "https://host.example", X_TITLE: "Host App", @@ -85,42 +88,45 @@ describe("OpenRouterRuntime", () => { expect(env.OPENROUTER_API_KEY).toBe(""); expect(env.HTTP_REFERER).toBeUndefined(); expect(env.X_TITLE).toBeUndefined(); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined(); expect(env.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api"); }); }); describe("withOpenRouterAdapterIdentity", () => { - it("restamps the adapter identity, events, and sessions without touching behavior", () => { - const claudeKind = ProviderDriverKind.make("claudeAgent"); - const session = { provider: claudeKind, threadId: "thread-1" }; - const event = { provider: claudeKind, type: "session.started" }; - const base = { - provider: claudeKind, - streamEvents: Stream.make(event), - startSession: () => Effect.succeed(session), - listSessions: () => Effect.succeed([session]), - stopSession: () => Effect.void, - } as unknown as ProviderAdapterShape; + it.effect("restamps the adapter identity, events, and sessions without touching behavior", () => + Effect.gen(function* () { + const claudeKind = ProviderDriverKind.make("claudeAgent"); + const session = { provider: claudeKind, threadId: "thread-1" }; + const event = { provider: claudeKind, type: "session.started" }; + const base = { + provider: claudeKind, + streamEvents: Stream.make(event), + startSession: () => Effect.succeed(session), + listSessions: () => Effect.succeed([session]), + stopSession: () => Effect.void, + } as unknown as ProviderAdapterShape; - const decorated = withOpenRouterAdapterIdentity(base); + const decorated = withOpenRouterAdapterIdentity(base); - expect(decorated.provider).toBe(OPENROUTER_DRIVER_KIND); - // Untouched members pass through by reference. - expect(decorated.stopSession).toBe(base.stopSession); + expect(decorated.provider).toBe(OPENROUTER_DRIVER_KIND); + // Untouched members pass through by reference. + expect(decorated.stopSession).toBe(base.stopSession); - const events = [ - ...(Effect.runSync(Stream.runCollect(decorated.streamEvents)) as Iterable<{ - provider: string; - }>), - ]; - expect(events.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + const events = [ + ...((yield* Stream.runCollect(decorated.streamEvents)) as Iterable<{ + provider: string; + }>), + ]; + expect(events.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); - const started = Effect.runSync( - decorated.startSession({} as never) as Effect.Effect<{ provider: string }>, - ); - expect(started.provider).toBe(OPENROUTER_DRIVER_KIND); + const started = yield* decorated.startSession({} as never) as Effect.Effect<{ + provider: string; + }>; + expect(started.provider).toBe(OPENROUTER_DRIVER_KIND); - const listed = Effect.runSync(decorated.listSessions()) as ReadonlyArray<{ provider: string }>; - expect(listed.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); - }); + const listed = (yield* decorated.listSessions()) as ReadonlyArray<{ provider: string }>; + expect(listed.map((entry) => entry.provider)).toEqual([OPENROUTER_DRIVER_KIND]); + }), + ); }); diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts index eb1b295fd8e4..30ffba715e68 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -2,7 +2,6 @@ import { ClaudeSettings, type OpenRouterSettings, ProviderDriverKind, - type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -25,6 +24,7 @@ const OPENROUTER_OWNED_ENV_KEYS = [ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_CUSTOM_HEADERS", "OPENROUTER_API_KEY", "HTTP_REFERER", "X_TITLE", @@ -105,6 +105,21 @@ export function buildOpenRouterProcessEnv( next.X_TITLE = appTitle; } + // Claude Code does not read HTTP_REFERER/X_TITLE — it only forwards extra + // request headers through ANTHROPIC_CUSTOM_HEADERS (newline-separated + // "Name: value" pairs). The plain vars above stay for other OpenRouter + // tooling that does read them. + const customHeaders: Array = []; + if (httpReferer.length > 0) { + customHeaders.push(`HTTP-Referer: ${httpReferer}`); + } + if (appTitle.length > 0) { + customHeaders.push(`X-Title: ${appTitle}`); + } + if (customHeaders.length > 0) { + next.ANTHROPIC_CUSTOM_HEADERS = customHeaders.join("\n"); + } + return next; } From b6e0611e884f3703fa4c0c71c2131ba4df26fc73 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Mon, 17 Aug 2026 23:36:14 -0400 Subject: [PATCH 3/6] test(turbo): track the openrouter-first-party seam in the manifest expectation Co-Authored-By: Claude Fable 5 --- scripts/turbo-customization-manifest.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/turbo-customization-manifest.test.ts b/scripts/turbo-customization-manifest.test.ts index 06e74274a4de..e4e843a89d2b 100644 --- a/scripts/turbo-customization-manifest.test.ts +++ b/scripts/turbo-customization-manifest.test.ts @@ -152,6 +152,7 @@ it("verifies the checked-in Turbo manifest and tracks the implemented multi-chat "multi-chat-pane-workspace", "nightly-and-secret-policy", "official-data-import", + "openrouter-first-party", "pooled-subscription-frame", "product-identity-and-updater", "relay-apns-off-publish-skip", From 110d9912638cb16fd9f1d6a328e220f62597e5fd Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 18 Aug 2026 07:54:08 -0400 Subject: [PATCH 4/6] perf(openrouter): skip the Claude CLI probe when no API key is set OpenRouter ships enabled so it shows up in provider settings, which meant every startup spawned an extra `claude --version` for installs that never configured it. Without a key the provider cannot start a session anyway, so report 'add an API key' directly and pay no spawn. Co-Authored-By: Claude Fable 5 --- .../Layers/OpenRouterProvider.test.ts | 20 +++++++++-- .../src/provider/Layers/OpenRouterProvider.ts | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts index 4166e4f550eb..9d8d591d86a0 100644 --- a/apps/server/src/provider/Layers/OpenRouterProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenRouterProvider.test.ts @@ -50,14 +50,25 @@ describe("makePendingOpenRouterProvider", () => { }), ); - it.effect("builds a pending snapshot for enabled OpenRouter", () => + it.effect("builds a pending snapshot for a configured OpenRouter", () => Effect.gen(function* () { - const snapshot = yield* makePendingOpenRouterProvider(decodeOpenRouterSettings({})); + const snapshot = yield* makePendingOpenRouterProvider( + decodeOpenRouterSettings({ apiKey: "sk-or-test" }), + ); expect(snapshot.enabled).toBe(true); expect(snapshot.status).toBe("warning"); expect(snapshot.message).toMatch(/checking openrouter/i); }), ); + + it.effect("asks for a key instead of pending when none is configured", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenRouterProvider(decodeOpenRouterSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); + }), + ); }); it.layer(NodeServices.layer.pipe(Layer.provideMerge(EmptyModelsHttpClientLive)))( @@ -158,7 +169,10 @@ it.layer(NodeServices.layer.pipe(Layer.provideMerge(ValidModelsHttpClientLive))) }), ); - expect(snapshot.installed).toBe(true); + // No key means no usable provider, so the CLI probe is skipped + // entirely and `installed` stays false — startup pays nothing for + // an OpenRouter nobody configured. + expect(snapshot.installed).toBe(false); expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unauthenticated"); expect(snapshot.message).toMatch(/Add an OpenRouter API key/i); diff --git a/apps/server/src/provider/Layers/OpenRouterProvider.ts b/apps/server/src/provider/Layers/OpenRouterProvider.ts index aed0c0be85a8..1a0332966294 100644 --- a/apps/server/src/provider/Layers/OpenRouterProvider.ts +++ b/apps/server/src/provider/Layers/OpenRouterProvider.ts @@ -158,6 +158,25 @@ export const checkOpenRouterProviderStatus = Effect.fn("checkOpenRouterProviderS }); } + // OpenRouter ships enabled so it appears in settings, but it cannot do + // anything without a key. Short-circuit before the CLI probe so the common + // "never configured OpenRouter" install pays no startup spawn for it. + if (settings.apiKey.trim().length === 0) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Add an OpenRouter API key in provider settings.", + }, + }); + } + const processEnv = environment ?? buildOpenRouterProcessEnv(settings); const cliFields = yield* probeClaudeCliForOpenRouter(settings, processEnv); const modelFetch = yield* fetchOpenRouterModels(settings); @@ -241,6 +260,22 @@ export const makePendingOpenRouterProvider = ( }); } + if (settings.apiKey.trim().length === 0) { + return buildServerProvider({ + presentation: OPENROUTER_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Add an OpenRouter API key in provider settings.", + }, + }); + } + return buildServerProvider({ presentation: OPENROUTER_PRESENTATION, enabled: true, From 2fa748842f5f10f83970c3cefaddd48d749cbece Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 18 Aug 2026 07:58:20 -0400 Subject: [PATCH 5/6] fix(release): build fork releases from turbo instead of main Scheduled and dispatched releases resolved from the default branch, which on this fork is main and only tracks upstream. Every installer the fork published was upstream code at upstream's version, with none of the fork's work in it, and the finalize job pushed the version bump to main as well. Non-tag runs now resolve to turbo, preflight pins the whole run to one commit, and finalize commits the bump to turbo. Tag pushes still build the pushed tag, and both switches are guarded on the fork's repository so upstream behavior is unchanged. Registered as the release-from-turbo-branch seam. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 29 +- .t3-turbo/customizations.json | 1220 +++++++++--------- SEAM.md | 18 + scripts/turbo-customization-manifest.test.ts | 1 + 4 files changed, 662 insertions(+), 606 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 892fb06e79ef..fb97bd2e79ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,17 @@ permissions: contents: read id-token: none +# T3 Turbo: the fork's product mainline is `turbo`; `main` only tracks upstream. +# Scheduled and dispatched releases resolve from the default branch, so every +# published fork installer was built from upstream code with none of the fork's +# work in it (chat panes, OpenRouter, relay changes) and carried upstream's +# version instead of the fork's. Non-tag releases here build and finalize from +# `turbo`. Tag pushes still build exactly the pushed tag, and the repository +# condition keeps upstream's behavior unchanged. +env: + TURBO_RELEASE_BRANCH: ${{ github.repository == 'gfsaaser24/t3code' && 'turbo' || 'main' }} + TURBO_RELEASE_REF: ${{ (github.event_name != 'push' && github.repository == 'gfsaaser24/t3code') && 'turbo' || github.sha }} + jobs: check_changes: name: Check for changes since last nightly @@ -37,6 +48,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: + ref: ${{ env.TURBO_RELEASE_REF }} fetch-depth: 0 sparse-checkout: | /* @@ -57,10 +69,10 @@ jobs: head_sha=$(git rev-parse HEAD) if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." + echo "No changes on ${TURBO_RELEASE_REF} since last nightly release ($last_nightly_tag). Skipping." echo "has_changes=false" >> "$GITHUB_OUTPUT" else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." + echo "Changes detected on ${TURBO_RELEASE_REF} since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." echo "has_changes=true" >> "$GITHUB_OUTPUT" fi @@ -82,18 +94,25 @@ jobs: cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ github.sha }} + ref: ${{ steps.release_ref.outputs.sha }} connect_enabled: ${{ steps.connect_config.outputs.enabled }} steps: - name: Checkout uses: actions/checkout@v6 with: + ref: ${{ env.TURBO_RELEASE_REF }} fetch-depth: 0 sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false + # Every build/release job checks out this exact commit, so the whole run + # is pinned to one tree even if `turbo` moves mid-release. + - id: release_ref + name: Resolve release commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: @@ -964,7 +983,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: main + ref: ${{ env.TURBO_RELEASE_BRANCH }} fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true @@ -1023,7 +1042,7 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main + git push origin "HEAD:${TURBO_RELEASE_BRANCH}" announce_discord: name: Announce release on Discord diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 5869dbee75b1..1c94a6ab5fdb 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -16,7 +16,7 @@ { "path": "AGENTS.md", "markers": [ - "T3 Turbo \u2014 how this branch operates", + "T3 Turbo — how this branch operates", "write anything to `pingdotgg/t3code`", "Turbo changes always survive ingestion", "Turbo state is isolated from the official T3 install" @@ -32,60 +32,6 @@ } ] }, - { - "id": "changelog-and-runbook", - "status": "implemented", - "summary": "The fork keeps its own changelog and operator runbook under docs/operations.", - "checks": [ - { - "path": "docs/operations/turbo-changelog.md", - "markers": ["# T3 Turbo changelog", "entry here in the same PR"] - }, - { - "path": "docs/operations/turbo-runbook.md", - "markers": [ - "# T3 Turbo operator runbook", - "Registering a new customization (checklist)", - "turbo:customizations:verify" - ] - } - ] - }, - { - "id": "product-identity-and-updater", - "status": "implemented", - "summary": "T3 Turbo has an isolated desktop identity, state home, and fork-owned update feed.", - "checks": [ - { - "path": "apps/desktop/src/app/DesktopEnvironment.ts", - "markers": [ - "const APP_BASE_NAME = \"T3 Turbo\";", - "const APP_RELEASE_REPOSITORY = \"gfsaaser24/t3code\";", - "\"com.gabef.t3turbo\"" - ] - }, - { - "path": "apps/desktop/src/app/DesktopStatePaths.ts", - "markers": ["input.joinPath(input.homeDirectory, \".t3-turbo\")"] - }, - { - "path": "apps/desktop/package.json", - "markers": ["\"productName\": \"T3 Turbo\""] - }, - { - "path": "scripts/build-desktop-artifact.ts", - "markers": ["T3CODE_DESKTOP_UPDATE_REPOSITORY", "pingdotgg/t3code", "--publish never"] - }, - { - "path": "apps/desktop/src/app/DesktopEnvironment.test.ts", - "markers": ["T3 Turbo", "gfsaaser24/t3code"] - }, - { - "path": ".github/workflows/release.yml", - "markers": ["name=T3 Turbo v$version"] - } - ] - }, { "id": "canonical-icon-pipeline", "status": "implemented", @@ -165,68 +111,197 @@ ] }, { - "id": "file-explorer", + "id": "changelog-and-runbook", "status": "implemented", - "summary": "Explorer navigation keeps file actions, reveal behavior, and Alt-click bulk folder expansion.", + "summary": "The fork keeps its own changelog and operator runbook under docs/operations.", "checks": [ { - "path": "apps/web/src/components/files/FileBrowserPanel.tsx", + "path": "docs/operations/turbo-changelog.md", + "markers": ["# T3 Turbo changelog", "entry here in the same PR"] + }, + { + "path": "docs/operations/turbo-runbook.md", "markers": [ - "fileTreeContextMenuItems", - "getAltChevronExpansion", - "setAllDirectoriesExpanded", - "if (clicked === \"open-new-tab\")" + "# T3 Turbo operator runbook", + "Registering a new customization (checklist)", + "turbo:customizations:verify" + ] + } + ] + }, + { + "id": "cheap-message-unpacking", + "status": "implemented", + "summary": "TrimmedString trims through the pure both-directions transform instead of allocating an Effect per value, and ForwardCompatibleArray decodes each element once instead of twice while keeping per-element drop-on-failure and a debug log.", + "checks": [ + { + "path": "packages/contracts/src/baseSchemas.ts", + "markers": [ + "SchemaTransformation.transform({", + "decode: (value) => value.trim(),", + "encode: (value) => value.trim(),", + "Schema.toType(Schema.Array(element))", + "Effect.logDebug(\"ForwardCompatibleArray dropped undecodable elements\"", + "new SchemaIssue.Pointer([index], error.issue)" ] }, { - "path": "apps/web/src/components/files/fileTreeContextMenu.ts", - "markers": ["\"open-new-tab\"", "\"rename\"", "\"duplicate\"", "\"delete\""] + "path": "packages/contracts/src/turbo/baseSchemas.test.ts", + "markers": [ + "trims on the encode-without-decode path too", + "drops elements this build cannot decode and keeps the rest", + "names the failing element's index when encoding fails", + "decodes each element exactly once" + ] }, { - "path": "apps/web/src/components/files/fileTreeBulkExpansion.ts", + "path": "SEAM.md", + "markers": ["**Tuned** `packages/contracts/src/baseSchemas.ts`"] + } + ] + }, + { + "id": "cheap-timestamp-and-sort-keys", + "status": "implemented", + "summary": "Product timestamps compare as plain fixed-width ISO strings instead of through the ICU collator, and the sidebar bucket sorts plus the keyless pinned block resolve each row's sort key once instead of per comparison.", + "checks": [ + { + "path": "apps/web/src/session-logic.ts", "markers": [ - "export function getAltChevronExpansion", - "export function setAllDirectoriesExpanded" + "import { compareIsoTimestamps } from \"@t3tools/client-runtime/state/thread-activity-order\"", + "compareIsoTimestamps(left.createdAt, right.createdAt)", + "compareIsoTimestamps(left.updatedAt, right.updatedAt)", + "compareIsoTimestamps(a.createdAt, b.createdAt)", + "compareIsoTimestamps(a.completedAt, b.completedAt)" ] }, { - "path": "apps/web/src/components/files/fileTreeContextMenu.test.ts", - "markers": ["fileTreeContextMenuItems"] + "path": "apps/web/src/components/Sidebar.logic.ts", + "markers": [ + "createdAtMs: parseTimestampMs(thread.createdAt)", + "right.createdAtMs - left.createdAtMs", + "settledAtMs: timestamp === null ? 0 : parseTimestampMs(timestamp)", + "right.settledAtMs - left.settledAtMs", + "export function sortSnoozedThreadsForSidebar", + "wakeAtMs: firstValidTimestampMs(thread.snoozedUntil ?? null)" + ] }, { - "path": "apps/web/src/components/files/fileTreeBulkExpansion.test.ts", - "markers": ["getAltChevronExpansion", "setAllDirectoriesExpanded"] + "path": "apps/web/src/components/Sidebar.tsx", + "markers": ["snoozedThreads: sortSnoozedThreadsForSidebar(snoozed)"] + }, + { + "path": "packages/client-runtime/src/state/threadSort.ts", + "markers": [ + "const leftCreatedAt = left.createdAt;", + "leftCreatedAt > rightCreatedAt", + "function isCanonicalIsoTimestamp(value: string): boolean", + "isCanonicalIsoTimestamp(leftCreatedAt) && isCanonicalIsoTimestamp(rightCreatedAt)" + ] + }, + { + "path": "packages/client-runtime/src/state/threadSortPinnedKeyless.test.ts", + "markers": [ + "legacySortPinnedThreadsByOrderKey", + "emits the pre-swap order for product-minted timestamps, ties included", + "emits the pre-swap order for non-canonical and malformed stamps too" + ] + }, + { + "path": "apps/web/src/turbo/sortOrderEquivalence.test.ts", + "markers": [ + "orders every pair of product-minted timestamps exactly as localeCompare did", + "emits the pre-decorate order, ties included", + "sortSnoozedThreadsForSidebar (snoozed shelf)" + ] + }, + { + "path": "SEAM.md", + "markers": [ + "**Tuned** `apps/web/src/session-logic.ts`", + "**Tuned** `apps/web/src/components/Sidebar.logic.ts`", + "**Tuned** `packages/client-runtime/src/state/threadSort.ts`" + ] } ] }, { - "id": "workspace-image-preview", + "id": "deferred-streaming-code-blocks", "status": "implemented", - "summary": "Workspace image types open in editor tabs and render through the shared image-preview classifier.", + "summary": "Streaming code fences render as a row-capped, height-reserving placeholder inside the chat code-block frame and are highlighted exactly once when the message completes; the line count is incremental across deltas, the animation is the repo's duty-cycled skeleton sweep, and a fence that never grows is treated as history whose persisted streaming flag predates the reducer fix.", "checks": [ { - "path": "packages/shared/src/filePreview.ts", + "path": "apps/web/src/turbo/streamingCodeBlock.tsx", "markers": [ - "WORKSPACE_IMAGE_PREVIEW_EXTENSIONS", - "\".ico\"", - "export function isWorkspaceImagePreviewPath" + "export const STREAMING_CODE_FIRST_DELTA_MS = 500;", + "export const STREAMING_CODE_MAX_PLACEHOLDER_ROWS = 24;", + "if (input.stall === \"never-started\") return \"highlighted\";", + "export function countStreamingCodeLines(code: string): number", + "export function advanceStreamingCodeLineCount(", + "export function resolveStreamingCodeBlockView(input: {", + "export function useStreamingCodeStall(code: string, isStreaming: boolean): StreamingCodeStall", + "export function StreamingCodeBlockFrame({", + "data-streaming-code-placeholder", + "data-streaming-code-spacer", + "import { Skeleton } from \"~/components/ui/skeleton\";" ] }, { - "path": "apps/web/src/components/files/FilePreviewPanel.tsx", - "markers": ["function WorkspaceImagePreview", "isWorkspaceImagePreviewPath(relativePath)"] + "path": "apps/web/src/components/ChatMarkdown.tsx", + "markers": [ + "import { StreamingCodeBlockFrame } from \"../turbo/streamingCodeBlock\";", + ""] + "path": "apps/web/src/turbo/streamingCodeBlock.test.tsx", + "markers": [ + "grows one placeholder line per accumulated code line", + "caps the rendered rows and reserves the rest with one spacer", + "uses the duty-cycled skeleton sweep, not a per-frame pulse", + "agrees with the full scan at every prefix of a streamed fence", + "colours a fence that never grew, even though the message still claims to stream" + ] }, { - "path": "packages/shared/src/filePreview.test.ts", - "markers": ["isWorkspaceImagePreviewPath"] + "path": "SEAM.md", + "markers": ["**Tuned** `apps/web/src/components/ChatMarkdown.tsx`"] + } + ] + }, + { + "id": "file-explorer", + "status": "implemented", + "summary": "Explorer navigation keeps file actions, reveal behavior, and Alt-click bulk folder expansion.", + "checks": [ + { + "path": "apps/web/src/components/files/FileBrowserPanel.tsx", + "markers": [ + "fileTreeContextMenuItems", + "getAltChevronExpansion", + "setAllDirectoriesExpanded", + "if (clicked === \"open-new-tab\")" + ] }, { - "path": "apps/web/src/rightPanelStore.test.ts", - "markers": ["opens image files as reusable peer tabs"] + "path": "apps/web/src/components/files/fileTreeContextMenu.ts", + "markers": ["\"open-new-tab\"", "\"rename\"", "\"duplicate\"", "\"delete\""] + }, + { + "path": "apps/web/src/components/files/fileTreeBulkExpansion.ts", + "markers": [ + "export function getAltChevronExpansion", + "export function setAllDirectoriesExpanded" + ] + }, + { + "path": "apps/web/src/components/files/fileTreeContextMenu.test.ts", + "markers": ["fileTreeContextMenuItems"] + }, + { + "path": "apps/web/src/components/files/fileTreeBulkExpansion.test.ts", + "markers": ["getAltChevronExpansion", "setAllDirectoriesExpanded"] } ] }, @@ -311,113 +386,16 @@ ] }, { - "id": "official-data-import", + "id": "multi-chat-pane-workspace", "status": "implemented", - "summary": "Dependency-light import planning, identity remapping, staged storage, restore, and projection replay modules remain recoverable.", + "summary": "Typed, persisted chat-pane layouts keep pane controls and resource ownership in a replaceable Turbo seam.", "checks": [ { - "path": "apps/server/src/turbo/officialImport/plan.ts", + "path": "packages/contracts/src/settings.ts", "markers": [ - "Schema.Literals([\"skip\", \"replace\", \"clone\"])", - "export const OfficialImportIdMap", - "export const planOfficialImport", - "export const validateOfficialImportPlan" - ] - }, - { - "path": "apps/server/src/turbo/officialImport/replay.ts", - "markers": [ - "OfficialImportProjectionVerificationError", - "rebuildOfficialImportProjections" - ] - }, - { - "path": "apps/server/src/turbo/officialImport/storage.ts", - "markers": ["prepareImportWorkspace", "cutoverImport", "restoreImportBackup"] - }, - { - "path": "apps/server/src/cli/officialImport.ts", - "markers": [ - "export const officialImportCommand", - "prepareOfficialImport", - "applyPreparedOfficialImport" - ] - }, - { - "path": "apps/server/src/bin.ts", - "markers": ["import { officialImportCommand }", "officialImportCommand,"] - }, - { - "path": "packages/contracts/src/ipc.ts", - "markers": [ - "DesktopOfficialT3ImportInputSchema", - "DesktopOfficialT3ImportResultSchema", - "discoverOfficialT3Import?:", - "runOfficialT3Import?:" - ] - }, - { - "path": "apps/desktop/src/ipc/channels.ts", - "markers": ["DISCOVER_OFFICIAL_T3_IMPORT_CHANNEL", "RUN_OFFICIAL_T3_IMPORT_CHANNEL"] - }, - { - "path": "apps/desktop/src/ipc/DesktopIpcHandlers.ts", - "markers": [ - "yield* ipc.handle(discoverOfficialT3Import)", - "yield* ipc.handle(runOfficialT3Import)" - ] - }, - { - "path": "apps/desktop/src/preload.ts", - "markers": ["discoverOfficialT3Import: () =>", "runOfficialT3Import: (input) =>"] - }, - { - "path": "apps/server/src/turbo/officialImport/plan.test.ts", - "markers": ["official import clone identity graph", "validateOfficialImportPlan"] - }, - { - "path": "apps/server/src/turbo/officialImport/storage.test.ts", - "markers": ["appendCanonicalEvents", "restoreImportBackup"] - }, - { - "path": "apps/desktop/src/ipc/methods/officialT3Environment.ts", - "markers": [ - "const executeImport = Effect.fn", - "export const runOfficialT3Import", - "yield* primary.stop();", - "snapshot.desiredRunning ? primary.start : Effect.void", - "Official T3 Code still has an active chat, turn, or approval" - ] - }, - { - "path": "apps/web/src/components/desktop/DesktopEnvironmentSwitcher.tsx", - "markers": ["bridge?.runOfficialT3Import", "Import official T3 Code"] - }, - { - "path": "docs/user/official-t3-import.md", - "markers": ["t3 import official", "Keep both", "Relay and remote clients"] - }, - { - "path": ".plans/22-t3-turbo-official-data-import.md", - "markers": [ - "# T3 Turbo One-Way Official Data Import", - "Keep Turbo and skip official", - "Keep both; import official with a new UUID" - ] - } - ] - }, - { - "id": "multi-chat-pane-workspace", - "status": "implemented", - "summary": "Typed, persisted chat-pane layouts keep pane controls and resource ownership in a replaceable Turbo seam.", - "checks": [ - { - "path": "packages/contracts/src/settings.ts", - "markers": [ - "export const TurboChatPaneLayout", - "export const TurboChatPaneWeight", - "turboChatPaneLayout: CompatibleTurboChatPaneLayout" + "export const TurboChatPaneLayout", + "export const TurboChatPaneWeight", + "turboChatPaneLayout: CompatibleTurboChatPaneLayout" ] }, { @@ -525,308 +503,303 @@ ] }, { - "id": "terminal-scrollback-batching", - "status": "implemented", - "summary": "A terminal session keeps its scrollback as an incremental line buffer with a ~16 ms output batch instead of a string chopped and re-glued per PTY chunk; the debounce runs on a per-session fiber (never inside the shared worker, which would serialize every session's batch interval and every drain behind one another) and is enqueued once per burst rather than once per chunk, the batch is a keyed coalescing worker that flushPersist enqueues into and drains before the persist worker, every scrollback read flushes it so the string handed to clients stays byte-identical, and a dirtySincePersist flag (not the per-flush result) decides whether a write is still owed so a racing read cannot strand the tail.", + "id": "nightly-and-secret-policy", + "status": "policy", + "summary": "Daily 11 PM Eastern ingestion preserves the last known-good Turbo stack, publishes only to the fork, and excludes the local secrets note.", "checks": [ { - "path": "apps/server/src/turbo/terminalHistoryBuffer.ts", + "path": ".gitignore", + "markers": ["/SECRETS DO NOT COMMIT.md"] + }, + { + "path": ".t3-turbo/OPENCLAW_RULES.md", "markers": [ - "export function queueTerminalHistoryChunk", - "export function flushTerminalHistoryBuffer", - "export function endTerminalHistoryStream", - "export function readTerminalHistoryBuffer", - "export function takeTerminalHistoryToPersist", - "dirtySincePersist" + "ingestion starts with the last known-good T3 Turbo branch", + "Never resolve a collision with a blanket `ours`, `theirs`, force push, clean checkout", + "Never bake credentials, tokens, secrets", + "Do not publish T3 Turbo to NPM" ] }, { - "path": "apps/server/src/turbo/terminalHistoryBuffer.test.ts", + "path": ".github/workflows/turbo-nightly-sync.yml", "markers": [ - "matches upstream across the cap-trim boundary", - "matches upstream's graceful degradation for a non-positive cap", - "is byte-identical however the recorded stream is chunked and batched", - "is byte-identical however the real stream is chunked and batched", - "still owes the persist when a read flushed the batch before the batch tick", - "import { capHistory, sanitizeTerminalHistoryChunk } from \"../terminal/Manager.ts\";" + "cron: \"0 23 * * *\"", + "timezone: \"America/New_York\"", + "Resolve the completed Eastern cutoff", + "-f until=\"$CUTOFF_INSTANT\"", + "Rebase in an isolated worktree", + "apps/web/src/turbo/chatPanes/chatPaneResourcePolicy.test.ts", + "scripts/turbo-product-branding.test.ts", + "Record registered relay and portal branch state", + "Measure registered relay and portal branch state", + "report_repair:", + "Record the reviewed repair and PR path", + "Create nightly completion report", + "T3CODE_DESKTOP_UPDATE_REPOSITORY", + "--title \"T3 Turbo $CUTOFF_LABEL.exe\"", + "--force-with-lease", + "TURBO_CUTOFF_INSTANT: ${{ inputs.cutoff_instant }}" ] }, { - "path": "apps/server/src/terminal/Manager.ts", + "path": "scripts/turbo-nightly-sync.ts", "markers": [ - "const DEFAULT_HISTORY_BATCH_MS = 16;", - "historyBuffer: TerminalHistoryBuffer;", - "queueTerminalHistoryChunk(session.historyBuffer, nextEvent.data);", - "historyBatchWorker.drainKey(sessionKey);", - "readTerminalHistoryBuffer(session.historyBuffer)", - "takeTerminalHistoryToPersist(session.historyBuffer)", - "export function sanitizeTerminalHistoryChunk", - "export function capHistory", - "historyBatchScheduled: boolean;", - "if (session.historyBatchScheduled) {", - "session.historyBatchScheduled = false;" + "nextTurboVersion", + "selectTurboVersionBase", + "resolveTurboCutoffOverride", + "Refusing to move the recorded official Nightly release backward." ] }, { - "path": "SEAM.md", - "markers": ["**Tuned** `apps/server/src/terminal/Manager.ts`"] + "path": "docs/internals/t3-turbo-nightly-inbound.md", + "markers": [ + "replays our Turbo commit", + "Every day at 11:00 PM", + "installer only in `gfsaaser24/t3code`", + "last known-good release" + ] } ] }, { - "id": "sqlite-fast-mode-pragma", + "id": "official-data-import", "status": "implemented", - "summary": "The single sqlite setup layer applies the standard WAL companion PRAGMA synchronous = NORMAL to every connection, leaving foreign_keys and journal_mode untouched.", + "summary": "Dependency-light import planning, identity remapping, staged storage, restore, and projection replay modules remain recoverable.", "checks": [ { - "path": "apps/server/src/persistence/Layers/Sqlite.ts", + "path": "apps/server/src/turbo/officialImport/plan.ts", "markers": [ - "PRAGMA foreign_keys = ON;", - "PRAGMA journal_mode = WAL;", - "PRAGMA synchronous = NORMAL;" + "Schema.Literals([\"skip\", \"replace\", \"clone\"])", + "export const OfficialImportIdMap", + "export const planOfficialImport", + "export const validateOfficialImportPlan" ] }, { - "path": "apps/server/src/persistence/Layers/SqlitePragmas.test.ts", + "path": "apps/server/src/turbo/officialImport/replay.ts", "markers": [ - "in-memory persistence enables synchronous=NORMAL and keeps foreign_keys on", - "file-backed persistence keeps WAL and applies synchronous=NORMAL" + "OfficialImportProjectionVerificationError", + "rebuildOfficialImportProjections" ] }, { - "path": "SEAM.md", - "markers": ["**Tuned** `apps/server/src/persistence/Layers/Sqlite.ts`"] - } - ] - }, - { - "id": "cheap-timestamp-and-sort-keys", - "status": "implemented", - "summary": "Product timestamps compare as plain fixed-width ISO strings instead of through the ICU collator, and the sidebar bucket sorts plus the keyless pinned block resolve each row's sort key once instead of per comparison.", - "checks": [ + "path": "apps/server/src/turbo/officialImport/storage.ts", + "markers": ["prepareImportWorkspace", "cutoverImport", "restoreImportBackup"] + }, { - "path": "apps/web/src/session-logic.ts", + "path": "apps/server/src/cli/officialImport.ts", "markers": [ - "import { compareIsoTimestamps } from \"@t3tools/client-runtime/state/thread-activity-order\"", - "compareIsoTimestamps(left.createdAt, right.createdAt)", - "compareIsoTimestamps(left.updatedAt, right.updatedAt)", - "compareIsoTimestamps(a.createdAt, b.createdAt)", - "compareIsoTimestamps(a.completedAt, b.completedAt)" + "export const officialImportCommand", + "prepareOfficialImport", + "applyPreparedOfficialImport" ] }, { - "path": "apps/web/src/components/Sidebar.logic.ts", + "path": "apps/server/src/bin.ts", + "markers": ["import { officialImportCommand }", "officialImportCommand,"] + }, + { + "path": "packages/contracts/src/ipc.ts", "markers": [ - "createdAtMs: parseTimestampMs(thread.createdAt)", - "right.createdAtMs - left.createdAtMs", - "settledAtMs: timestamp === null ? 0 : parseTimestampMs(timestamp)", - "right.settledAtMs - left.settledAtMs", - "export function sortSnoozedThreadsForSidebar", - "wakeAtMs: firstValidTimestampMs(thread.snoozedUntil ?? null)" + "DesktopOfficialT3ImportInputSchema", + "DesktopOfficialT3ImportResultSchema", + "discoverOfficialT3Import?:", + "runOfficialT3Import?:" ] }, { - "path": "apps/web/src/components/Sidebar.tsx", - "markers": ["snoozedThreads: sortSnoozedThreadsForSidebar(snoozed)"] + "path": "apps/desktop/src/ipc/channels.ts", + "markers": ["DISCOVER_OFFICIAL_T3_IMPORT_CHANNEL", "RUN_OFFICIAL_T3_IMPORT_CHANNEL"] }, { - "path": "packages/client-runtime/src/state/threadSort.ts", + "path": "apps/desktop/src/ipc/DesktopIpcHandlers.ts", "markers": [ - "const leftCreatedAt = left.createdAt;", - "leftCreatedAt > rightCreatedAt", - "function isCanonicalIsoTimestamp(value: string): boolean", - "isCanonicalIsoTimestamp(leftCreatedAt) && isCanonicalIsoTimestamp(rightCreatedAt)" + "yield* ipc.handle(discoverOfficialT3Import)", + "yield* ipc.handle(runOfficialT3Import)" ] }, { - "path": "packages/client-runtime/src/state/threadSortPinnedKeyless.test.ts", - "markers": [ - "legacySortPinnedThreadsByOrderKey", - "emits the pre-swap order for product-minted timestamps, ties included", - "emits the pre-swap order for non-canonical and malformed stamps too" - ] + "path": "apps/desktop/src/preload.ts", + "markers": ["discoverOfficialT3Import: () =>", "runOfficialT3Import: (input) =>"] }, { - "path": "apps/web/src/turbo/sortOrderEquivalence.test.ts", - "markers": [ - "orders every pair of product-minted timestamps exactly as localeCompare did", - "emits the pre-decorate order, ties included", - "sortSnoozedThreadsForSidebar (snoozed shelf)" - ] + "path": "apps/server/src/turbo/officialImport/plan.test.ts", + "markers": ["official import clone identity graph", "validateOfficialImportPlan"] }, { - "path": "SEAM.md", - "markers": [ - "**Tuned** `apps/web/src/session-logic.ts`", - "**Tuned** `apps/web/src/components/Sidebar.logic.ts`", - "**Tuned** `packages/client-runtime/src/state/threadSort.ts`" - ] - } - ] - }, - { - "id": "cheap-message-unpacking", - "status": "implemented", - "summary": "TrimmedString trims through the pure both-directions transform instead of allocating an Effect per value, and ForwardCompatibleArray decodes each element once instead of twice while keeping per-element drop-on-failure and a debug log.", - "checks": [ + "path": "apps/server/src/turbo/officialImport/storage.test.ts", + "markers": ["appendCanonicalEvents", "restoreImportBackup"] + }, { - "path": "packages/contracts/src/baseSchemas.ts", + "path": "apps/desktop/src/ipc/methods/officialT3Environment.ts", "markers": [ - "SchemaTransformation.transform({", - "decode: (value) => value.trim(),", - "encode: (value) => value.trim(),", - "Schema.toType(Schema.Array(element))", - "Effect.logDebug(\"ForwardCompatibleArray dropped undecodable elements\"", - "new SchemaIssue.Pointer([index], error.issue)" + "const executeImport = Effect.fn", + "export const runOfficialT3Import", + "yield* primary.stop();", + "snapshot.desiredRunning ? primary.start : Effect.void", + "Official T3 Code still has an active chat, turn, or approval" ] }, { - "path": "packages/contracts/src/turbo/baseSchemas.test.ts", - "markers": [ - "trims on the encode-without-decode path too", - "drops elements this build cannot decode and keeps the rest", - "names the failing element's index when encoding fails", - "decodes each element exactly once" - ] + "path": "apps/web/src/components/desktop/DesktopEnvironmentSwitcher.tsx", + "markers": ["bridge?.runOfficialT3Import", "Import official T3 Code"] }, { - "path": "SEAM.md", - "markers": ["**Tuned** `packages/contracts/src/baseSchemas.ts`"] + "path": "docs/user/official-t3-import.md", + "markers": ["t3 import official", "Keep both", "Relay and remote clients"] + }, + { + "path": ".plans/22-t3-turbo-official-data-import.md", + "markers": [ + "# T3 Turbo One-Way Official Data Import", + "Keep Turbo and skip official", + "Keep both; import official with a new UUID" + ] } ] }, { - "id": "deferred-streaming-code-blocks", + "id": "openrouter-first-party", "status": "implemented", - "summary": "Streaming code fences render as a row-capped, height-reserving placeholder inside the chat code-block frame and are highlighted exactly once when the message completes; the line count is incremental across deltas, the animation is the repo's duty-cycled skeleton sweep, and a fence that never grows is treated as history whose persisted streaming flag predates the reducer fix.", + "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", "checks": [ { - "path": "apps/web/src/turbo/streamingCodeBlock.tsx", + "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", "markers": [ - "export const STREAMING_CODE_FIRST_DELTA_MS = 500;", - "export const STREAMING_CODE_MAX_PLACEHOLDER_ROWS = 24;", - "if (input.stall === \"never-started\") return \"highlighted\";", - "export function countStreamingCodeLines(code: string): number", - "export function advanceStreamingCodeLineCount(", - "export function resolveStreamingCodeBlockView(input: {", - "export function useStreamingCodeStall(code: string, isStreaming: boolean): StreamingCodeStall", - "export function StreamingCodeBlockFrame({", - "data-streaming-code-placeholder", - "data-streaming-code-spacer", - "import { Skeleton } from \"~/components/ui/skeleton\";" + "OPENROUTER_OWNED_ENV_KEYS", + "buildOpenRouterProcessEnv", + "withOpenRouterAdapterIdentity" ] }, { - "path": "apps/web/src/components/ChatMarkdown.tsx", - "markers": [ - "import { StreamingCodeBlockFrame } from \"../turbo/streamingCodeBlock\";", - "", + "Effect.uninterruptibleMask((restore) =>", + "(item as { readonly kind: unknown }).kind === \"synchronized\";", + "function poolWithinFrame(stream: Stream.Stream): Stream.Stream {", + "const NON_CUMULATIVE_SUBSCRIPTION_TAGS: ReadonlySet = new Set([", + "WS_METHODS.subscribePreviewEvents,", + "WS_METHODS.previewAutomationConnect,", + "NON_CUMULATIVE_SUBSCRIPTION_TAGS.has(tag) ? items : poolWithinFrame(items)" ] }, { - "path": "packages/client-runtime/src/turbo/threadReducerStreamingSettle.test.ts", + "path": "packages/client-runtime/src/turbo/streamPoolTestClock.ts", "markers": [ - "thread.session-set clears streaming on the settled turn", - "thread.turn-interrupt-requested clears streaming on the interrupted turn", - "returns the same messages array when nothing was streaming" + "import { POOL_WINDOW } from \"../rpc/client.ts\";", + "const MAX_POOL_WINDOWS = 12;", + "export const awaitPooled = (effect: Effect.Effect): Effect.Effect =>" ] }, { - "path": "SEAM.md", + "path": "packages/client-runtime/src/turbo/streamPool.test.ts", "markers": [ - "**Additive** `packages/client-runtime/src/turbo/threadReducerStreamingSettle.test.ts`" + "drops pooled leftovers when the session dies", + "releases the synchronized marker without waiting out the window", + "releases one window's items as a single ordered chunk", + "never drops an item at a window boundary", + "never pools a subscription whose items are distinct facts", + "keeps the immediate-release bypass tied to the contracts literal" ] - } - ] - }, - { - "id": "terminal-buffer-byte-budget", - "status": "implemented", - "summary": "The client terminal buffer tracks its UTF-8 byte length in the reducer state and only re-encodes to trim once the total passes the cap plus a 25% slack window, then trims back down to the cap while keeping the multi-byte safety loop.", - "checks": [ + }, { - "path": "packages/client-runtime/src/state/terminalSession.ts", - "markers": [ - "readonly bufferBytes: number;", - "export const TERMINAL_BUFFER_TRIM_SLACK_RATIO = 0.25;", - "function utf8ByteLength(text: string): number", - "appendedBytes <= terminalBufferTrimThreshold(maxBufferBytes)", - "(byte & 0b1100_0000) !== 0b1000_0000" - ] + "path": "packages/client-runtime/src/state/threads-sync.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] }, { - "path": "packages/client-runtime/src/state/terminalSession.test.ts", - "markers": [ - "caps retained output by UTF-8 byte length once the slack threshold is crossed", - "expect(withinSlack.buffer.startsWith(snapshot.buffer)).toBe(true);" - ] + "path": "packages/client-runtime/src/state/threads-pagination.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] + }, + { + "path": "packages/client-runtime/src/state/shell-sync.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] + }, + { + "path": "packages/client-runtime/src/state/server.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] }, { "path": "SEAM.md", - "markers": [ - "**Tuned** `packages/client-runtime/src/state/terminalSession.ts`", - "**Tuned** `packages/client-runtime/src/state/terminalSession.test.ts`" - ] + "markers": ["**Tuned** `packages/client-runtime/src/rpc/client.ts`"] } ] }, { - "id": "terminal-drawer-redraw-gate", + "id": "product-identity-and-updater", "status": "implemented", - "summary": "The terminal drawer decides a redraw from the buffer and error it is about to write, not from a version counter that a reconnect restarts at 1 and a pooled burst can land on twice.", + "summary": "T3 Turbo has an isolated desktop identity, state home, and fork-owned update feed.", "checks": [ { - "path": "apps/web/src/components/ThreadTerminalDrawer.tsx", + "path": "apps/desktop/src/app/DesktopEnvironment.ts", "markers": [ - "export function terminalNeedsRedraw(", - "current.buffer !== previous.buffer ||", - "current.error !== previous.error ||", - "if (!terminalNeedsRedraw(previous, current)) {" + "const APP_BASE_NAME = \"T3 Turbo\";", + "const APP_RELEASE_REPOSITORY = \"gfsaaser24/t3code\";", + "\"com.gabef.t3turbo\"" ] }, { - "path": "apps/web/src/turbo/terminalDrawerRedraw.test.ts", - "markers": [ - "redraws a reconnect that reuses the version number the screen already drew", - "still redraws on a version bump, which is what drives the mount focus" - ] + "path": "apps/desktop/src/app/DesktopStatePaths.ts", + "markers": ["input.joinPath(input.homeDirectory, \".t3-turbo\")"] }, { - "path": "SEAM.md", - "markers": [ - "**Tuned** `apps/web/src/components/ThreadTerminalDrawer.tsx`", - "**Additive** `apps/web/src/turbo/terminalDrawerRedraw.test.ts`" - ] + "path": "apps/desktop/package.json", + "markers": ["\"productName\": \"T3 Turbo\""] + }, + { + "path": "scripts/build-desktop-artifact.ts", + "markers": ["T3CODE_DESKTOP_UPDATE_REPOSITORY", "pingdotgg/t3code", "--publish never"] + }, + { + "path": "apps/desktop/src/app/DesktopEnvironment.test.ts", + "markers": ["T3 Turbo", "gfsaaser24/t3code"] + }, + { + "path": ".github/workflows/release.yml", + "markers": ["name=T3 Turbo v$version"] } ] }, @@ -868,43 +841,6 @@ } ] }, - { - "id": "relay-request-budget-and-clerk-client", - "status": "implemented", - "summary": "The relay builds one Clerk backend client per configuration instead of one per OAuth fallback request, and the environment mint budget expires inside the relay's 9s request deadline instead of after it.", - "checks": [ - { - "path": "infra/relay/src/http/Api.ts", - "markers": [ - "const clerkOAuthClients = new WeakMap<", - "function clerkOAuthClient(config: RelayConfiguration.RelayConfiguration[\"Service\"])", - "const client = clerkOAuthClient(config);", - "export const RELAY_REQUEST_DEADLINE_MS = 9_000;" - ] - }, - { - "path": "infra/relay/src/environments/EnvironmentConnector.ts", - "markers": ["export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 7_000;"] - }, - { - "path": "infra/relay/src/turbo/relayRequestBudget.test.ts", - "markers": ["expires the environment mint inside the relay request deadline"] - }, - { - "path": "infra/relay/src/http/Api.test.ts", - "markers": ["module-level WeakMap keyed on this very object"] - }, - { - "path": "SEAM.md", - "markers": [ - "**Tuned** `infra/relay/src/http/Api.ts`", - "**Tuned** `infra/relay/src/environments/EnvironmentConnector.ts`", - "**Additive** `infra/relay/src/turbo/relayRequestBudget.test.ts`", - "**Tuned** `infra/relay/src/http/Api.test.ts`" - ] - } - ] - }, { "id": "relay-auth-and-link-memos", "status": "implemented", @@ -982,127 +918,304 @@ ] }, { - "id": "shared-sha256-base64url", + "id": "relay-policy", + "status": "policy", + "summary": "Relay, portal, tunnel, and self-host infrastructure retain separate ownership and credential boundaries.", + "checks": [ + { + "path": ".t3-turbo/OPENCLAW_RULES.md", + "markers": [ + "`infra/t3turbo-relay`", + "A product ingestion must not replace the relay branch", + "relay, online portal, tunnel, and self-host infrastructure seams" + ] + }, + { + "path": "infra/relay/README.md", + "markers": [ + "not in the hot path for normal T3 Turbo traffic", + "The environment server and relay have separate credentials and trust boundaries.", + "RELAY_TUNNEL_ZONE_NAME" + ] + }, + { + "path": ".github/workflows/deploy-relay.yml", + "markers": [ + "name: Detect Cloudflare configuration", + "if: steps.cloudflare_config.outputs.enabled == 'true'", + "RELAY_TUNNEL_ZONE_NAME", + " - turbo", + " - \"infra/relay/**\"" + ] + } + ] + }, + { + "id": "relay-request-budget-and-clerk-client", + "status": "implemented", + "summary": "The relay builds one Clerk backend client per configuration instead of one per OAuth fallback request, and the environment mint budget expires inside the relay's 9s request deadline instead of after it.", + "checks": [ + { + "path": "infra/relay/src/http/Api.ts", + "markers": [ + "const clerkOAuthClients = new WeakMap<", + "function clerkOAuthClient(config: RelayConfiguration.RelayConfiguration[\"Service\"])", + "const client = clerkOAuthClient(config);", + "export const RELAY_REQUEST_DEADLINE_MS = 9_000;" + ] + }, + { + "path": "infra/relay/src/environments/EnvironmentConnector.ts", + "markers": ["export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 7_000;"] + }, + { + "path": "infra/relay/src/turbo/relayRequestBudget.test.ts", + "markers": ["expires the environment mint inside the relay request deadline"] + }, + { + "path": "infra/relay/src/http/Api.test.ts", + "markers": ["module-level WeakMap keyed on this very object"] + }, + { + "path": "SEAM.md", + "markers": [ + "**Tuned** `infra/relay/src/http/Api.ts`", + "**Tuned** `infra/relay/src/environments/EnvironmentConnector.ts`", + "**Additive** `infra/relay/src/turbo/relayRequestBudget.test.ts`", + "**Tuned** `infra/relay/src/http/Api.test.ts`" + ] + } + ] + }, + { + "id": "release-from-turbo-branch", + "status": "policy", + "summary": "Scheduled and dispatched releases build, tag, and finalize from the fork's turbo mainline instead of the main branch that only tracks upstream, so published installers carry the fork's code and version.", + "checks": [ + { + "path": ".github/workflows/release.yml", + "markers": [ + "TURBO_RELEASE_BRANCH: ${{ github.repository == 'gfsaaser24/t3code' && 'turbo' || 'main' }}", + "TURBO_RELEASE_REF: ${{ (github.event_name != 'push' && github.repository == 'gfsaaser24/t3code') && 'turbo' || github.sha }}", + "ref: ${{ env.TURBO_RELEASE_REF }}", + "ref: ${{ steps.release_ref.outputs.sha }}", + "ref: ${{ env.TURBO_RELEASE_BRANCH }}", + "git push origin \"HEAD:${TURBO_RELEASE_BRANCH}\"" + ] + } + ] + }, + { + "id": "settled-lifecycle-sticky-pin", + "status": "implemented", + "summary": "Un-settling a thread is durable: the decider no longer spends the keep-active pin on activity (it still wakes explicitly settled threads). PARTIALLY RETIRED 2026-08-16: upstream #5880's autoSettleOnMerge toggle superseded the fork's merged-PR updatedAt gate, so threadSettled/contracts/GitManager and the web+mobile updatedAt threading now track upstream. Only the decider pin remains fork-owned. Upstream candidate for the pin: pingdotgg/t3code#5575; drop when upstream lands a sticky un-settle.", + "checks": [ + { + "path": "apps/server/src/orchestration/decider.ts", + "markers": [ + "deliberately sticky", + "if (targetThread.settledOverride === \"settled\") {", + "thread.settledOverride !== \"settled\" || !isSessionActivity", + "thread.settledOverride !== \"settled\" || !wakesSettledThread" + ] + } + ] + }, + { + "id": "shared-sha256-base64url", + "status": "implemented", + "summary": "The base64url SHA-256 expression lives once in packages/shared with a module-scope TextEncoder, and the DPoP thumbprint, the DPoP access-token hash, and the relay token-memo key all call it.", + "checks": [ + { + "path": "packages/shared/src/turbo/sha256.ts", + "markers": [ + "export function sha256Base64Url(text: string): string {", + "const encoder = new TextEncoder();" + ] + }, + { + "path": "packages/shared/package.json", + "markers": ["\"./turbo/sha256\"", "\"./src/turbo/sha256.ts\""] + }, + { + "path": "packages/shared/src/dpop.ts", + "markers": [ + "import { sha256Base64Url } from \"./turbo/sha256.ts\";", + "return sha256Base64Url(dpopThumbprintInput(jwk));", + "return sha256Base64Url(accessToken);" + ] + }, + { + "path": "SEAM.md", + "markers": [ + "**Additive** `packages/shared/src/turbo/sha256.ts`", + "**Tuned** `packages/shared/src/dpop.ts`" + ] + } + ] + }, + { + "id": "sqlite-fast-mode-pragma", + "status": "implemented", + "summary": "The single sqlite setup layer applies the standard WAL companion PRAGMA synchronous = NORMAL to every connection, leaving foreign_keys and journal_mode untouched.", + "checks": [ + { + "path": "apps/server/src/persistence/Layers/Sqlite.ts", + "markers": [ + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA synchronous = NORMAL;" + ] + }, + { + "path": "apps/server/src/persistence/Layers/SqlitePragmas.test.ts", + "markers": [ + "in-memory persistence enables synchronous=NORMAL and keeps foreign_keys on", + "file-backed persistence keeps WAL and applies synchronous=NORMAL" + ] + }, + { + "path": "SEAM.md", + "markers": ["**Tuned** `apps/server/src/persistence/Layers/Sqlite.ts`"] + } + ] + }, + { + "id": "streaming-flag-cleared-on-turn-settle", + "status": "implemented", + "summary": "The thread reducer lowers OrchestrationMessage.streaming on the settled turn's messages in the two branches that already settle a turn (thread.session-set leaving \"running\", and thread.turn-interrupt-requested), because upstream only ever lowers it from the final thread.message-sent — which never arrives when a turn dies — and the raised flag is persisted, so isStreaming otherwise lies for the rest of the session and on every later load.", + "checks": [ + { + "path": "packages/client-runtime/src/state/threadReducer.ts", + "markers": [ + "function clearStreamingForTurn(", + "messages: clearStreamingForTurn(thread.messages, event.payload.turnId),", + "clearStreamingForTurn(thread.messages, latestTurn.turnId)" + ] + }, + { + "path": "packages/client-runtime/src/turbo/threadReducerStreamingSettle.test.ts", + "markers": [ + "thread.session-set clears streaming on the settled turn", + "thread.turn-interrupt-requested clears streaming on the interrupted turn", + "returns the same messages array when nothing was streaming" + ] + }, + { + "path": "SEAM.md", + "markers": [ + "**Additive** `packages/client-runtime/src/turbo/threadReducerStreamingSettle.test.ts`" + ] + } + ] + }, + { + "id": "terminal-buffer-byte-budget", "status": "implemented", - "summary": "The base64url SHA-256 expression lives once in packages/shared with a module-scope TextEncoder, and the DPoP thumbprint, the DPoP access-token hash, and the relay token-memo key all call it.", + "summary": "The client terminal buffer tracks its UTF-8 byte length in the reducer state and only re-encodes to trim once the total passes the cap plus a 25% slack window, then trims back down to the cap while keeping the multi-byte safety loop.", "checks": [ { - "path": "packages/shared/src/turbo/sha256.ts", + "path": "packages/client-runtime/src/state/terminalSession.ts", "markers": [ - "export function sha256Base64Url(text: string): string {", - "const encoder = new TextEncoder();" + "readonly bufferBytes: number;", + "export const TERMINAL_BUFFER_TRIM_SLACK_RATIO = 0.25;", + "function utf8ByteLength(text: string): number", + "appendedBytes <= terminalBufferTrimThreshold(maxBufferBytes)", + "(byte & 0b1100_0000) !== 0b1000_0000" ] }, { - "path": "packages/shared/package.json", - "markers": ["\"./turbo/sha256\"", "\"./src/turbo/sha256.ts\""] - }, - { - "path": "packages/shared/src/dpop.ts", + "path": "packages/client-runtime/src/state/terminalSession.test.ts", "markers": [ - "import { sha256Base64Url } from \"./turbo/sha256.ts\";", - "return sha256Base64Url(dpopThumbprintInput(jwk));", - "return sha256Base64Url(accessToken);" + "caps retained output by UTF-8 byte length once the slack threshold is crossed", + "expect(withinSlack.buffer.startsWith(snapshot.buffer)).toBe(true);" ] }, { "path": "SEAM.md", "markers": [ - "**Additive** `packages/shared/src/turbo/sha256.ts`", - "**Tuned** `packages/shared/src/dpop.ts`" + "**Tuned** `packages/client-runtime/src/state/terminalSession.ts`", + "**Tuned** `packages/client-runtime/src/state/terminalSession.test.ts`" ] } ] }, { - "id": "relay-policy", - "status": "policy", - "summary": "Relay, portal, tunnel, and self-host infrastructure retain separate ownership and credential boundaries.", + "id": "terminal-drawer-redraw-gate", + "status": "implemented", + "summary": "The terminal drawer decides a redraw from the buffer and error it is about to write, not from a version counter that a reconnect restarts at 1 and a pooled burst can land on twice.", "checks": [ { - "path": ".t3-turbo/OPENCLAW_RULES.md", + "path": "apps/web/src/components/ThreadTerminalDrawer.tsx", "markers": [ - "`infra/t3turbo-relay`", - "A product ingestion must not replace the relay branch", - "relay, online portal, tunnel, and self-host infrastructure seams" + "export function terminalNeedsRedraw(", + "current.buffer !== previous.buffer ||", + "current.error !== previous.error ||", + "if (!terminalNeedsRedraw(previous, current)) {" ] }, { - "path": "infra/relay/README.md", + "path": "apps/web/src/turbo/terminalDrawerRedraw.test.ts", "markers": [ - "not in the hot path for normal T3 Turbo traffic", - "The environment server and relay have separate credentials and trust boundaries.", - "RELAY_TUNNEL_ZONE_NAME" + "redraws a reconnect that reuses the version number the screen already drew", + "still redraws on a version bump, which is what drives the mount focus" ] }, { - "path": ".github/workflows/deploy-relay.yml", + "path": "SEAM.md", "markers": [ - "name: Detect Cloudflare configuration", - "if: steps.cloudflare_config.outputs.enabled == 'true'", - "RELAY_TUNNEL_ZONE_NAME", - " - turbo", - " - \"infra/relay/**\"" + "**Tuned** `apps/web/src/components/ThreadTerminalDrawer.tsx`", + "**Additive** `apps/web/src/turbo/terminalDrawerRedraw.test.ts`" ] } ] }, { - "id": "nightly-and-secret-policy", - "status": "policy", - "summary": "Daily 11 PM Eastern ingestion preserves the last known-good Turbo stack, publishes only to the fork, and excludes the local secrets note.", + "id": "terminal-scrollback-batching", + "status": "implemented", + "summary": "A terminal session keeps its scrollback as an incremental line buffer with a ~16 ms output batch instead of a string chopped and re-glued per PTY chunk; the debounce runs on a per-session fiber (never inside the shared worker, which would serialize every session's batch interval and every drain behind one another) and is enqueued once per burst rather than once per chunk, the batch is a keyed coalescing worker that flushPersist enqueues into and drains before the persist worker, every scrollback read flushes it so the string handed to clients stays byte-identical, and a dirtySincePersist flag (not the per-flush result) decides whether a write is still owed so a racing read cannot strand the tail.", "checks": [ { - "path": ".gitignore", - "markers": ["/SECRETS DO NOT COMMIT.md"] - }, - { - "path": ".t3-turbo/OPENCLAW_RULES.md", + "path": "apps/server/src/turbo/terminalHistoryBuffer.ts", "markers": [ - "ingestion starts with the last known-good T3 Turbo branch", - "Never resolve a collision with a blanket `ours`, `theirs`, force push, clean checkout", - "Never bake credentials, tokens, secrets", - "Do not publish T3 Turbo to NPM" + "export function queueTerminalHistoryChunk", + "export function flushTerminalHistoryBuffer", + "export function endTerminalHistoryStream", + "export function readTerminalHistoryBuffer", + "export function takeTerminalHistoryToPersist", + "dirtySincePersist" ] }, { - "path": ".github/workflows/turbo-nightly-sync.yml", + "path": "apps/server/src/turbo/terminalHistoryBuffer.test.ts", "markers": [ - "cron: \"0 23 * * *\"", - "timezone: \"America/New_York\"", - "Resolve the completed Eastern cutoff", - "-f until=\"$CUTOFF_INSTANT\"", - "Rebase in an isolated worktree", - "apps/web/src/turbo/chatPanes/chatPaneResourcePolicy.test.ts", - "scripts/turbo-product-branding.test.ts", - "Record registered relay and portal branch state", - "Measure registered relay and portal branch state", - "report_repair:", - "Record the reviewed repair and PR path", - "Create nightly completion report", - "T3CODE_DESKTOP_UPDATE_REPOSITORY", - "--title \"T3 Turbo $CUTOFF_LABEL.exe\"", - "--force-with-lease", - "TURBO_CUTOFF_INSTANT: ${{ inputs.cutoff_instant }}" + "matches upstream across the cap-trim boundary", + "matches upstream's graceful degradation for a non-positive cap", + "is byte-identical however the recorded stream is chunked and batched", + "is byte-identical however the real stream is chunked and batched", + "still owes the persist when a read flushed the batch before the batch tick", + "import { capHistory, sanitizeTerminalHistoryChunk } from \"../terminal/Manager.ts\";" ] }, { - "path": "scripts/turbo-nightly-sync.ts", + "path": "apps/server/src/terminal/Manager.ts", "markers": [ - "nextTurboVersion", - "selectTurboVersionBase", - "resolveTurboCutoffOverride", - "Refusing to move the recorded official Nightly release backward." + "const DEFAULT_HISTORY_BATCH_MS = 16;", + "historyBuffer: TerminalHistoryBuffer;", + "queueTerminalHistoryChunk(session.historyBuffer, nextEvent.data);", + "historyBatchWorker.drainKey(sessionKey);", + "readTerminalHistoryBuffer(session.historyBuffer)", + "takeTerminalHistoryToPersist(session.historyBuffer)", + "export function sanitizeTerminalHistoryChunk", + "export function capHistory", + "historyBatchScheduled: boolean;", + "if (session.historyBatchScheduled) {", + "session.historyBatchScheduled = false;" ] }, { - "path": "docs/internals/t3-turbo-nightly-inbound.md", - "markers": [ - "replays our Turbo commit", - "Every day at 11:00 PM", - "installer only in `gfsaaser24/t3code`", - "last known-good release" - ] + "path": "SEAM.md", + "markers": ["**Tuned** `apps/server/src/terminal/Manager.ts`"] } ] }, @@ -1216,128 +1329,33 @@ ] }, { - "id": "pooled-subscription-frame", - "status": "implemented", - "summary": "Durable RPC subscriptions pool one frame (16 ms) of already-arrived stream items inside the per-session stream and release them as a single chunk, so a burst costs the screen one blip per frame instead of one per item; the pool is created and shut down with the session, and the \"synchronized\" connection marker is never held back by the window; subscriptions whose items are distinct facts rather than cumulative state (preview events, automation requests) bypass the pool entirely, because a chunk-collapsing atom consumer would otherwise drop all but the last item of a window.", - "checks": [ - { - "path": "packages/client-runtime/src/rpc/client.ts", - "markers": [ - "export const POOL_WINDOW: Duration.Input = \"16 millis\";", - "export const flushesImmediately = (item: unknown): boolean =>", - "Effect.uninterruptibleMask((restore) =>", - "(item as { readonly kind: unknown }).kind === \"synchronized\";", - "function poolWithinFrame(stream: Stream.Stream): Stream.Stream {", - "const NON_CUMULATIVE_SUBSCRIPTION_TAGS: ReadonlySet = new Set([", - "WS_METHODS.subscribePreviewEvents,", - "WS_METHODS.previewAutomationConnect,", - "NON_CUMULATIVE_SUBSCRIPTION_TAGS.has(tag) ? items : poolWithinFrame(items)" - ] - }, - { - "path": "packages/client-runtime/src/turbo/streamPoolTestClock.ts", - "markers": [ - "import { POOL_WINDOW } from \"../rpc/client.ts\";", - "const MAX_POOL_WINDOWS = 12;", - "export const awaitPooled = (effect: Effect.Effect): Effect.Effect =>" - ] - }, - { - "path": "packages/client-runtime/src/turbo/streamPool.test.ts", - "markers": [ - "drops pooled leftovers when the session dies", - "releases the synchronized marker without waiting out the window", - "releases one window's items as a single ordered chunk", - "never drops an item at a window boundary", - "never pools a subscription whose items are distinct facts", - "keeps the immediate-release bypass tied to the contracts literal" - ] - }, - { - "path": "packages/client-runtime/src/state/threads-sync.test.ts", - "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] - }, - { - "path": "packages/client-runtime/src/state/threads-pagination.test.ts", - "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] - }, - { - "path": "packages/client-runtime/src/state/shell-sync.test.ts", - "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] - }, - { - "path": "packages/client-runtime/src/state/server.test.ts", - "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] - }, - { - "path": "SEAM.md", - "markers": ["**Tuned** `packages/client-runtime/src/rpc/client.ts`"] - } - ] - }, - { - "id": "settled-lifecycle-sticky-pin", - "status": "implemented", - "summary": "Un-settling a thread is durable: the decider no longer spends the keep-active pin on activity (it still wakes explicitly settled threads). PARTIALLY RETIRED 2026-08-16: upstream #5880's autoSettleOnMerge toggle superseded the fork's merged-PR updatedAt gate, so threadSettled/contracts/GitManager and the web+mobile updatedAt threading now track upstream. Only the decider pin remains fork-owned. Upstream candidate for the pin: pingdotgg/t3code#5575; drop when upstream lands a sticky un-settle.", - "checks": [ - { - "path": "apps/server/src/orchestration/decider.ts", - "markers": [ - "deliberately sticky", - "if (targetThread.settledOverride === \"settled\") {", - "thread.settledOverride !== \"settled\" || !isSessionActivity", - "thread.settledOverride !== \"settled\" || !wakesSettledThread" - ] - } - ] - }, - { - "id": "openrouter-first-party", + "id": "workspace-image-preview", "status": "implemented", - "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", + "summary": "Workspace image types open in editor tabs and render through the shared image-preview classifier.", "checks": [ { - "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", - "markers": [ - "OPENROUTER_OWNED_ENV_KEYS", - "buildOpenRouterProcessEnv", - "withOpenRouterAdapterIdentity" - ] - }, - { - "path": "apps/server/src/provider/openrouter/OpenRouterModels.ts", - "markers": ["fetchOpenRouterModels", "FALLBACK_OPENROUTER_MODELS"] - }, - { - "path": "apps/server/src/provider/Drivers/OpenRouterDriver.ts", + "path": "packages/shared/src/filePreview.ts", "markers": [ - "withOpenRouterAdapterIdentity(", - "buildOpenRouterProcessEnv(effectiveConfig, baseEnv)" + "WORKSPACE_IMAGE_PREVIEW_EXTENSIONS", + "\".ico\"", + "export function isWorkspaceImagePreviewPath" ] }, { - "path": "apps/server/src/provider/Layers/OpenRouterProvider.ts", - "markers": ["checkOpenRouterProviderStatus", "makePendingOpenRouterProvider"] - }, - { - "path": "apps/server/src/provider/builtInDrivers.ts", - "markers": ["OpenRouterDriver"] - }, - { - "path": "packages/contracts/src/settings.ts", - "markers": ["export const OpenRouterSettings"] + "path": "apps/web/src/components/files/FilePreviewPanel.tsx", + "markers": ["function WorkspaceImagePreview", "isWorkspaceImagePreviewPath(relativePath)"] }, { - "path": "packages/contracts/src/model.ts", - "markers": ["OPENROUTER_DRIVER_KIND"] + "path": "apps/web/src/rightPanelStore.ts", + "markers": ["const fileSurface =", "openFile: (ref, relativePath, line) =>"] }, { - "path": "apps/web/src/components/settings/providerDriverMeta.ts", - "markers": ["label: \"OpenRouter\""] + "path": "packages/shared/src/filePreview.test.ts", + "markers": ["isWorkspaceImagePreviewPath"] }, { - "path": "apps/web/src/session-logic.ts", - "markers": ["label: \"OpenRouter\""] + "path": "apps/web/src/rightPanelStore.test.ts", + "markers": ["opens image files as reusable peer tabs"] } ] } diff --git a/SEAM.md b/SEAM.md index 6fff922609a3..abd138ec6cc2 100644 --- a/SEAM.md +++ b/SEAM.md @@ -419,6 +419,24 @@ On a nightly-sync conflict: everything here is additive except `builtInDrivers.t after upstream's. If upstream ships its own OpenRouter or the ACP registry (#6071) covers it, prefer upstream and retire the shim first. +## Releases build from `turbo` (fork policy) + +Upstream's `release.yml` resolves scheduled and dispatched releases from whatever ref the run was +started on, which on this fork is the default branch `main`. `main` only tracks upstream, so every +installer the fork published was upstream code at upstream's version — none of the fork's work +(chat panes, OpenRouter, relay changes) ever reached a published release, and the fork's own +version line never advanced there. + +- `TURBO_RELEASE_REF` pins non-tag runs to `turbo`; tag pushes still build the pushed tag. +- `preflight` resolves that ref to a commit sha (`steps.release_ref`) and every build, release, and + deploy job checks out that one sha, so a mid-run push to `turbo` cannot split the release. +- `TURBO_RELEASE_BRANCH` sends the finalize job's version-bump commit to `turbo`, not `main`. +- Both are guarded by `github.repository == 'gfsaaser24/t3code'`, so upstream behavior is unchanged + and the file stays merge-clean. + +On a nightly-sync conflict: keep upstream's job graph and re-add the two `env` entries plus the +five `ref:`/push lines. If upstream ever gains its own release-branch input, prefer it. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/scripts/turbo-customization-manifest.test.ts b/scripts/turbo-customization-manifest.test.ts index e4e843a89d2b..f80162f7c7da 100644 --- a/scripts/turbo-customization-manifest.test.ts +++ b/scripts/turbo-customization-manifest.test.ts @@ -159,6 +159,7 @@ it("verifies the checked-in Turbo manifest and tracks the implemented multi-chat "relay-auth-and-link-memos", "relay-policy", "relay-request-budget-and-clerk-client", + "release-from-turbo-branch", "settled-lifecycle-sticky-pin", "shared-sha256-base64url", "sqlite-fast-mode-pragma", From db0e1748e51a3e13f0782455179cb089b2b3c620 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 18 Aug 2026 07:59:12 -0400 Subject: [PATCH 6/6] chore(turbo): keep the customization manifest in append order The previous commit re-serialized and re-sorted the whole manifest, which is 1200 lines of churn for one new seam and makes every nightly-sync conflict on this file worse. Restore the original ordering and append the seam. Co-Authored-By: Claude Fable 5 --- .t3-turbo/customizations.json | 1222 ++++++++++++++++----------------- 1 file changed, 611 insertions(+), 611 deletions(-) diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 1c94a6ab5fdb..770f430f5197 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -16,7 +16,7 @@ { "path": "AGENTS.md", "markers": [ - "T3 Turbo — how this branch operates", + "T3 Turbo \u2014 how this branch operates", "write anything to `pingdotgg/t3code`", "Turbo changes always survive ingestion", "Turbo state is isolated from the official T3 install" @@ -32,6 +32,60 @@ } ] }, + { + "id": "changelog-and-runbook", + "status": "implemented", + "summary": "The fork keeps its own changelog and operator runbook under docs/operations.", + "checks": [ + { + "path": "docs/operations/turbo-changelog.md", + "markers": ["# T3 Turbo changelog", "entry here in the same PR"] + }, + { + "path": "docs/operations/turbo-runbook.md", + "markers": [ + "# T3 Turbo operator runbook", + "Registering a new customization (checklist)", + "turbo:customizations:verify" + ] + } + ] + }, + { + "id": "product-identity-and-updater", + "status": "implemented", + "summary": "T3 Turbo has an isolated desktop identity, state home, and fork-owned update feed.", + "checks": [ + { + "path": "apps/desktop/src/app/DesktopEnvironment.ts", + "markers": [ + "const APP_BASE_NAME = \"T3 Turbo\";", + "const APP_RELEASE_REPOSITORY = \"gfsaaser24/t3code\";", + "\"com.gabef.t3turbo\"" + ] + }, + { + "path": "apps/desktop/src/app/DesktopStatePaths.ts", + "markers": ["input.joinPath(input.homeDirectory, \".t3-turbo\")"] + }, + { + "path": "apps/desktop/package.json", + "markers": ["\"productName\": \"T3 Turbo\""] + }, + { + "path": "scripts/build-desktop-artifact.ts", + "markers": ["T3CODE_DESKTOP_UPDATE_REPOSITORY", "pingdotgg/t3code", "--publish never"] + }, + { + "path": "apps/desktop/src/app/DesktopEnvironment.test.ts", + "markers": ["T3 Turbo", "gfsaaser24/t3code"] + }, + { + "path": ".github/workflows/release.yml", + "markers": ["name=T3 Turbo v$version"] + } + ] + }, { "id": "canonical-icon-pipeline", "status": "implemented", @@ -111,197 +165,68 @@ ] }, { - "id": "changelog-and-runbook", - "status": "implemented", - "summary": "The fork keeps its own changelog and operator runbook under docs/operations.", - "checks": [ - { - "path": "docs/operations/turbo-changelog.md", - "markers": ["# T3 Turbo changelog", "entry here in the same PR"] - }, - { - "path": "docs/operations/turbo-runbook.md", - "markers": [ - "# T3 Turbo operator runbook", - "Registering a new customization (checklist)", - "turbo:customizations:verify" - ] - } - ] - }, - { - "id": "cheap-message-unpacking", - "status": "implemented", - "summary": "TrimmedString trims through the pure both-directions transform instead of allocating an Effect per value, and ForwardCompatibleArray decodes each element once instead of twice while keeping per-element drop-on-failure and a debug log.", - "checks": [ - { - "path": "packages/contracts/src/baseSchemas.ts", - "markers": [ - "SchemaTransformation.transform({", - "decode: (value) => value.trim(),", - "encode: (value) => value.trim(),", - "Schema.toType(Schema.Array(element))", - "Effect.logDebug(\"ForwardCompatibleArray dropped undecodable elements\"", - "new SchemaIssue.Pointer([index], error.issue)" - ] - }, - { - "path": "packages/contracts/src/turbo/baseSchemas.test.ts", - "markers": [ - "trims on the encode-without-decode path too", - "drops elements this build cannot decode and keeps the rest", - "names the failing element's index when encoding fails", - "decodes each element exactly once" - ] - }, - { - "path": "SEAM.md", - "markers": ["**Tuned** `packages/contracts/src/baseSchemas.ts`"] - } - ] - }, - { - "id": "cheap-timestamp-and-sort-keys", + "id": "file-explorer", "status": "implemented", - "summary": "Product timestamps compare as plain fixed-width ISO strings instead of through the ICU collator, and the sidebar bucket sorts plus the keyless pinned block resolve each row's sort key once instead of per comparison.", + "summary": "Explorer navigation keeps file actions, reveal behavior, and Alt-click bulk folder expansion.", "checks": [ { - "path": "apps/web/src/session-logic.ts", - "markers": [ - "import { compareIsoTimestamps } from \"@t3tools/client-runtime/state/thread-activity-order\"", - "compareIsoTimestamps(left.createdAt, right.createdAt)", - "compareIsoTimestamps(left.updatedAt, right.updatedAt)", - "compareIsoTimestamps(a.createdAt, b.createdAt)", - "compareIsoTimestamps(a.completedAt, b.completedAt)" - ] - }, - { - "path": "apps/web/src/components/Sidebar.logic.ts", - "markers": [ - "createdAtMs: parseTimestampMs(thread.createdAt)", - "right.createdAtMs - left.createdAtMs", - "settledAtMs: timestamp === null ? 0 : parseTimestampMs(timestamp)", - "right.settledAtMs - left.settledAtMs", - "export function sortSnoozedThreadsForSidebar", - "wakeAtMs: firstValidTimestampMs(thread.snoozedUntil ?? null)" - ] - }, - { - "path": "apps/web/src/components/Sidebar.tsx", - "markers": ["snoozedThreads: sortSnoozedThreadsForSidebar(snoozed)"] - }, - { - "path": "packages/client-runtime/src/state/threadSort.ts", - "markers": [ - "const leftCreatedAt = left.createdAt;", - "leftCreatedAt > rightCreatedAt", - "function isCanonicalIsoTimestamp(value: string): boolean", - "isCanonicalIsoTimestamp(leftCreatedAt) && isCanonicalIsoTimestamp(rightCreatedAt)" - ] - }, - { - "path": "packages/client-runtime/src/state/threadSortPinnedKeyless.test.ts", - "markers": [ - "legacySortPinnedThreadsByOrderKey", - "emits the pre-swap order for product-minted timestamps, ties included", - "emits the pre-swap order for non-canonical and malformed stamps too" - ] - }, - { - "path": "apps/web/src/turbo/sortOrderEquivalence.test.ts", + "path": "apps/web/src/components/files/FileBrowserPanel.tsx", "markers": [ - "orders every pair of product-minted timestamps exactly as localeCompare did", - "emits the pre-decorate order, ties included", - "sortSnoozedThreadsForSidebar (snoozed shelf)" + "fileTreeContextMenuItems", + "getAltChevronExpansion", + "setAllDirectoriesExpanded", + "if (clicked === \"open-new-tab\")" ] }, { - "path": "SEAM.md", - "markers": [ - "**Tuned** `apps/web/src/session-logic.ts`", - "**Tuned** `apps/web/src/components/Sidebar.logic.ts`", - "**Tuned** `packages/client-runtime/src/state/threadSort.ts`" - ] - } - ] - }, - { - "id": "deferred-streaming-code-blocks", - "status": "implemented", - "summary": "Streaming code fences render as a row-capped, height-reserving placeholder inside the chat code-block frame and are highlighted exactly once when the message completes; the line count is incremental across deltas, the animation is the repo's duty-cycled skeleton sweep, and a fence that never grows is treated as history whose persisted streaming flag predates the reducer fix.", - "checks": [ - { - "path": "apps/web/src/turbo/streamingCodeBlock.tsx", - "markers": [ - "export const STREAMING_CODE_FIRST_DELTA_MS = 500;", - "export const STREAMING_CODE_MAX_PLACEHOLDER_ROWS = 24;", - "if (input.stall === \"never-started\") return \"highlighted\";", - "export function countStreamingCodeLines(code: string): number", - "export function advanceStreamingCodeLineCount(", - "export function resolveStreamingCodeBlockView(input: {", - "export function useStreamingCodeStall(code: string, isStreaming: boolean): StreamingCodeStall", - "export function StreamingCodeBlockFrame({", - "data-streaming-code-placeholder", - "data-streaming-code-spacer", - "import { Skeleton } from \"~/components/ui/skeleton\";" - ] + "path": "apps/web/src/components/files/fileTreeContextMenu.ts", + "markers": ["\"open-new-tab\"", "\"rename\"", "\"duplicate\"", "\"delete\""] }, { - "path": "apps/web/src/components/ChatMarkdown.tsx", + "path": "apps/web/src/components/files/fileTreeBulkExpansion.ts", "markers": [ - "import { StreamingCodeBlockFrame } from \"../turbo/streamingCodeBlock\";", - ""] }, { - "path": "apps/web/src/components/files/fileTreeContextMenu.test.ts", - "markers": ["fileTreeContextMenuItems"] + "path": "packages/shared/src/filePreview.test.ts", + "markers": ["isWorkspaceImagePreviewPath"] }, { - "path": "apps/web/src/components/files/fileTreeBulkExpansion.test.ts", - "markers": ["getAltChevronExpansion", "setAllDirectoriesExpanded"] + "path": "apps/web/src/rightPanelStore.test.ts", + "markers": ["opens image files as reusable peer tabs"] } ] }, @@ -386,16 +311,113 @@ ] }, { - "id": "multi-chat-pane-workspace", + "id": "official-data-import", "status": "implemented", - "summary": "Typed, persisted chat-pane layouts keep pane controls and resource ownership in a replaceable Turbo seam.", + "summary": "Dependency-light import planning, identity remapping, staged storage, restore, and projection replay modules remain recoverable.", "checks": [ { - "path": "packages/contracts/src/settings.ts", + "path": "apps/server/src/turbo/officialImport/plan.ts", "markers": [ - "export const TurboChatPaneLayout", - "export const TurboChatPaneWeight", - "turboChatPaneLayout: CompatibleTurboChatPaneLayout" + "Schema.Literals([\"skip\", \"replace\", \"clone\"])", + "export const OfficialImportIdMap", + "export const planOfficialImport", + "export const validateOfficialImportPlan" + ] + }, + { + "path": "apps/server/src/turbo/officialImport/replay.ts", + "markers": [ + "OfficialImportProjectionVerificationError", + "rebuildOfficialImportProjections" + ] + }, + { + "path": "apps/server/src/turbo/officialImport/storage.ts", + "markers": ["prepareImportWorkspace", "cutoverImport", "restoreImportBackup"] + }, + { + "path": "apps/server/src/cli/officialImport.ts", + "markers": [ + "export const officialImportCommand", + "prepareOfficialImport", + "applyPreparedOfficialImport" + ] + }, + { + "path": "apps/server/src/bin.ts", + "markers": ["import { officialImportCommand }", "officialImportCommand,"] + }, + { + "path": "packages/contracts/src/ipc.ts", + "markers": [ + "DesktopOfficialT3ImportInputSchema", + "DesktopOfficialT3ImportResultSchema", + "discoverOfficialT3Import?:", + "runOfficialT3Import?:" + ] + }, + { + "path": "apps/desktop/src/ipc/channels.ts", + "markers": ["DISCOVER_OFFICIAL_T3_IMPORT_CHANNEL", "RUN_OFFICIAL_T3_IMPORT_CHANNEL"] + }, + { + "path": "apps/desktop/src/ipc/DesktopIpcHandlers.ts", + "markers": [ + "yield* ipc.handle(discoverOfficialT3Import)", + "yield* ipc.handle(runOfficialT3Import)" + ] + }, + { + "path": "apps/desktop/src/preload.ts", + "markers": ["discoverOfficialT3Import: () =>", "runOfficialT3Import: (input) =>"] + }, + { + "path": "apps/server/src/turbo/officialImport/plan.test.ts", + "markers": ["official import clone identity graph", "validateOfficialImportPlan"] + }, + { + "path": "apps/server/src/turbo/officialImport/storage.test.ts", + "markers": ["appendCanonicalEvents", "restoreImportBackup"] + }, + { + "path": "apps/desktop/src/ipc/methods/officialT3Environment.ts", + "markers": [ + "const executeImport = Effect.fn", + "export const runOfficialT3Import", + "yield* primary.stop();", + "snapshot.desiredRunning ? primary.start : Effect.void", + "Official T3 Code still has an active chat, turn, or approval" + ] + }, + { + "path": "apps/web/src/components/desktop/DesktopEnvironmentSwitcher.tsx", + "markers": ["bridge?.runOfficialT3Import", "Import official T3 Code"] + }, + { + "path": "docs/user/official-t3-import.md", + "markers": ["t3 import official", "Keep both", "Relay and remote clients"] + }, + { + "path": ".plans/22-t3-turbo-official-data-import.md", + "markers": [ + "# T3 Turbo One-Way Official Data Import", + "Keep Turbo and skip official", + "Keep both; import official with a new UUID" + ] + } + ] + }, + { + "id": "multi-chat-pane-workspace", + "status": "implemented", + "summary": "Typed, persisted chat-pane layouts keep pane controls and resource ownership in a replaceable Turbo seam.", + "checks": [ + { + "path": "packages/contracts/src/settings.ts", + "markers": [ + "export const TurboChatPaneLayout", + "export const TurboChatPaneWeight", + "turboChatPaneLayout: CompatibleTurboChatPaneLayout" ] }, { @@ -503,303 +525,308 @@ ] }, { - "id": "nightly-and-secret-policy", - "status": "policy", - "summary": "Daily 11 PM Eastern ingestion preserves the last known-good Turbo stack, publishes only to the fork, and excludes the local secrets note.", + "id": "terminal-scrollback-batching", + "status": "implemented", + "summary": "A terminal session keeps its scrollback as an incremental line buffer with a ~16 ms output batch instead of a string chopped and re-glued per PTY chunk; the debounce runs on a per-session fiber (never inside the shared worker, which would serialize every session's batch interval and every drain behind one another) and is enqueued once per burst rather than once per chunk, the batch is a keyed coalescing worker that flushPersist enqueues into and drains before the persist worker, every scrollback read flushes it so the string handed to clients stays byte-identical, and a dirtySincePersist flag (not the per-flush result) decides whether a write is still owed so a racing read cannot strand the tail.", "checks": [ { - "path": ".gitignore", - "markers": ["/SECRETS DO NOT COMMIT.md"] - }, - { - "path": ".t3-turbo/OPENCLAW_RULES.md", + "path": "apps/server/src/turbo/terminalHistoryBuffer.ts", "markers": [ - "ingestion starts with the last known-good T3 Turbo branch", - "Never resolve a collision with a blanket `ours`, `theirs`, force push, clean checkout", - "Never bake credentials, tokens, secrets", - "Do not publish T3 Turbo to NPM" + "export function queueTerminalHistoryChunk", + "export function flushTerminalHistoryBuffer", + "export function endTerminalHistoryStream", + "export function readTerminalHistoryBuffer", + "export function takeTerminalHistoryToPersist", + "dirtySincePersist" ] }, { - "path": ".github/workflows/turbo-nightly-sync.yml", + "path": "apps/server/src/turbo/terminalHistoryBuffer.test.ts", "markers": [ - "cron: \"0 23 * * *\"", - "timezone: \"America/New_York\"", - "Resolve the completed Eastern cutoff", - "-f until=\"$CUTOFF_INSTANT\"", - "Rebase in an isolated worktree", - "apps/web/src/turbo/chatPanes/chatPaneResourcePolicy.test.ts", - "scripts/turbo-product-branding.test.ts", - "Record registered relay and portal branch state", - "Measure registered relay and portal branch state", - "report_repair:", - "Record the reviewed repair and PR path", - "Create nightly completion report", - "T3CODE_DESKTOP_UPDATE_REPOSITORY", - "--title \"T3 Turbo $CUTOFF_LABEL.exe\"", - "--force-with-lease", - "TURBO_CUTOFF_INSTANT: ${{ inputs.cutoff_instant }}" + "matches upstream across the cap-trim boundary", + "matches upstream's graceful degradation for a non-positive cap", + "is byte-identical however the recorded stream is chunked and batched", + "is byte-identical however the real stream is chunked and batched", + "still owes the persist when a read flushed the batch before the batch tick", + "import { capHistory, sanitizeTerminalHistoryChunk } from \"../terminal/Manager.ts\";" ] }, { - "path": "scripts/turbo-nightly-sync.ts", + "path": "apps/server/src/terminal/Manager.ts", "markers": [ - "nextTurboVersion", - "selectTurboVersionBase", - "resolveTurboCutoffOverride", - "Refusing to move the recorded official Nightly release backward." + "const DEFAULT_HISTORY_BATCH_MS = 16;", + "historyBuffer: TerminalHistoryBuffer;", + "queueTerminalHistoryChunk(session.historyBuffer, nextEvent.data);", + "historyBatchWorker.drainKey(sessionKey);", + "readTerminalHistoryBuffer(session.historyBuffer)", + "takeTerminalHistoryToPersist(session.historyBuffer)", + "export function sanitizeTerminalHistoryChunk", + "export function capHistory", + "historyBatchScheduled: boolean;", + "if (session.historyBatchScheduled) {", + "session.historyBatchScheduled = false;" ] }, { - "path": "docs/internals/t3-turbo-nightly-inbound.md", - "markers": [ - "replays our Turbo commit", - "Every day at 11:00 PM", - "installer only in `gfsaaser24/t3code`", - "last known-good release" - ] + "path": "SEAM.md", + "markers": ["**Tuned** `apps/server/src/terminal/Manager.ts`"] } ] }, { - "id": "official-data-import", + "id": "sqlite-fast-mode-pragma", "status": "implemented", - "summary": "Dependency-light import planning, identity remapping, staged storage, restore, and projection replay modules remain recoverable.", + "summary": "The single sqlite setup layer applies the standard WAL companion PRAGMA synchronous = NORMAL to every connection, leaving foreign_keys and journal_mode untouched.", "checks": [ { - "path": "apps/server/src/turbo/officialImport/plan.ts", + "path": "apps/server/src/persistence/Layers/Sqlite.ts", "markers": [ - "Schema.Literals([\"skip\", \"replace\", \"clone\"])", - "export const OfficialImportIdMap", - "export const planOfficialImport", - "export const validateOfficialImportPlan" + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA synchronous = NORMAL;" ] }, { - "path": "apps/server/src/turbo/officialImport/replay.ts", + "path": "apps/server/src/persistence/Layers/SqlitePragmas.test.ts", "markers": [ - "OfficialImportProjectionVerificationError", - "rebuildOfficialImportProjections" + "in-memory persistence enables synchronous=NORMAL and keeps foreign_keys on", + "file-backed persistence keeps WAL and applies synchronous=NORMAL" ] }, { - "path": "apps/server/src/turbo/officialImport/storage.ts", - "markers": ["prepareImportWorkspace", "cutoverImport", "restoreImportBackup"] - }, + "path": "SEAM.md", + "markers": ["**Tuned** `apps/server/src/persistence/Layers/Sqlite.ts`"] + } + ] + }, + { + "id": "cheap-timestamp-and-sort-keys", + "status": "implemented", + "summary": "Product timestamps compare as plain fixed-width ISO strings instead of through the ICU collator, and the sidebar bucket sorts plus the keyless pinned block resolve each row's sort key once instead of per comparison.", + "checks": [ { - "path": "apps/server/src/cli/officialImport.ts", + "path": "apps/web/src/session-logic.ts", "markers": [ - "export const officialImportCommand", - "prepareOfficialImport", - "applyPreparedOfficialImport" + "import { compareIsoTimestamps } from \"@t3tools/client-runtime/state/thread-activity-order\"", + "compareIsoTimestamps(left.createdAt, right.createdAt)", + "compareIsoTimestamps(left.updatedAt, right.updatedAt)", + "compareIsoTimestamps(a.createdAt, b.createdAt)", + "compareIsoTimestamps(a.completedAt, b.completedAt)" ] }, { - "path": "apps/server/src/bin.ts", - "markers": ["import { officialImportCommand }", "officialImportCommand,"] - }, - { - "path": "packages/contracts/src/ipc.ts", + "path": "apps/web/src/components/Sidebar.logic.ts", "markers": [ - "DesktopOfficialT3ImportInputSchema", - "DesktopOfficialT3ImportResultSchema", - "discoverOfficialT3Import?:", - "runOfficialT3Import?:" + "createdAtMs: parseTimestampMs(thread.createdAt)", + "right.createdAtMs - left.createdAtMs", + "settledAtMs: timestamp === null ? 0 : parseTimestampMs(timestamp)", + "right.settledAtMs - left.settledAtMs", + "export function sortSnoozedThreadsForSidebar", + "wakeAtMs: firstValidTimestampMs(thread.snoozedUntil ?? null)" ] }, { - "path": "apps/desktop/src/ipc/channels.ts", - "markers": ["DISCOVER_OFFICIAL_T3_IMPORT_CHANNEL", "RUN_OFFICIAL_T3_IMPORT_CHANNEL"] + "path": "apps/web/src/components/Sidebar.tsx", + "markers": ["snoozedThreads: sortSnoozedThreadsForSidebar(snoozed)"] }, { - "path": "apps/desktop/src/ipc/DesktopIpcHandlers.ts", + "path": "packages/client-runtime/src/state/threadSort.ts", "markers": [ - "yield* ipc.handle(discoverOfficialT3Import)", - "yield* ipc.handle(runOfficialT3Import)" + "const leftCreatedAt = left.createdAt;", + "leftCreatedAt > rightCreatedAt", + "function isCanonicalIsoTimestamp(value: string): boolean", + "isCanonicalIsoTimestamp(leftCreatedAt) && isCanonicalIsoTimestamp(rightCreatedAt)" ] }, { - "path": "apps/desktop/src/preload.ts", - "markers": ["discoverOfficialT3Import: () =>", "runOfficialT3Import: (input) =>"] + "path": "packages/client-runtime/src/state/threadSortPinnedKeyless.test.ts", + "markers": [ + "legacySortPinnedThreadsByOrderKey", + "emits the pre-swap order for product-minted timestamps, ties included", + "emits the pre-swap order for non-canonical and malformed stamps too" + ] }, { - "path": "apps/server/src/turbo/officialImport/plan.test.ts", - "markers": ["official import clone identity graph", "validateOfficialImportPlan"] + "path": "apps/web/src/turbo/sortOrderEquivalence.test.ts", + "markers": [ + "orders every pair of product-minted timestamps exactly as localeCompare did", + "emits the pre-decorate order, ties included", + "sortSnoozedThreadsForSidebar (snoozed shelf)" + ] }, { - "path": "apps/server/src/turbo/officialImport/storage.test.ts", - "markers": ["appendCanonicalEvents", "restoreImportBackup"] - }, - { - "path": "apps/desktop/src/ipc/methods/officialT3Environment.ts", - "markers": [ - "const executeImport = Effect.fn", - "export const runOfficialT3Import", - "yield* primary.stop();", - "snapshot.desiredRunning ? primary.start : Effect.void", - "Official T3 Code still has an active chat, turn, or approval" - ] - }, - { - "path": "apps/web/src/components/desktop/DesktopEnvironmentSwitcher.tsx", - "markers": ["bridge?.runOfficialT3Import", "Import official T3 Code"] - }, - { - "path": "docs/user/official-t3-import.md", - "markers": ["t3 import official", "Keep both", "Relay and remote clients"] - }, - { - "path": ".plans/22-t3-turbo-official-data-import.md", + "path": "SEAM.md", "markers": [ - "# T3 Turbo One-Way Official Data Import", - "Keep Turbo and skip official", - "Keep both; import official with a new UUID" + "**Tuned** `apps/web/src/session-logic.ts`", + "**Tuned** `apps/web/src/components/Sidebar.logic.ts`", + "**Tuned** `packages/client-runtime/src/state/threadSort.ts`" ] } ] }, { - "id": "openrouter-first-party", + "id": "cheap-message-unpacking", "status": "implemented", - "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", + "summary": "TrimmedString trims through the pure both-directions transform instead of allocating an Effect per value, and ForwardCompatibleArray decodes each element once instead of twice while keeping per-element drop-on-failure and a debug log.", "checks": [ { - "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", + "path": "packages/contracts/src/baseSchemas.ts", "markers": [ - "OPENROUTER_OWNED_ENV_KEYS", - "buildOpenRouterProcessEnv", - "withOpenRouterAdapterIdentity" + "SchemaTransformation.transform({", + "decode: (value) => value.trim(),", + "encode: (value) => value.trim(),", + "Schema.toType(Schema.Array(element))", + "Effect.logDebug(\"ForwardCompatibleArray dropped undecodable elements\"", + "new SchemaIssue.Pointer([index], error.issue)" ] }, { - "path": "apps/server/src/provider/openrouter/OpenRouterModels.ts", - "markers": ["fetchOpenRouterModels", "FALLBACK_OPENROUTER_MODELS"] - }, - { - "path": "apps/server/src/provider/Drivers/OpenRouterDriver.ts", + "path": "packages/contracts/src/turbo/baseSchemas.test.ts", "markers": [ - "withOpenRouterAdapterIdentity(", - "buildOpenRouterProcessEnv(effectiveConfig, baseEnv)" + "trims on the encode-without-decode path too", + "drops elements this build cannot decode and keeps the rest", + "names the failing element's index when encoding fails", + "decodes each element exactly once" ] }, { - "path": "apps/server/src/provider/Layers/OpenRouterProvider.ts", - "markers": ["checkOpenRouterProviderStatus", "makePendingOpenRouterProvider"] - }, - { - "path": "apps/server/src/provider/builtInDrivers.ts", - "markers": ["OpenRouterDriver"] - }, - { - "path": "packages/contracts/src/settings.ts", - "markers": ["export const OpenRouterSettings"] - }, - { - "path": "packages/contracts/src/model.ts", - "markers": ["OPENROUTER_DRIVER_KIND"] - }, - { - "path": "apps/web/src/components/settings/providerDriverMeta.ts", - "markers": ["label: \"OpenRouter\""] - }, - { - "path": "apps/web/src/session-logic.ts", - "markers": ["label: \"OpenRouter\""] + "path": "SEAM.md", + "markers": ["**Tuned** `packages/contracts/src/baseSchemas.ts`"] } ] }, { - "id": "pooled-subscription-frame", + "id": "deferred-streaming-code-blocks", "status": "implemented", - "summary": "Durable RPC subscriptions pool one frame (16 ms) of already-arrived stream items inside the per-session stream and release them as a single chunk, so a burst costs the screen one blip per frame instead of one per item; the pool is created and shut down with the session, and the \"synchronized\" connection marker is never held back by the window; subscriptions whose items are distinct facts rather than cumulative state (preview events, automation requests) bypass the pool entirely, because a chunk-collapsing atom consumer would otherwise drop all but the last item of a window.", + "summary": "Streaming code fences render as a row-capped, height-reserving placeholder inside the chat code-block frame and are highlighted exactly once when the message completes; the line count is incremental across deltas, the animation is the repo's duty-cycled skeleton sweep, and a fence that never grows is treated as history whose persisted streaming flag predates the reducer fix.", "checks": [ { - "path": "packages/client-runtime/src/rpc/client.ts", + "path": "apps/web/src/turbo/streamingCodeBlock.tsx", "markers": [ - "export const POOL_WINDOW: Duration.Input = \"16 millis\";", - "export const flushesImmediately = (item: unknown): boolean =>", - "Effect.uninterruptibleMask((restore) =>", - "(item as { readonly kind: unknown }).kind === \"synchronized\";", - "function poolWithinFrame(stream: Stream.Stream): Stream.Stream {", - "const NON_CUMULATIVE_SUBSCRIPTION_TAGS: ReadonlySet = new Set([", - "WS_METHODS.subscribePreviewEvents,", - "WS_METHODS.previewAutomationConnect,", - "NON_CUMULATIVE_SUBSCRIPTION_TAGS.has(tag) ? items : poolWithinFrame(items)" + "export const STREAMING_CODE_FIRST_DELTA_MS = 500;", + "export const STREAMING_CODE_MAX_PLACEHOLDER_ROWS = 24;", + "if (input.stall === \"never-started\") return \"highlighted\";", + "export function countStreamingCodeLines(code: string): number", + "export function advanceStreamingCodeLineCount(", + "export function resolveStreamingCodeBlockView(input: {", + "export function useStreamingCodeStall(code: string, isStreaming: boolean): StreamingCodeStall", + "export function StreamingCodeBlockFrame({", + "data-streaming-code-placeholder", + "data-streaming-code-spacer", + "import { Skeleton } from \"~/components/ui/skeleton\";" ] }, { - "path": "packages/client-runtime/src/turbo/streamPoolTestClock.ts", + "path": "apps/web/src/components/ChatMarkdown.tsx", "markers": [ - "import { POOL_WINDOW } from \"../rpc/client.ts\";", - "const MAX_POOL_WINDOWS = 12;", - "export const awaitPooled = (effect: Effect.Effect): Effect.Effect =>" + "import { StreamingCodeBlockFrame } from \"../turbo/streamingCodeBlock\";", + "", + "Effect.uninterruptibleMask((restore) =>", + "(item as { readonly kind: unknown }).kind === \"synchronized\";", + "function poolWithinFrame(stream: Stream.Stream): Stream.Stream {", + "const NON_CUMULATIVE_SUBSCRIPTION_TAGS: ReadonlySet = new Set([", + "WS_METHODS.subscribePreviewEvents,", + "WS_METHODS.previewAutomationConnect,", + "NON_CUMULATIVE_SUBSCRIPTION_TAGS.has(tag) ? items : poolWithinFrame(items)" ] }, { - "path": "apps/web/src/components/files/FilePreviewPanel.tsx", - "markers": ["function WorkspaceImagePreview", "isWorkspaceImagePreviewPath(relativePath)"] + "path": "packages/client-runtime/src/turbo/streamPoolTestClock.ts", + "markers": [ + "import { POOL_WINDOW } from \"../rpc/client.ts\";", + "const MAX_POOL_WINDOWS = 12;", + "export const awaitPooled = (effect: Effect.Effect): Effect.Effect =>" + ] }, { - "path": "apps/web/src/rightPanelStore.ts", - "markers": ["const fileSurface =", "openFile: (ref, relativePath, line) =>"] + "path": "packages/client-runtime/src/turbo/streamPool.test.ts", + "markers": [ + "drops pooled leftovers when the session dies", + "releases the synchronized marker without waiting out the window", + "releases one window's items as a single ordered chunk", + "never drops an item at a window boundary", + "never pools a subscription whose items are distinct facts", + "keeps the immediate-release bypass tied to the contracts literal" + ] }, { - "path": "packages/shared/src/filePreview.test.ts", - "markers": ["isWorkspaceImagePreviewPath"] + "path": "packages/client-runtime/src/state/threads-sync.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] }, { - "path": "apps/web/src/rightPanelStore.test.ts", - "markers": ["opens image files as reusable peer tabs"] + "path": "packages/client-runtime/src/state/threads-pagination.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] + }, + { + "path": "packages/client-runtime/src/state/shell-sync.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] + }, + { + "path": "packages/client-runtime/src/state/server.test.ts", + "markers": ["import { awaitPooled } from \"../turbo/streamPoolTestClock.ts\";"] + }, + { + "path": "SEAM.md", + "markers": ["**Tuned** `packages/client-runtime/src/rpc/client.ts`"] + } + ] + }, + { + "id": "settled-lifecycle-sticky-pin", + "status": "implemented", + "summary": "Un-settling a thread is durable: the decider no longer spends the keep-active pin on activity (it still wakes explicitly settled threads). PARTIALLY RETIRED 2026-08-16: upstream #5880's autoSettleOnMerge toggle superseded the fork's merged-PR updatedAt gate, so threadSettled/contracts/GitManager and the web+mobile updatedAt threading now track upstream. Only the decider pin remains fork-owned. Upstream candidate for the pin: pingdotgg/t3code#5575; drop when upstream lands a sticky un-settle.", + "checks": [ + { + "path": "apps/server/src/orchestration/decider.ts", + "markers": [ + "deliberately sticky", + "if (targetThread.settledOverride === \"settled\") {", + "thread.settledOverride !== \"settled\" || !isSessionActivity", + "thread.settledOverride !== \"settled\" || !wakesSettledThread" + ] + } + ] + }, + { + "id": "openrouter-first-party", + "status": "implemented", + "summary": "First-class OpenRouter provider, adapted from upstream PR pingdotgg/t3code#4125 (closed upstream in favor of the docs recipe and pending orchestrator v2). Rides the Claude Agent CLI as its runtime. Designed to survive v2: the transport core (env ownership, model catalog, settings bridge) lives in provider/openrouter and touches no V1 adapter contract; the Claude adapter is decorated (withOpenRouterAdapterIdentity), never modified. Only the V1 ProviderDriver shim (Drivers/OpenRouterDriver.ts) retires at the v2 cutover, to be rewritten as a small instance flavor feeding the same env into ClaudeAdapterV2, which already imports the identical env plumbing.", + "checks": [ + { + "path": "apps/server/src/provider/openrouter/OpenRouterRuntime.ts", + "markers": [ + "OPENROUTER_OWNED_ENV_KEYS", + "buildOpenRouterProcessEnv", + "withOpenRouterAdapterIdentity" + ] + }, + { + "path": "apps/server/src/provider/openrouter/OpenRouterModels.ts", + "markers": ["fetchOpenRouterModels", "FALLBACK_OPENROUTER_MODELS"] + }, + { + "path": "apps/server/src/provider/Drivers/OpenRouterDriver.ts", + "markers": [ + "withOpenRouterAdapterIdentity(", + "buildOpenRouterProcessEnv(effectiveConfig, baseEnv)" + ] + }, + { + "path": "apps/server/src/provider/Layers/OpenRouterProvider.ts", + "markers": ["checkOpenRouterProviderStatus", "makePendingOpenRouterProvider"] + }, + { + "path": "apps/server/src/provider/builtInDrivers.ts", + "markers": ["OpenRouterDriver"] + }, + { + "path": "packages/contracts/src/settings.ts", + "markers": ["export const OpenRouterSettings"] + }, + { + "path": "packages/contracts/src/model.ts", + "markers": ["OPENROUTER_DRIVER_KIND"] + }, + { + "path": "apps/web/src/components/settings/providerDriverMeta.ts", + "markers": ["label: \"OpenRouter\""] + }, + { + "path": "apps/web/src/session-logic.ts", + "markers": ["label: \"OpenRouter\""] + } + ] + }, + { + "id": "release-from-turbo-branch", + "status": "policy", + "summary": "Scheduled and dispatched releases build, tag, and finalize from the fork's turbo mainline instead of the main branch that only tracks upstream, so published installers carry the fork's code and version.", + "checks": [ + { + "path": ".github/workflows/release.yml", + "markers": [ + "TURBO_RELEASE_BRANCH: ${{ github.repository == 'gfsaaser24/t3code' && 'turbo' || 'main' }}", + "TURBO_RELEASE_REF: ${{ (github.event_name != 'push' && github.repository == 'gfsaaser24/t3code') && 'turbo' || github.sha }}", + "ref: ${{ env.TURBO_RELEASE_REF }}", + "ref: ${{ steps.release_ref.outputs.sha }}", + "ref: ${{ env.TURBO_RELEASE_BRANCH }}", + "git push origin \"HEAD:${TURBO_RELEASE_BRANCH}\"" + ] } ] }